feat(server): add a company material list with list/add endpoints

Nests, drawings and releases store material and grade as free text, so
there was no canonical list for clients to suggest from or to fix
spelling against. Schema v10 adds materials and material_grades keyed by
SharedListNames.Key, with GET/POST /api/materials. Adding is idempotent
by key and keeps the first spelling.

The tables start empty: the materials already stored did not import
cleanly, so the list is filled through the API instead. Existing
free-text columns are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-21 11:32:41 -04:00
1 parent 3ca7d6185f
commit 5ecdc98581
11 files changed
+382 -8

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
namespace OpenNest.Data;
/// <summary>
/// Wire shape of one material in the nest server's company material list, with its
/// grades. Names keep the spelling first added; matching uses <see cref="SharedListNames.Key"/>.
/// </summary>
public sealed class MaterialListEntry
{
public string Name { get; set; } = "";
public List<string> Grades { get; set; } = new();
}
/// <summary>
/// Body of an add to the company material list: a material and, optionally, one of its
/// grades. Adding a name or grade that is already listed (by key) changes nothing.
/// </summary>
public sealed class MaterialListAddition
{
public string? Name { get; set; }
public string? Grade { get; set; }
}
public static class MaterialListRules
{
/// <summary>The first rule <paramref name="addition"/> breaks, or null when it may be added.</summary>
public static string? GetError(MaterialListAddition? addition)
{
if (addition is null)
return "Missing material.";
if (SharedListNames.Key(addition.Name).Length == 0)
return "name is required.";
return DrawingLibraryRules.TextError("name", addition.Name, DrawingLibraryRules.MaxTextLength)
?? (addition.Grade is null ? null
: DrawingLibraryRules.TextError("grade", addition.Grade, DrawingLibraryRules.MaxTextLength));
}
/// <summary>The name as listed: trimmed, with internal whitespace runs collapsed to one space.</summary>
public static string Spelling(string? name) =>
name == null ? "" : string.Join(' ', name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
}
@@ -22,7 +22,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items);
Assert.Equal(before, NestDigest(factory.DatabasePath));
Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -43,7 +43,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(before, NestDigest(factory.DatabasePath));
// Two distinct blobs across four revisions: the bracket's notes edit and the
// plate both reuse the bracket's first package.
@@ -80,7 +80,7 @@ public sealed class DrawingLibraryDatabaseTests
{
using var factory = new ServerFactory();
using (new NestDatabase(factory.DatabasePath, pooling: false)) { }
Execute(factory.DatabasePath, "PRAGMA user_version=10");
Execute(factory.DatabasePath, "PRAGMA user_version=11");
Assert.Throws<InvalidOperationException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
}
@@ -22,7 +22,7 @@ public sealed class DrawingSetDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items);
Assert.Equal(before, Digest(factory.DatabasePath));
Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -47,7 +47,7 @@ public sealed class DrawingSetDatabaseTests
Assert.Equal(before, Digest(factory.DatabasePath));
Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing");
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
[Fact]
+153
View File
@@ -0,0 +1,153 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.Data.Sqlite;
using OpenNest.Data;
using static OpenNest.Server.Tests.DrawingLibraryFixtures;
using static OpenNest.Server.Tests.DrawingSetFixtures;
namespace OpenNest.Server.Tests;
public sealed class MaterialListTests
{
[Fact]
public void V9Upgrade_StartsEmptyAndLeavesStoredMaterialsAlone()
{
using var factory = new ServerFactory();
var nest = Guid.NewGuid();
var drawing = Record();
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
database.Insert(nest, Nest("A", " Stainless ", "304"), [1]);
database.InsertDrawing(drawing.Id, drawing, Package());
}
Execute(factory.DatabasePath, "DROP TABLE material_grades; DROP TABLE materials; PRAGMA user_version=9;");
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Empty(upgraded.ListMaterials());
Assert.Equal((" Stainless ", "304"), (upgraded.Get(nest)!.Material, upgraded.Get(nest)!.MaterialGrade));
Assert.Equal(drawing.MaterialName, upgraded.GetDrawing(drawing.Id)!.MaterialName);
}
[Fact]
public void Add_IsIdempotentByKeyAndKeepsTheFirstSpelling()
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
var (created, createdAdded) = database.AddMaterial(new MaterialListAddition { Name = " Aluminum 5000 " });
var (again, againAdded) = database.AddMaterial(new MaterialListAddition { Name = "ALUMINUM 5000" });
var (graded, gradeAdded) = database.AddMaterial(new MaterialListAddition { Name = "aluminum 5000", Grade = "5052" });
var (_, sameGradeAdded) = database.AddMaterial(new MaterialListAddition { Name = "Aluminum 5000", Grade = " 5052" });
var (both, bothAdded) = database.AddMaterial(new MaterialListAddition { Name = "Copper", Grade = "C110" });
Assert.Equal((true, false, true, false, true), (createdAdded, againAdded, gradeAdded, sameGradeAdded, bothAdded));
Assert.Equal("Aluminum 5000", created.Name);
Assert.Equal(("Aluminum 5000", 0), (again.Name, again.Grades.Count));
Assert.Equal(("Aluminum 5000", "5052"), (graded.Name, Assert.Single(graded.Grades)));
Assert.Equal(("Copper", "C110"), (both.Name, Assert.Single(both.Grades)));
Assert.Equal(new[] { "Aluminum 5000", "Copper" }, database.ListMaterials().Select(m => m.Name));
}
[Fact]
public void Add_SameGradeUnderTwoMaterials_ListsItUnderEach()
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
database.AddMaterial(new MaterialListAddition { Name = "Stainless", Grade = "Grade A" });
var (_, added) = database.AddMaterial(new MaterialListAddition { Name = "Mild Steel", Grade = "Grade A" });
Assert.True(added);
Assert.All(database.ListMaterials(), m => Assert.Equal("Grade A", Assert.Single(m.Grades)));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Add_BlankName_IsRefusedWithNoChange(string? name)
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Throws<ArgumentException>(() => database.AddMaterial(new MaterialListAddition { Name = name, Grade = "304" }));
Assert.Empty(database.ListMaterials());
}
[Fact]
public async Task Api_PostReturnsCreatedThenOkAndGetListsTheMaterial()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var first = await client.PostAsync("/api/materials", Json("""{"name":"Stainless","grade":"304"}"""));
using var repeat = await client.PostAsync("/api/materials", Json("""{"name":"stainless","grade":"304"}"""));
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
Assert.Equal(HttpStatusCode.OK, repeat.StatusCode);
var entry = (await repeat.Content.ReadFromJsonAsync<MaterialListEntry>(Options))!;
Assert.Equal(("Stainless", "304"), (entry.Name, Assert.Single(entry.Grades)));
var listed = Assert.Single((await client.GetFromJsonAsync<List<MaterialListEntry>>("/api/materials", Options))!);
Assert.Equal(("Stainless", "304"), (listed.Name, Assert.Single(listed.Grades)));
}
[Theory]
[InlineData("""{"grade":"304"}""")]
[InlineData("""{"name":" "}""")]
[InlineData("""{"name":"Stainless","density":0.29}""")]
[InlineData("""{"name":"Stainless","grade":7}""")]
[InlineData("""not json""")]
[InlineData("""null""")]
public async Task Api_RejectedAddition_IsBadRequestWithNoChange(string body)
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var response = await client.PostAsync("/api/materials", Json(body));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Empty((await client.GetFromJsonAsync<List<MaterialListEntry>>("/api/materials", Options))!);
}
[Fact]
public async Task Api_OverlongName_IsBadRequest()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
var name = new string('x', DrawingLibraryRules.MaxTextLength + 1);
using var response = await client.PostAsync("/api/materials", Json($$"""{"name":"{{name}}"}"""));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private static NestRecord Nest(string name, string material, string grade) => new()
{
Name = name,
Material = material,
MaterialGrade = grade,
Thickness = 0.25,
DateCreated = new DateTime(2026, 1, 1),
DateModified = new DateTime(2026, 1, 2),
};
private static void Execute(string path, string sql)
{
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
private static object? Scalar(string path, string sql)
{
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = sql;
return command.ExecuteScalar();
}
}
@@ -74,7 +74,7 @@ public sealed class NestMaterialGradeTests
Assert.Equal("Stainless", record.Material);
Assert.Equal("", record.MaterialGrade);
Assert.Equal("", Assert.Single(upgraded.History(id, 0, 10)!.Items).MaterialGrade);
Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
private static NestRecord Record(string name, string material, string grade) => new()
+1 -1
View File
@@ -294,7 +294,7 @@ public sealed class SetReleaseTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(10L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'set_release%' OR name LIKE 'ix_set_release%'"));
var release = upgraded.GetRelease(releaseId)!;
Assert.Equal(("Legacy notes", "Legacy operator", false), (release.Notes, release.ReleasedBy, release.IsUndone));
+39
View File
@@ -0,0 +1,39 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// <c>/api/materials</c>: the company material list. Adding is idempotent by
/// <see cref="SharedListNames.Key"/>: 201 when a material or grade was added, 200 when both
/// were already listed. Either way the reply is the material as listed.
/// </summary>
internal static class MaterialRoutes
{
private static readonly JsonSerializerOptions AdditionOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
};
public static void MapMaterials(this IEndpointRouteBuilder app)
{
app.MapGet("/api/materials", (NestDatabase db) => Results.Ok(db.ListMaterials()));
app.MapPost("/api/materials", async (HttpRequest request, NestDatabase db) =>
{
var (json, error) = await DrawingSetRoutes.ReadJson(request, "material");
if (error is not null) return error;
MaterialListAddition? addition;
try { addition = JsonSerializer.Deserialize<MaterialListAddition>(json!, AdditionOptions); }
catch (JsonException) { return Results.BadRequest("Invalid material JSON."); }
if (MaterialListRules.GetError(addition) is { } invalid)
return Results.BadRequest(invalid);
var (entry, added) = db.AddMaterial(addition!);
return added ? Results.Created("/api/materials", entry) : Results.Ok(entry);
});
}
}
+110
View File
@@ -0,0 +1,110 @@
using Microsoft.Data.Sqlite;
using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// The company material list: <c>materials</c> and each one's <c>material_grades</c>, keyed by
/// <see cref="SharedListNames.Key"/>. Nests, drawings and releases keep their own free-text
/// material and grade; the list only suggests names and fixes their spelling.
/// </summary>
public sealed partial class NestDatabase
{
// v9 -> v10. The list starts empty: it is filled through POST /api/materials, not from
// the free-text materials already stored. IF NOT EXISTS keeps the step safe on a database
// rewound to v9.
private const string MaterialSchema = """
CREATE TABLE IF NOT EXISTS materials (
key TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
createdAt TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS material_grades (
materialKey TEXT NOT NULL REFERENCES materials(key),
key TEXT NOT NULL,
name TEXT NOT NULL,
createdAt TEXT NOT NULL,
PRIMARY KEY (materialKey, key));
PRAGMA user_version=10;
""";
/// <summary>Every listed material with its grades, both in case-insensitive name order.</summary>
public IReadOnlyList<MaterialListEntry> ListMaterials()
{
lock (_sync)
{
var entries = new List<MaterialListEntry>();
var byKey = new Dictionary<string, MaterialListEntry>();
using (var command = _connection.CreateCommand())
{
command.CommandText = "SELECT key, name FROM materials ORDER BY name COLLATE NOCASE, key";
using var reader = command.ExecuteReader();
while (reader.Read())
{
var entry = new MaterialListEntry { Name = reader.GetString(1) };
byKey[reader.GetString(0)] = entry;
entries.Add(entry);
}
}
using (var command = _connection.CreateCommand())
{
command.CommandText = "SELECT materialKey, name FROM material_grades ORDER BY name COLLATE NOCASE, key";
using var reader = command.ExecuteReader();
while (reader.Read())
byKey[reader.GetString(0)].Grades.Add(reader.GetString(1));
}
return entries;
}
}
/// <summary>
/// Adds a material and, when given, one of its grades; names already listed by key keep
/// their spelling. Returns the material as now listed and whether anything was added.
/// </summary>
public (MaterialListEntry Entry, bool Added) AddMaterial(MaterialListAddition addition)
{
if (MaterialListRules.GetError(addition) is { } error)
throw new ArgumentException(error, nameof(addition));
var name = MaterialListRules.Spelling(addition.Name);
var key = SharedListNames.Key(name);
var grade = MaterialListRules.Spelling(addition.Grade);
lock (_sync)
{
using var transaction = _connection.BeginTransaction();
var now = DateTime.Now.ToString("o");
var added = InsertMaterial(key, name, now, transaction);
if (grade.Length > 0)
added |= InsertGrade(key, SharedListNames.Key(grade), grade, now, transaction);
transaction.Commit();
return (ListMaterials().Single(entry => SharedListNames.Key(entry.Name) == key), added);
}
}
private bool InsertMaterial(string key, string name, string createdAt, SqliteTransaction transaction)
{
using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "INSERT OR IGNORE INTO materials (key, name, createdAt) VALUES ($key, $name, $createdAt)";
command.Parameters.AddWithValue("$key", key);
command.Parameters.AddWithValue("$name", name);
command.Parameters.AddWithValue("$createdAt", createdAt);
return command.ExecuteNonQuery() > 0;
}
private bool InsertGrade(string materialKey, string key, string name, string createdAt, SqliteTransaction transaction)
{
using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
INSERT OR IGNORE INTO material_grades (materialKey, key, name, createdAt)
VALUES ($materialKey, $key, $name, $createdAt)
""";
command.Parameters.AddWithValue("$materialKey", materialKey);
command.Parameters.AddWithValue("$key", key);
command.Parameters.AddWithValue("$name", name);
command.Parameters.AddWithValue("$createdAt", createdAt);
return command.ExecuteNonQuery() > 0;
}
}
+7 -1
View File
@@ -346,7 +346,7 @@ public sealed partial class NestDatabase : IDisposable
command.Transaction = transaction;
command.CommandText = "PRAGMA user_version";
var version = (long)command.ExecuteScalar()!;
if (version > 9) throw new InvalidOperationException("Database schema is newer than this server.");
if (version > 10) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
@@ -419,6 +419,12 @@ public sealed partial class NestDatabase : IDisposable
command.CommandText = ReleaseUpgradeToV9;
command.ExecuteNonQuery();
}
if (version < 10)
{
// The company material list, empty until filled through the API.
command.CommandText = MaterialSchema;
command.ExecuteNonQuery();
}
transaction.Commit();
}
}
+1
View File
@@ -142,6 +142,7 @@ app.MapPost("/api/nests/{id:guid}/revisions/{revision:int}/restore", (Guid id, i
app.MapDrawingLibrary();
app.MapDrawingSets();
app.MapReleases();
app.MapMaterials();
app.Run();
+25
View File
@@ -641,6 +641,31 @@ Schema v8 adds a `materialGrade` text column (default blank) to `nests` and
A server older than v8 refuses to open a v8 database, so rolling back needs the
pre-upgrade backup.
## Material list endpoints
The server keeps the company material list: each material with its grades. Both
are keyed by `SharedListNames.Key`, so names that differ only in case or spacing
are the same entry, and an entry keeps the spelling it was first added with
(trimmed, internal spaces collapsed). Nests, drawings and releases keep their own
free-text material and grade. The list only suggests names and records the
company spelling; it does not check or rewrite stored records. A grade belongs to
one material, and the same grade name may be listed under several materials.
Wire types are `OpenNest.Data.MaterialListEntry` and `MaterialListAddition`.
| Method | Path | Body | Response |
|---|---|---|---|
| GET | `/api/materials` | — | `[{ "name", "grades": [...] }]`, materials and grades in case-insensitive name order |
| POST | `/api/materials` | `{ "name": "...", "grade": "..." }` (`grade` optional) | The material as listed: 201 when the material or grade was added, 200 when both were already listed |
Validation (400, no state change, `MaterialListRules`): a JSON object with no
other properties; `name` not blank; name and grade at most 200 characters with
no NUL characters.
Schema v10 adds empty `materials` and `material_grades` tables. Existing
records are not read into the list; fill it through `POST /api/materials`. No
other table changes. A server older than v10 refuses to open a v10 database, so
rolling back needs the pre-upgrade backup.
## Storage
`OpenNest.Server.NestDatabase` uses SQLite (`Microsoft.Data.Sqlite`) with