mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 23:11:12 -04:00
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:
1 parent
3ca7d6185f
commit
5ecdc98581
11 files changed
+382
-8
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user