diff --git a/OpenNest.Data/MaterialList.cs b/OpenNest.Data/MaterialList.cs
new file mode 100644
index 00000000..e93fa728
--- /dev/null
+++ b/OpenNest.Data/MaterialList.cs
@@ -0,0 +1,40 @@
+namespace OpenNest.Data;
+
+///
+/// Wire shape of one material in the nest server's company material list, with its
+/// grades. Names keep the spelling first added; matching uses .
+///
+public sealed class MaterialListEntry
+{
+ public string Name { get; set; } = "";
+ public List Grades { get; set; } = new();
+}
+
+///
+/// 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.
+///
+public sealed class MaterialListAddition
+{
+ public string? Name { get; set; }
+ public string? Grade { get; set; }
+}
+
+public static class MaterialListRules
+{
+ /// The first rule breaks, or null when it may be added.
+ 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));
+ }
+
+ /// The name as listed: trimmed, with internal whitespace runs collapsed to one space.
+ public static string Spelling(string? name) =>
+ name == null ? "" : string.Join(' ', name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
+}
diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
index 79361875..9f26da8e 100644
--- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
+++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
@@ -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(() => new NestDatabase(factory.DatabasePath, pooling: false));
}
diff --git a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs
index 03e2628b..8f9c11f7 100644
--- a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs
+++ b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs
@@ -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]
diff --git a/OpenNest.Server.Tests/MaterialListTests.cs b/OpenNest.Server.Tests/MaterialListTests.cs
new file mode 100644
index 00000000..5dd5fe97
--- /dev/null
+++ b/OpenNest.Server.Tests/MaterialListTests.cs
@@ -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(() => 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(Options))!;
+ Assert.Equal(("Stainless", "304"), (entry.Name, Assert.Single(entry.Grades)));
+ var listed = Assert.Single((await client.GetFromJsonAsync>("/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>("/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();
+ }
+}
diff --git a/OpenNest.Server.Tests/NestMaterialGradeTests.cs b/OpenNest.Server.Tests/NestMaterialGradeTests.cs
index 58c98653..8f58cb9a 100644
--- a/OpenNest.Server.Tests/NestMaterialGradeTests.cs
+++ b/OpenNest.Server.Tests/NestMaterialGradeTests.cs
@@ -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()
diff --git a/OpenNest.Server.Tests/SetReleaseTests.cs b/OpenNest.Server.Tests/SetReleaseTests.cs
index 9237f364..88775970 100644
--- a/OpenNest.Server.Tests/SetReleaseTests.cs
+++ b/OpenNest.Server.Tests/SetReleaseTests.cs
@@ -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));
diff --git a/OpenNest.Server/MaterialRoutes.cs b/OpenNest.Server/MaterialRoutes.cs
new file mode 100644
index 00000000..fc3590da
--- /dev/null
+++ b/OpenNest.Server/MaterialRoutes.cs
@@ -0,0 +1,39 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using OpenNest.Data;
+
+namespace OpenNest.Server;
+
+///
+/// /api/materials: the company material list. Adding is idempotent by
+/// : 201 when a material or grade was added, 200 when both
+/// were already listed. Either way the reply is the material as listed.
+///
+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(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);
+ });
+ }
+}
diff --git a/OpenNest.Server/NestDatabase.Materials.cs b/OpenNest.Server/NestDatabase.Materials.cs
new file mode 100644
index 00000000..57475de0
--- /dev/null
+++ b/OpenNest.Server/NestDatabase.Materials.cs
@@ -0,0 +1,110 @@
+using Microsoft.Data.Sqlite;
+using OpenNest.Data;
+
+namespace OpenNest.Server;
+
+///
+/// The company material list: materials and each one's material_grades, keyed by
+/// . Nests, drawings and releases keep their own free-text
+/// material and grade; the list only suggests names and fixes their spelling.
+///
+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;
+ """;
+
+ /// Every listed material with its grades, both in case-insensitive name order.
+ public IReadOnlyList ListMaterials()
+ {
+ lock (_sync)
+ {
+ var entries = new List();
+ var byKey = new Dictionary();
+ 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;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+}
diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs
index e716fcd0..420ed94a 100644
--- a/OpenNest.Server/NestDatabase.cs
+++ b/OpenNest.Server/NestDatabase.cs
@@ -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();
}
}
diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs
index e08e3b48..2a0c5784 100644
--- a/OpenNest.Server/Program.cs
+++ b/OpenNest.Server/Program.cs
@@ -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();
diff --git a/docs/nest-storage.md b/docs/nest-storage.md
index 4911bc92..8b93482d 100644
--- a/docs/nest-storage.md
+++ b/docs/nest-storage.md
@@ -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