feat(bff): documents endpoint accepts the base64 file and forwards it to the domain (refs #103)

The self-service documents endpoint takes { contentBase64, fileName, contentType }
as JSON (bsn from the token) and forwards it via IDomainClient.ProvideDocumentsAsync.
Regenerates openapi.json + the Angular client (postSelfServiceRegistrationsIdDocuments
now takes a ProvideDocumentsRequest body).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
not
2026-07-20 12:13:10 +02:00
co-authored by Claude Opus 4.8
parent 9d327bbd81
commit 4c516cdad3
4 changed files with 69 additions and 15 deletions
+19 -7
View File
@@ -35,6 +35,14 @@ export interface OpenbaarEntry {
reference: string | null; reference: string | null;
} }
export interface ProvideDocumentsRequest {
contentBase64: string;
/** @nullable */
fileName?: string | null;
/** @nullable */
contentType?: string | null;
}
export interface SubmitAccepted { export interface SubmitAccepted {
registrationId: string; registrationId: string;
status: string; status: string;
@@ -226,15 +234,19 @@ export class BffApiV1Service {
); );
} }
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>; postSelfServiceRegistrationsIdDocuments<TData = void>(id: string,
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>; provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientBodyOptions): Observable<TData>;
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>; postSelfServiceRegistrationsIdDocuments<TData = void>(id: string,
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string,
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
postSelfServiceRegistrationsIdDocuments<TData = void>( postSelfServiceRegistrationsIdDocuments<TData = void>(
id: string, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> { id: string,
provideDocumentsRequest: ProvideDocumentsRequest, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') { if (options?.observe === 'events') {
return this.http.post<TData>( return this.http.post<TData>(
`/self-service/registrations/${id}/documents`, `/self-service/registrations/${id}/documents`,
undefined,{ provideDocumentsRequest,{
...(options as Omit<NonNullable<typeof options>, 'observe'>), ...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events', observe: 'events',
} }
@@ -244,7 +256,7 @@ export class BffApiV1Service {
if (options?.observe === 'response') { if (options?.observe === 'response') {
return this.http.post<TData>( return this.http.post<TData>(
`/self-service/registrations/${id}/documents`, `/self-service/registrations/${id}/documents`,
undefined,{ provideDocumentsRequest,{
...(options as Omit<NonNullable<typeof options>, 'observe'>), ...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response', observe: 'response',
} }
@@ -253,7 +265,7 @@ export class BffApiV1Service {
return this.http.post<TData>( return this.http.post<TData>(
`/self-service/registrations/${id}/documents`, `/self-service/registrations/${id}/documents`,
undefined,{ provideDocumentsRequest,{
...(options as Omit<NonNullable<typeof options>, 'observe'>), ...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body', observe: 'body',
} }
+9 -6
View File
@@ -27,10 +27,11 @@ public interface IDomainClient
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary> /// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default); Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default);
/// <summary>Provide the documents the caller's own registration is waiting for ("documenten /// <summary>Provide (upload) the diploma the caller's own registration is waiting for ("documenten
/// aanleveren"). Owner-scoped by <paramref name="bsn"/>. Returns <c>false</c> when the domain /// aanleveren"). The file is carried base64-encoded. Owner-scoped by <paramref name="bsn"/>. Returns
/// reports the registration is unknown or not the caller's (404), so the BFF can relay a 404.</summary> /// <c>false</c> when the domain reports the registration is unknown or not the caller's (404).</summary>
Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, CancellationToken ct = default); Task<bool> ProvideDocumentsAsync(
string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default);
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary> /// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default); Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
@@ -68,10 +69,12 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return true; return true;
} }
public async Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, CancellationToken ct = default) public async Task<bool> ProvideDocumentsAsync(
string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default)
{ {
using var response = await http.PostAsJsonAsync( using var response = await http.PostAsJsonAsync(
$"registrations/{registrationId}/documents", new { bsn }, ct); $"registrations/{registrationId}/documents",
new { bsn, contentBase64, fileName, contentType }, ct);
// The domain 404s an unknown or not-owned registration; relay that rather than fail hard. // The domain 404s an unknown or not-owned registration; relay that rather than fail hard.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return false; return false;
+8 -2
View File
@@ -109,13 +109,15 @@ app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, Claim
// forwarded to the domain, which owner-scopes the action and completes the WachtOpDocumenten task; a // forwarded to the domain, which owner-scopes the action and completes the WachtOpDocumenten task; a
// registration that is unknown or not the caller's comes back 404. The real file upload + ZGW storage // registration that is unknown or not the caller's comes back 404. The real file upload + ZGW storage
// is S-10b — this is the trigger that unblocks the process. // is S-10b — this is the trigger that unblocks the process.
app.MapPost("/self-service/registrations/{id}/documents", async (string id, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) => app.MapPost("/self-service/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
{ {
var bsn = user.FindFirstValue("bsn"); var bsn = user.FindFirstValue("bsn");
if (string.IsNullOrWhiteSpace(bsn)) if (string.IsNullOrWhiteSpace(bsn))
return Results.BadRequest("The token carries no bsn claim."); return Results.BadRequest("The token carries no bsn claim.");
if (string.IsNullOrWhiteSpace(body?.ContentBase64))
return Results.BadRequest("A document is required.");
var provided = await domain.ProvideDocumentsAsync(id, bsn, ct); var provided = await domain.ProvideDocumentsAsync(id, bsn, body.ContentBase64, body.FileName, body.ContentType, ct);
return provided ? Results.NoContent() : Results.NotFound(); return provided ? Results.NoContent() : Results.NotFound();
}) })
.RequireAuthorization() .RequireAuthorization()
@@ -163,6 +165,10 @@ app.Run();
/// <summary>The behandelaar's decision on a registration.</summary> /// <summary>The behandelaar's decision on a registration.</summary>
public sealed record DecideRequest(string Besluit); public sealed record DecideRequest(string Besluit);
/// <summary>A diploma upload from the self-service portal — the file base64-encoded client-side, with
/// its name and MIME type. The bsn is taken from the DigiD token, not this body.</summary>
public sealed record ProvideDocumentsRequest(string ContentBase64, string? FileName = null, string? ContentType = null);
// Behandel (medewerker-realm) authentication + authorization wiring (ADR-0013). // Behandel (medewerker-realm) authentication + authorization wiring (ADR-0013).
internal static class BehandelAuth internal static class BehandelAuth
{ {
+33
View File
@@ -76,6 +76,16 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProvideDocumentsRequest"
}
}
},
"required": true
},
"responses": { "responses": {
"204": { "204": {
"description": "No Content" "description": "No Content"
@@ -228,6 +238,29 @@
} }
} }
}, },
"ProvideDocumentsRequest": {
"required": [
"contentBase64"
],
"type": "object",
"properties": {
"contentBase64": {
"type": "string"
},
"fileName": {
"type": [
"null",
"string"
]
},
"contentType": {
"type": [
"null",
"string"
]
}
}
},
"SubmitAccepted": { "SubmitAccepted": {
"required": [ "required": [
"registrationId", "registrationId",