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:
eho
2026-06-27 20:15:40 +02:00
co-authored by Claude Opus 4.8
parent a079d3259e
commit 0d37cc097a
9 changed files with 813 additions and 5 deletions
+19 -2
View File
@@ -46,11 +46,28 @@ public sealed record DuoLookupDto(IReadOnlyList<DuoDiplomaDto> Diplomas, ManualD
public sealed record IntakePolicyDto(int ScholingThreshold);
// --- Document upload contracts ---
public sealed record DocumentCategoryDto(
string CategoryId, string Label, string Description, bool Required,
IReadOnlyList<string> AcceptedTypes, int MaxSizeMb, bool Multiple, bool AllowPostDelivery);
public sealed record UploadCategoriesDto(IReadOnlyList<DocumentCategoryDto> Categories);
public sealed record UploadResponse(string DocumentId, string LocalId);
public sealed record UploadStatusItemDto(string LocalId, string Status, string? DocumentId);
public sealed record UploadStatusDto(IReadOnlyList<UploadStatusItemDto> Results);
// Per-category delivery intent on submit: digital categories carry their DocumentId,
// post-delivery categories carry Channel="post".
public sealed record DocumentRefDto(string CategoryId, string Channel, string? DocumentId = null);
// Submit requests carry only the fields the server re-validates (UX-only fields
// stay on the client). ponytail: a real submit would carry the full application.
public sealed record RegistratieRequest(string DiplomaHerkomst);
public sealed record RegistratieRequest(string DiplomaHerkomst, IReadOnlyList<DocumentRefDto>? Documents = null);
public sealed record IntakeRequest(int Uren);
public sealed record HerregistratieRequest(int Uren);
public sealed record HerregistratieRequest(int Uren, IReadOnlyList<DocumentRefDto>? Documents = null);
public sealed record ChangeRequestRequest(string Straat, string Postcode, string Woonplaats);
public sealed record ReferentieResponse(string Referentie);
@@ -1,4 +1,5 @@
using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents;
using BigRegister.Domain.People;
using BigRegister.Domain.Registrations;
@@ -30,4 +31,7 @@ public static class Mappers
d.Id, d.Naam, d.Instelling, d.Jaar,
DiplomaRules.ProfessionFor(d),
DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList());
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery);
}
@@ -0,0 +1,101 @@
namespace BigRegister.Api.Data;
/// <summary>
/// Stored document metadata. The demo deliberately keeps NO file content — only
/// metadata — which is enough for status/delete/link and avoids holding PII bytes
/// in memory. A real backend persists the bytes to blob storage keyed by DocumentId.
/// </summary>
public sealed record StoredDocument(
string DocumentId, string LocalId, string CategoryId, string WizardId,
string FileName, long SizeBytes, string Owner, DateTimeOffset UploadedAt)
{
public bool Linked { get; set; }
}
public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor);
/// <summary>
/// In-memory document store + audit log (no DB). ponytail: one global lock — fine
/// for a single-process demo store; swap for per-key locks if it ever serves load.
/// The audit log holds metadata only (never file content or other PII).
/// </summary>
public static class DocumentStore
{
/// The single seeded user (the demo has no real auth; ownership = this id).
public const string DemoOwner = "19012345601";
private static readonly Dictionary<string, StoredDocument> _docs = new();
private static readonly List<AuditEntry> _audit = new();
private static readonly object _gate = new();
public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, long sizeBytes, string owner)
{
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, sizeBytes, owner, DateTimeOffset.UtcNow);
lock (_gate) _docs[doc.DocumentId] = doc;
Audit("upload", doc.DocumentId, categoryId, owner);
return doc;
}
public static StoredDocument? Get(string documentId)
{
lock (_gate) return _docs.TryGetValue(documentId, out var d) ? d : null;
}
/// Status for the poll-on-return pattern: a known localId is "complete" (it
/// arrived), an unknown one is still in flight / never started.
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
{
var set = localIds.ToHashSet();
lock (_gate) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList();
}
/// Mark digital documents as linked to a finalised submission (blocks user delete).
public static void Link(IEnumerable<string> documentIds)
{
lock (_gate)
foreach (var id in documentIds)
if (_docs.TryGetValue(id, out var d)) d.Linked = true;
}
public enum DeleteResult { Ok, NotFound, Linked }
/// User delete: owner-scoped; blocked once linked to a finalised submission.
public static DeleteResult DeleteOwned(string documentId, string owner)
{
string categoryId;
lock (_gate)
{
if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound;
if (d.Linked) return DeleteResult.Linked;
categoryId = d.CategoryId;
_docs.Remove(documentId);
}
Audit("delete-user", documentId, categoryId, owner);
return DeleteResult.Ok;
}
/// Admin delete: bypasses ownership, unlinks, and (seam) flags the submission for
/// review so a caseworker is notified. Returns false if the document is unknown.
public static bool AdminDelete(string documentId, string actor)
{
string categoryId;
lock (_gate)
{
if (!_docs.TryGetValue(documentId, out var d)) return false;
categoryId = d.CategoryId;
_docs.Remove(documentId);
}
Audit("delete-admin", documentId, categoryId, actor);
return true;
}
public static void Audit(string action, string documentId, string categoryId, string actor)
{
lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
}
public static IReadOnlyList<AuditEntry> AuditLog
{
get { lock (_gate) return _audit.ToList(); }
}
}
@@ -0,0 +1,59 @@
namespace BigRegister.Domain.Documents;
/// <summary>
/// SERVER-OWNED document category config. The frontend renders these; it never
/// hardcodes file types, size limits, labels, required-ness or the post-delivery
/// flag. Adding a new category or changing a rule is a backend-only change.
/// </summary>
public sealed record DocumentCategory(
string CategoryId,
string Label,
string Description,
bool Required,
IReadOnlyList<string> AcceptedTypes,
int MaxSizeMb,
bool Multiple,
bool AllowPostDelivery);
public static class DocumentRules
{
private static readonly string[] Pdf = { "application/pdf" };
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
/// <summary>The categories that apply to a given wizard/step.</summary>
public static IReadOnlyList<DocumentCategory> CategoriesFor(string wizardId) => wizardId switch
{
"registratie" => new[]
{
new DocumentCategory("diploma", "Diplomabewijs",
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false),
new DocumentCategory("identiteit", "Identiteitsbewijs",
"Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true),
},
"herregistratie" => new[]
{
new DocumentCategory("werkervaring", "Bewijs van werkervaring",
"Upload bewijs van uw gewerkte uren (bijv. een werkgeversverklaring).", true, Pdf, 10, true, true),
new DocumentCategory("nascholing", "Nascholingscertificaten",
"Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true),
},
_ => Array.Empty<DocumentCategory>(),
};
public static DocumentCategory? Find(string wizardId, string categoryId) =>
CategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
/// <summary>
/// Authoritative upload validation (the client check is UX-only). Returns a
/// rejection reason, or null when the file is acceptable.
/// </summary>
public static string? RejectUpload(DocumentCategory? category, string contentType, long sizeBytes)
{
if (category is null) return "Onbekende documentcategorie.";
if (!category.AcceptedTypes.Contains(contentType))
return $"Bestandstype niet toegestaan voor {category.Label}.";
if (sizeBytes > (long)category.MaxSizeMb * 1024 * 1024)
return $"Bestand is groter dan {category.MaxSizeMb} MB.";
return null;
}
}
+76 -3
View File
@@ -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}",