using System.Text.Json; 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; var builder = WebApplication.CreateBuilder(args); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", new() { Title = "BIG-register BFF", Version = "v1" })); builder.Services.AddProblemDetails(); builder.Services.ConfigureHttpJsonOptions(o => { o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; }); const string SpaCors = "spa"; builder.Services.AddCors(o => o.AddPolicy(SpaCors, p => p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod())); var app = builder.Build(); app.UseSwagger(); app.UseSwaggerUI(); app.UseCors(SpaCors); // Liveness/readiness for orchestrators (k8s probes, load balancers). No data, no PII. app.MapGet("/health", () => Results.Ok(new { status = "ok" })); app.MapGet("/health/ready", () => Results.Ok(new { status = "ready" })); // Versioned prefix: additive changes (new fields) stay on v1 — the generated client // + FE parse* boundary absorb them; a breaking change introduces /api/v2 alongside. var api = app.MapGroup("/api/v1"); // --- GET: screen-shaped reads. Decisions are computed here, never on the client. --- api.MapGet("/dashboard-view", () => { var reg = SeedData.Registration; var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today)); return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(), new HerregistratieDecisionsDto(eligible, reason)); }); api.MapGet("/notes", () => SeedData.Notes.Select(n => new AantekeningDto(n.Type, n.Omschrijving, n.Datum)).ToList()); // BRP "no address" fallback would be `new BrpAddressDto(false, null)` — the seeded // citizen has one. api.MapGet("/brp/address", () => new BrpAddressDto(true, SeedData.BrpAddress.ToDto())); api.MapGet("/duo/diplomas", () => new DuoLookupDto( SeedData.Diplomas.Select(d => d.ToDto()).ToList(), new ManualDiplomaPolicyDto( DiplomaRules.ManualProfessions(), DiplomaRules.ManualQuestions().Select(q => q.ToDto()).ToList()))); api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThreshold)); // --- 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), req.Documents)) .Produces() .ProducesProblem(StatusCodes.Status422UnprocessableEntity); api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) => Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren), req.Documents)) .Produces() .ProducesProblem(StatusCodes.Status422UnprocessableEntity); api.MapPost("/intakes", (IntakeRequest req, HttpContext ctx) => Submit(ctx, "intake", SubmissionRules.RejectZeroUren(req.Uren))) .Produces() .ProducesProblem(StatusCodes.Status422UnprocessableEntity); api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) => Submit(ctx, "adreswijziging", SubmissionRules.RejectChangeRequest(req.Straat, req.Postcode))) .Produces() .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); // --- Applications (aanvragen): the system of record the dashboard reads. --- api.MapGet("/applications", () => { var now = DateTimeOffset.UtcNow; return ApplicationStore.List(DocumentStore.DemoOwner) .OrderByDescending(a => a.UpdatedAt) .Select(a => a.ToSummaryDto(now)).ToList(); }); api.MapGet("/applications/{id}", (string id) => ApplicationStore.Get(id, DocumentStore.DemoOwner) is { } a ? Results.Ok(a.ToDetailDto(DateTimeOffset.UtcNow)) : Results.NotFound()) .Produces() .Produces(StatusCodes.Status404NotFound); api.MapPost("/applications", (CreateApplicationRequest req) => { var a = ApplicationStore.Create(req.Type, DocumentStore.DemoOwner); return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow)); }) .Produces(StatusCodes.Status201Created); // Draft sync per step — idempotent; keep it debounced on the client (it is chatty). api.MapPut("/applications/{id}", (string id, DraftSyncRequest req) => ApplicationStore.SyncDraft(id, DocumentStore.DemoOwner, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds) ? Results.NoContent() : Results.NotFound()) .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound); // Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot // be withdrawn (out of scope — no "intrekken"). api.MapDelete("/applications/{id}", (string id) => { var a = ApplicationStore.Get(id, DocumentStore.DemoOwner); if (a is null) return Results.NotFound(); if (a.Submitted) return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict); ApplicationStore.Delete(id, DocumentStore.DemoOwner); return Results.NoContent(); }) .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status404NotFound); // Submit runs the server-owned rules, sets autoApprovable, and transitions the // aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case. api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx) => { var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner); if (existing is null) return Results.NotFound(); if (existing.Submitted) return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict); // Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves. (string? reject, bool autoApprovable) = existing.Type switch { "registratie" => (null, req.DiplomaHerkomst == "duo"), _ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true), }; var docs = req.Documents; if (docs is not null) DocumentStore.Link(docs.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!)); var documentIds = docs?.Where(d => d.DocumentId is not null).Select(d => d.DocumentId!).ToList(); var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds); if (submitted is null) return Results.Conflict(); app.Logger.LogInformation( "aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}", id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie); return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow))); }) .Produces() .ProducesProblem(StatusCodes.Status409Conflict) .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, IReadOnlyList? documents = null) { var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v) ? v.ToString() : "none"; if (reject is not null) { app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid); 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}", kind, reference, cid, DateTimeOffset.UtcNow); return Results.Ok(new ReferentieResponse(reference)); } // Exposed so the integration tests can spin up the app with WebApplicationFactory. public partial class Program { }