Acts on the showcase review. Four workstreams; all tests green (npm run lint, 70 FE tests, ng build, 33 backend tests). Enforcement + CI: - eslint.config.mjs bans `any` and enforces layer/context boundaries (domain ≠ Angular; herregistratie → registratie → shared, auth → shared); `npm run lint` added; ajv 6 scoped to ESLint via nested override. - .github/workflows/ci.yml: FE lint+check:tokens+test+build, backend dotnet test, and an API-client drift check. One form idiom (the headline finding): - change-request-form converged onto the wizard pattern — change-request.machine.ts (Model/Msg/reduce + value objects) + submit-change-request.ts (Result) + a real POST /api/v1/change-requests (server re-validates). Spec + story added; the detail page no longer holds an ad-hoc success signal. Resilience/observability seam: - api-client.provider.ts: request timeout, X-Correlation-Id, Idempotency-Key for writes; comments naming the retry/auth seams. - Backend logs correlation id + a no-PII submit-audit line; /api/v1 prefix + backward-compat note; client regenerated. Quick wins: - Dev tooling excluded from prod: scenario.interceptor wired only under isDevMode() (?scenario= inert in prod); debug panel @if(isDev) (tree-shaken out). - src/environments + apiBaseUrl into provideApiClient (angular.json fileReplacements). - Backend /health + /health/ready. - Debug view PII-minimised (redactProfile: name/address/DOB redacted, BIG masked). - IntakePolicyAdapter (removes inline resource in the intake wizard). - README de-staled; CLAUDE.md gains EN/NL + forms-one-idiom + lint/CI notes. - Stories: text-input, link, data-row, site-header, site-footer, change-request-form. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
113 lines
4.6 KiB
C#
113 lines
4.6 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using BigRegister.Api.Contracts;
|
|
using BigRegister.Api.Data;
|
|
using BigRegister.Domain.Diplomas;
|
|
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)))
|
|
.Produces<ReferentieResponse>()
|
|
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
|
|
|
api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) =>
|
|
Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren)))
|
|
.Produces<ReferentieResponse>()
|
|
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
|
|
|
api.MapPost("/intakes", (IntakeRequest req, HttpContext ctx) =>
|
|
Submit(ctx, "intake", SubmissionRules.RejectZeroUren(req.Uren)))
|
|
.Produces<ReferentieResponse>()
|
|
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
|
|
|
api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
|
|
Submit(ctx, "adreswijziging", SubmissionRules.RejectChangeRequest(req.Straat, req.Postcode)))
|
|
.Produces<ReferentieResponse>()
|
|
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
|
|
|
app.Run();
|
|
|
|
// 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)
|
|
{
|
|
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);
|
|
}
|
|
|
|
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 { }
|