Upload feature (a): BFF endpoints + category config + tests
- Domain/Documents: server-owned category config per wizard + authoritative
type/size validation (DocumentRules).
- Data/DocumentStore: in-memory metadata store (no file bytes/PII) + audit log;
user delete (owner-scoped, 409 once linked), admin delete (role seam via
X-Admin header), link-on-submit, poll-by-localId status.
- Program.cs: GET /uploads/categories, POST /uploads (multipart, excluded from
OpenAPI — hand-written on FE), GET /uploads/status, DELETE /uploads/{id},
DELETE /admin/uploads/{id}. Submit links digital docs + records post-delivery.
- Contracts extended (DocumentRefDto on registratie/herregistratie submit);
regenerated NSwag client + swagger.json (drift check stays green).
- Tests: 16 new (endpoints + DocumentRules); dotnet test 44/44.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ using System.Text.Json.Serialization;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Domain.Documents;
|
||||
using BigRegister.Domain.Intake;
|
||||
using BigRegister.Domain.Registrations;
|
||||
using BigRegister.Domain.Submissions;
|
||||
@@ -65,12 +66,12 @@ api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThre
|
||||
// --- POST: submits. The server is the authority; it re-validates and decides. ---
|
||||
|
||||
api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) =>
|
||||
Submit(ctx, "registratie", SubmissionRules.RejectRegistratie(req.DiplomaHerkomst)))
|
||||
Submit(ctx, "registratie", SubmissionRules.RejectRegistratie(req.DiplomaHerkomst), req.Documents))
|
||||
.Produces<ReferentieResponse>()
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) =>
|
||||
Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren)))
|
||||
Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren), req.Documents))
|
||||
.Produces<ReferentieResponse>()
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
@@ -84,12 +85,75 @@ api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
|
||||
.Produces<ReferentieResponse>()
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
// --- Document upload ---
|
||||
|
||||
// Server-owned category config per wizard. The FE renders these; it never hardcodes.
|
||||
api.MapGet("/uploads/categories", (string wizardId) =>
|
||||
new UploadCategoriesDto(DocumentRules.CategoriesFor(wizardId).Select(c => c.ToDto()).ToList()));
|
||||
|
||||
// Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded
|
||||
// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type
|
||||
// and size authoritatively; stores metadata only (no file bytes / PII held).
|
||||
api.MapPost("/uploads", async (HttpRequest request) =>
|
||||
{
|
||||
if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
|
||||
var form = await request.ReadFormAsync();
|
||||
var file = form.Files.GetFile("file");
|
||||
string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
|
||||
if (file is null || categoryId == "" || localId == "" || wizardId == "")
|
||||
return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);
|
||||
|
||||
var category = DocumentRules.Find(wizardId, categoryId);
|
||||
var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
|
||||
if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
|
||||
|
||||
var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.Length, DocumentStore.DemoOwner);
|
||||
return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId));
|
||||
})
|
||||
.ExcludeFromDescription();
|
||||
|
||||
// Poll-on-return: which of these client localIds have arrived at the BFF.
|
||||
api.MapGet("/uploads/status", (string? localIds) =>
|
||||
{
|
||||
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId);
|
||||
var results = ids.Select(id => found.TryGetValue(id, out var d)
|
||||
? new UploadStatusItemDto(id, "complete", d.DocumentId)
|
||||
: new UploadStatusItemDto(id, "unknown", null)).ToList();
|
||||
return new UploadStatusDto(results);
|
||||
});
|
||||
|
||||
// User delete: owner-scoped; 409 once linked to a finalised submission.
|
||||
api.MapDelete("/uploads/{documentId}", (string documentId) =>
|
||||
DocumentStore.DeleteOwned(documentId, DocumentStore.DemoOwner) switch
|
||||
{
|
||||
DocumentStore.DeleteResult.Ok => Results.NoContent(),
|
||||
DocumentStore.DeleteResult.Linked => Results.Problem(
|
||||
detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.",
|
||||
statusCode: StatusCodes.Status409Conflict),
|
||||
_ => Results.NotFound(),
|
||||
})
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Admin delete (seam): a real system requires an admin role; here an X-Admin header
|
||||
// stands in. Bypasses ownership, unlinks, and flags the submission for review.
|
||||
api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) =>
|
||||
!IsAdmin(ctx) ? Results.StatusCode(StatusCodes.Status403Forbidden)
|
||||
: DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound())
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.Run();
|
||||
|
||||
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
|
||||
|
||||
// Audit + outcome for a submit, with NO personal data: only kind, outcome,
|
||||
// generated reference and the caller's correlation id (the observability seam — a
|
||||
// real system ships this to structured logging / an audit store).
|
||||
IResult Submit(HttpContext ctx, string kind, string? reject)
|
||||
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null)
|
||||
{
|
||||
var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
|
||||
? v.ToString()
|
||||
@@ -101,6 +165,15 @@ IResult Submit(HttpContext ctx, string kind, string? reject)
|
||||
return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||
}
|
||||
|
||||
if (documents is not null)
|
||||
{
|
||||
// Link digital documents (blocks later user delete) and record post-delivery
|
||||
// intent so a caseworker knows to expect the physical document.
|
||||
DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
|
||||
foreach (var d in documents.Where(d => d.Channel == "post"))
|
||||
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
|
||||
}
|
||||
|
||||
var reference = SubmissionRules.NewReference();
|
||||
app.Logger.LogInformation(
|
||||
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
|
||||
|
||||
Reference in New Issue
Block a user