mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 05:01:05 -04:00
A release can now cover several sets, each with its own kit count, so parts
from different kits share sheets. It can also merge into an existing draft
nest instead of always creating one. Schema v9 replaces the single-set
release tables: releases, their set lines, their nests (created or merged,
with the nest revision before and after) and the drawing quantities each set
added to each nest, so a kit stays traceable once kits share a nest. The v8
migration turns each existing release into one set line with created nests.
Undo runs on the server in one transaction: created nests are soft-deleted
and merged nests restored to their pre-merge revision, but only while each
still holds what the release wrote. Otherwise nothing changes and the
changed nests are listed, so the operator can skip them. A nest restored to
that revision counts as unchanged, which lets releases sharing a nest be
undone newest first.
The old /api/sets/{id}/releases and /api/nests/{id}/release routes are
replaced by /api/releases; no tagged desktop used them. The desktop still
releases one set at a time until the release dialog is reworked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
119 lines
5.8 KiB
C#
119 lines
5.8 KiB
C#
using System.Text;
|
|
using OpenNest.Data;
|
|
|
|
namespace OpenNest.Server;
|
|
|
|
/// <summary>
|
|
/// <c>/api/sets</c>: named, versioned lists of library drawings with quantities, as JSON
|
|
/// (their releases are <see cref="ReleaseRoutes"/>). Existing-set
|
|
/// writes follow the drawing If-Match contract (428 missing, 400 malformed, 409 stale or
|
|
/// wrong state with the current record).
|
|
/// </summary>
|
|
internal static class DrawingSetRoutes
|
|
{
|
|
public static void MapDrawingSets(this IEndpointRouteBuilder app)
|
|
{
|
|
app.MapGet("/api/sets/query", (HttpRequest request, NestDatabase db) =>
|
|
DrawingSetRequest.TryParseQuery(request.Query, out var query, out var error)
|
|
? Results.Ok(db.QueryDrawingSets(query))
|
|
: Results.BadRequest(error));
|
|
|
|
app.MapGet("/api/sets/{id:guid}", (Guid id, NestDatabase db) =>
|
|
db.GetDrawingSet(id) is { } record ? Results.Ok(record) : Results.NotFound());
|
|
|
|
app.MapGet("/api/sets/{id:guid}/revisions", (Guid id, HttpRequest request, NestDatabase db) =>
|
|
!DrawingLibraryRequest.TryReadPaging(request.Query, out var offset, out var limit, out var error)
|
|
? Results.BadRequest(error)
|
|
: db.DrawingSetHistory(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound());
|
|
|
|
app.MapGet("/api/sets/{id:guid}/revisions/{revision:int}", (Guid id, int revision, NestDatabase db) =>
|
|
revision < 1 ? Results.BadRequest("Invalid revision.") :
|
|
db.GetDrawingSetRevision(id, revision) is { } record ? Results.Ok(record) : Results.NotFound());
|
|
|
|
app.MapGet("/api/drawings/{id:guid}/sets", (Guid id, HttpRequest request, NestDatabase db) =>
|
|
!DrawingLibraryRequest.TryReadPaging(request.Query, out var offset, out var limit, out var error)
|
|
? Results.BadRequest(error)
|
|
: db.DrawingSetsContaining(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound());
|
|
|
|
// JSON set with members; the client allocates the id.
|
|
app.MapPost("/api/sets", async (HttpRequest request, NestDatabase db) =>
|
|
{
|
|
var (record, error) = await ReadSet(request);
|
|
if (error is not null) return error;
|
|
if (record!.Id == Guid.Empty)
|
|
return Results.BadRequest("id is required: allocate it before the first save.");
|
|
try { return Results.Ok(db.InsertDrawingSet(record.Id, record)); }
|
|
catch (DrawingSetConflictException conflict) { return Conflict(conflict); }
|
|
catch (ArgumentException invalid) { return Results.BadRequest(invalid.Message); }
|
|
});
|
|
|
|
app.MapPut("/api/sets/{id:guid}", async (Guid id, HttpRequest request, NestDatabase db) =>
|
|
{
|
|
var precondition = IfMatch.Read(request, out var expected);
|
|
if (precondition is not null) return precondition;
|
|
var (record, error) = await ReadSet(request);
|
|
if (error is not null) return error;
|
|
if (record!.Id != Guid.Empty && record.Id != id)
|
|
return Results.BadRequest("Set id does not match the route.");
|
|
try
|
|
{
|
|
var updated = db.UpdateDrawingSet(id, record, expected);
|
|
return updated is null ? Results.NotFound() : Results.Ok(updated);
|
|
}
|
|
catch (DrawingSetConflictException conflict) { return Conflict(conflict); }
|
|
catch (ArgumentException invalid) { return Results.BadRequest(invalid.Message); }
|
|
});
|
|
|
|
app.MapPost("/api/sets/{id:guid}/archive", (Guid id, HttpRequest request, NestDatabase db) =>
|
|
SetArchived(id, true, request, db));
|
|
|
|
app.MapPost("/api/sets/{id:guid}/restore", (Guid id, HttpRequest request, NestDatabase db) =>
|
|
SetArchived(id, false, request, db));
|
|
}
|
|
|
|
private static IResult SetArchived(Guid id, bool archived, HttpRequest request, NestDatabase db)
|
|
{
|
|
var precondition = IfMatch.Read(request, out var expected);
|
|
if (precondition is not null) return precondition;
|
|
try
|
|
{
|
|
var changed = db.SetDrawingSetArchived(id, archived, expected);
|
|
return changed is null ? Results.NotFound() : Results.Ok(changed);
|
|
}
|
|
catch (DrawingSetConflictException conflict) { return Conflict(conflict); }
|
|
}
|
|
|
|
private static IResult Conflict(DrawingSetConflictException conflict) =>
|
|
Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict);
|
|
|
|
private static async Task<(DrawingSetRecord? Record, IResult? Error)> ReadSet(HttpRequest request)
|
|
{
|
|
var (json, error) = await ReadJson(request, "set");
|
|
if (error is not null) return (null, error);
|
|
return DrawingSetRequest.TryReadSet(json, out var record, out var invalid)
|
|
? (record, null)
|
|
: (null, Results.BadRequest(invalid));
|
|
}
|
|
|
|
internal static async Task<(string? Json, IResult? Error)> ReadJson(HttpRequest request, string what)
|
|
{
|
|
if (!request.HasJsonContentType())
|
|
return (null, Results.BadRequest($"Expected an application/json {what}."));
|
|
if (request.ContentLength > DrawingSetRules.MaxBodyBytes)
|
|
return (null, Results.BadRequest($"The {what} is too large."));
|
|
|
|
// Bounded read: a body without a length, or longer than it claims, stops at the limit.
|
|
using var body = new MemoryStream();
|
|
var chunk = new byte[81920];
|
|
int read;
|
|
while ((read = await request.Body.ReadAsync(chunk, request.HttpContext.RequestAborted)) > 0)
|
|
{
|
|
if (body.Length + read > DrawingSetRules.MaxBodyBytes)
|
|
return (null, Results.BadRequest($"The {what} is too large."));
|
|
body.Write(chunk, 0, read);
|
|
}
|
|
|
|
return (Encoding.UTF8.GetString(body.GetBuffer(), 0, (int)body.Length), null);
|
|
}
|
|
}
|