feat(backend): enforce the scholing threshold server-side (WP-69)
ADR-0001's own canonical "config value" example was unenforced: GET
/intake/policy echoed ScholingThreshold, but no request DTO carried a
scholing answer, so the server had nothing to re-validate. A crafted
POST could skip a requirement the wizard presents as mandatory.
IntakePolicy.RejectIncompleteScholing is the authority — three-valued
completeness (below threshold an answer is required; "nee" is legal and
still submits; punten only belong to a followed scholing), living in the
class that owns the constant so scripts/check-seam.sh keeps guarding the
FE/BE literal pair. Both submit paths call it; a violation 400s with
ProblemDetails and leaves the aanvraag a Concept. Gated on
Type == "intake" (the endpoint's switch lumps herregistratie with
intake, which has no scholing question), and guarded by `reject is null`
so a zero-uren submission is still decided on its merits.
Also fixes a live FE bug in the same rule: validateStep required punten
whenever scholingGevolgd was 'ja' regardless of lageUren, while the
template renders those fields only when lageUren — so answering 'ja'
then raising uren either blocked the user on an invisible field or
emitted aanvullendeScholing: undefined alongside punten. punten now
derives from aanvullendeScholing, so that combination is unrepresentable
in ValidIntake.
Note: EndpointTests' Worked_hours_submission_succeeds was itself
asserting the vulnerable payload ({ uren: 40 }, no answer) and needed a
complete answer added; the zero-hours rows are the ordering regression
net and are unmodified.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -147,6 +147,35 @@ describe('intake acceptance journeys', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('raising uren above the threshold after answering scholing drops both fields (WP-69 §6)', () => {
|
||||
// Given a journey that answered the scholing question while uren was low.
|
||||
const atReview = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'Next' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||
{ tag: 'SetAnswer', key: 'punten', value: '150' },
|
||||
{ tag: 'Next' },
|
||||
);
|
||||
expect(atReview.tag).toBe('Answering');
|
||||
expect(atReview.tag === 'Answering' && atReview.cursor).toBe(2); // review
|
||||
|
||||
// When the user goes back and raises uren above the threshold, then submits...
|
||||
const done = given(reduce, atReview)(
|
||||
{ tag: 'GaNaarStap', cursor: 1 },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
|
||||
{ tag: 'Next' }, // the now-hidden scholing question no longer blocks
|
||||
{ tag: 'Submit' },
|
||||
{ tag: 'SubmitConfirmed' },
|
||||
);
|
||||
|
||||
// Then the submission succeeds, and BOTH the stale answer and its punten are gone —
|
||||
// exactly the crafted-POST-shaped payload WP-69's server rule rejects.
|
||||
expect(done.tag).toBe('Submitted');
|
||||
expect(done.tag === 'Submitted' && done.data.aanvullendeScholing).toBeUndefined();
|
||||
expect(done.tag === 'Submitted' && done.data.punten).toBeUndefined();
|
||||
});
|
||||
|
||||
it('SetPolicy (server-owned threshold) can turn an already-answered uren into one that now requires scholing', () => {
|
||||
const atWerkStep = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
|
||||
@@ -142,6 +142,34 @@ describe('submit', () => {
|
||||
expect(withScholing.data.punten).toBe(200);
|
||||
});
|
||||
|
||||
it('does not require punten for a hidden question (WP-69 §6)', () => {
|
||||
// scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above
|
||||
// threshold — the template hides the question, so punten must not be required either.
|
||||
const good = expectTag(
|
||||
submit(answering({ buitenlandGewerkt: 'nee', uren: '1500', scholingGevolgd: 'ja' })),
|
||||
'Submitting',
|
||||
);
|
||||
expect(good.data.aanvullendeScholing).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops punten when raising uren hides the question (WP-69 §6)', () => {
|
||||
// Same stale answer, but this time punten was also filled in while uren was low.
|
||||
const good = expectTag(
|
||||
submit(
|
||||
answering({
|
||||
buitenlandGewerkt: 'nee',
|
||||
uren: '1500',
|
||||
scholingGevolgd: 'ja',
|
||||
punten: '150',
|
||||
}),
|
||||
),
|
||||
'Submitting',
|
||||
);
|
||||
// ValidIntake stays honest: neither the stale 'ja' nor its punten leak through.
|
||||
expect(good.data.aanvullendeScholing).toBeUndefined();
|
||||
expect(good.data.punten).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolve maps Submitting to Submitted on a successful submit', () => {
|
||||
const submitting = submit(answering(complete));
|
||||
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
|
||||
|
||||
@@ -112,8 +112,12 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
|
||||
if (!u.ok) errors.uren = u.error;
|
||||
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd)
|
||||
errors.scholingGevolgd = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
||||
// Nascholingspunten are only asked (and required) when scholing was followed.
|
||||
if (a.scholingGevolgd === 'ja') {
|
||||
// Nascholingspunten are only asked (and required) when the question is actually
|
||||
// visible (lageUren) AND scholing was followed — matching the template's
|
||||
// `@if (scholingZichtbaar())`. Without the `lageUren` guard, answering 'ja' and then
|
||||
// raising uren above the threshold left an error on a field the template no longer
|
||||
// renders (WP-69 §6).
|
||||
if (lageUren(a, scholingThreshold) && a.scholingGevolgd === 'ja') {
|
||||
const p = parseUren(a.punten ?? '');
|
||||
if (!p.ok) errors.punten = p.error;
|
||||
}
|
||||
@@ -142,14 +146,19 @@ function validateAll(a: Answers, scholingThreshold: number): Result<Errors, Vali
|
||||
|
||||
const werktBuitenland = a.buitenlandGewerkt === 'ja';
|
||||
const buitenland = parseUren(a.buitenlandseUren ?? '');
|
||||
// Punten are only collected when aanvullende scholing was gevolgd.
|
||||
const punten = a.scholingGevolgd === 'ja' ? parseUren(a.punten ?? '') : undefined;
|
||||
const aanvullendeScholing = lageUren(a, scholingThreshold)
|
||||
? a.scholingGevolgd === 'ja'
|
||||
: undefined;
|
||||
// Punten are derived from aanvullendeScholing, NOT the raw scholingGevolgd answer (WP-69
|
||||
// §6) — a stale 'ja' left over from when uren was low, after uren was raised above the
|
||||
// threshold, must not leak a punten value into the parsed, submitted ValidIntake.
|
||||
const punten = aanvullendeScholing === true ? parseUren(a.punten ?? '') : undefined;
|
||||
return ok({
|
||||
werktBuitenland,
|
||||
land: werktBuitenland ? a.land : undefined,
|
||||
buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,
|
||||
uren: uren.value,
|
||||
aanvullendeScholing: lageUren(a, scholingThreshold) ? a.scholingGevolgd === 'ja' : undefined,
|
||||
aanvullendeScholing,
|
||||
punten: punten?.ok ? punten.value : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -387,7 +387,14 @@ export class IntakeWizardComponent {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Submitting') return;
|
||||
this.profile.beginHerregistratie();
|
||||
const r = await this.draftSync.submit({ uren: s.data.uren });
|
||||
// WP-69: the scholing answer rides along so the server can re-validate it as the
|
||||
// authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by
|
||||
// JSON.stringify, so a wizard above the threshold sends neither field.
|
||||
const r = await this.draftSync.submit({
|
||||
uren: s.data.uren,
|
||||
aanvullendeScholing: s.data.aanvullendeScholing,
|
||||
scholingPunten: s.data.punten,
|
||||
});
|
||||
if (r.ok) {
|
||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
||||
this.profile.confirmHerregistratie();
|
||||
|
||||
+3
-2
@@ -59,7 +59,7 @@ cd backend && dotnet test # rule unit tests + endpoint integration tests
|
||||
| GET | `/api/intake/policy` | scholing threshold (config value) |
|
||||
| POST | `/api/registrations` | submit registration → reference, or 422 (manual diploma) |
|
||||
| POST | `/api/herregistraties` | submit re-registration → reference, or 422 (0 hours) |
|
||||
| POST | `/api/intakes` | submit intake → reference, or 422 (0 hours) |
|
||||
| POST | `/api/intakes` | submit intake → reference, or 422 (0 hours) / 400 (incomplete scholing answer) |
|
||||
|
||||
Rejections use **ProblemDetails (RFC 7807)** with status **422**. Every request
|
||||
carries an `X-Correlation-Id` (set by the FE fetch adapter); the backend echoes it
|
||||
@@ -77,7 +77,8 @@ semantics) is introduced as **`/api/v2`** served alongside v1 until clients migr
|
||||
|
||||
- `Diplomas/DiplomaRules.cs` — profession derivation + which policy questions apply.
|
||||
- `Registrations/HerregistratieRule.cs` — eligibility + reason + status invariant.
|
||||
- `Intake/IntakePolicy.cs` — scholing threshold.
|
||||
- `Intake/IntakePolicy.cs` — scholing threshold + completeness re-validation on submit
|
||||
(`RejectIncompleteScholing`, WP-69).
|
||||
- `Submissions/SubmissionRules.cs` — submit rejections + reference generation.
|
||||
|
||||
## Typed client (NSwag)
|
||||
|
||||
@@ -75,7 +75,12 @@ public sealed record DocumentRefDto(string CategoryId, string Channel, string? D
|
||||
// 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, IReadOnlyList<DocumentRefDto>? Documents = null);
|
||||
public sealed record IntakeRequest(int Uren);
|
||||
|
||||
// AanvullendeScholing/ScholingPunten (WP-69): the wizard's scholing answer, re-validated
|
||||
// server-side as the authority by IntakePolicy.RejectIncompleteScholing. Named
|
||||
// ScholingPunten (not Punten) — the sibling SubmitApplicationRequest is shared by all three
|
||||
// wizard types and the herregistratie wizard has its own unrelated `punten`.
|
||||
public sealed record IntakeRequest(int Uren, bool? AanvullendeScholing = null, int? ScholingPunten = null);
|
||||
public sealed record HerregistratieRequest(int Uren, IReadOnlyList<DocumentRefDto>? Documents = null);
|
||||
public sealed record ChangeRequestRequest(string Telefoon);
|
||||
|
||||
@@ -120,9 +125,12 @@ public sealed record DraftSyncRequest(
|
||||
IReadOnlyList<string>? DocumentIds = null);
|
||||
|
||||
// Submit carries only the fields the server re-validates per wizard type.
|
||||
// AanvullendeScholing/ScholingPunten (WP-69) — see IntakeRequest; intake-typed aanvragen
|
||||
// only (gated by IntakePolicy.RejectIncompleteScholing's caller), null for the others.
|
||||
public sealed record SubmitApplicationRequest(
|
||||
string? DiplomaHerkomst = null, int? Uren = null,
|
||||
IReadOnlyList<DocumentRefDto>? Documents = null);
|
||||
IReadOnlyList<DocumentRefDto>? Documents = null,
|
||||
bool? AanvullendeScholing = null, int? ScholingPunten = null);
|
||||
|
||||
public sealed record SubmitApplicationResponse(string Referentie, AanvraagStatusDto Status);
|
||||
|
||||
|
||||
@@ -4,17 +4,39 @@ namespace BigRegister.Domain.Intake;
|
||||
/// Config value (ADR-0001's "config value" shape). Below this many NL work-hours the
|
||||
/// scholing question is required. The frontend receives this value
|
||||
/// (<c>GET /intake/policy</c>) and applies it for instant UX feedback
|
||||
/// (<c>intake.machine.ts</c>'s <c>lageUren</c>).
|
||||
///
|
||||
/// WP-68 (F5): the class doc used to claim "the backend re-validates on submit as the
|
||||
/// authority" — it doesn't. Neither <c>SubmitApplicationRequest</c> nor <c>IntakeRequest</c>
|
||||
/// carries a scholing answer at all, so there is nothing for the server to re-validate;
|
||||
/// both submit paths only apply <c>SubmissionRules.RejectZeroUren</c>. A crafted POST can
|
||||
/// bypass the scholing requirement entirely. Enforcing this needs a wire change (the
|
||||
/// request DTOs must carry the wizard's scholing answer) and is deferred to WP-69 — this
|
||||
/// comment states the gap rather than a false guarantee.
|
||||
/// (<c>intake.machine.ts</c>'s <c>lageUren</c>); <see cref="RejectIncompleteScholing"/> is the
|
||||
/// backend re-validating it as the authority on submit (WP-69) — both
|
||||
/// <c>POST /applications/{id}/submit</c> (intake-typed aanvragen only) and the legacy
|
||||
/// <c>POST /intakes</c> call it before writing anything, and a violation 400s
|
||||
/// (<c>ProblemDetails</c>), never silently accepts an incomplete answer.
|
||||
/// </summary>
|
||||
public static class IntakePolicy
|
||||
{
|
||||
public const int ScholingThreshold = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Completeness rule for the scholing question (WP-69) — not merit: below
|
||||
/// <see cref="ScholingThreshold"/> an answer must be present, but "nee" is a legal answer
|
||||
/// that still submits (turning "few uren + no scholing" into a rejection is out of scope,
|
||||
/// see the WP). Three-valued, so two parameters (uren, punten) couldn't express it:
|
||||
/// <list type="bullet">
|
||||
/// <item>below threshold and no answer at all ⇒ incomplete;</item>
|
||||
/// <item>answered <c>true</c> (scholing gevolgd) ⇒ punten required and non-negative
|
||||
/// (mirrors <c>parseUren</c>);</item>
|
||||
/// <item>answered anything but <c>true</c> ⇒ punten must be absent (a stale answer left
|
||||
/// behind by raising <c>uren</c> is not a legal payload).</item>
|
||||
/// </list>
|
||||
/// Returns the Dutch detail message for a <c>400 ProblemDetails</c>, or null when complete.
|
||||
/// Boundary is <c><</c>, not <c><=</c> — mirrors <c>lageUren</c>.
|
||||
/// </summary>
|
||||
public static string? RejectIncompleteScholing(int uren, bool? aanvullendeScholing, int? scholingPunten)
|
||||
{
|
||||
if (uren < ScholingThreshold && aanvullendeScholing is null)
|
||||
return $"Beantwoord de vraag over aanvullende scholing: bij minder dan {ScholingThreshold} gewerkte uren is dit verplicht.";
|
||||
if (aanvullendeScholing == true && (scholingPunten is null || scholingPunten < 0))
|
||||
return "Vul het aantal behaalde nascholingspunten in.";
|
||||
if (aanvullendeScholing != true && scholingPunten is not null)
|
||||
return "Nascholingspunten horen alleen bij een gevolgde aanvullende scholing.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,8 +195,16 @@ api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) =>
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
api.MapPost("/intakes", (IntakeRequest req, HttpContext ctx) =>
|
||||
Submit(ctx, "intake", SubmissionRules.RejectZeroUren(req.Uren)))
|
||||
{
|
||||
// WP-69: completeness check outside Submit(...) — deliberately not folded into `reject`,
|
||||
// so this 400 is never cached in IdempotencyStore the way a 422 rejection would be.
|
||||
var reject = SubmissionRules.RejectZeroUren(req.Uren);
|
||||
if (reject is null && IntakePolicy.RejectIncompleteScholing(req.Uren, req.AanvullendeScholing, req.ScholingPunten) is { } incomplete)
|
||||
return Results.Problem(detail: incomplete, statusCode: StatusCodes.Status400BadRequest);
|
||||
return Submit(ctx, "intake", reject);
|
||||
})
|
||||
.Produces<ReferentieResponse>()
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
|
||||
@@ -361,6 +369,14 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
||||
_ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true),
|
||||
};
|
||||
|
||||
// WP-69: intake-only (herregistratie has no scholing question) — guarded by `reject is
|
||||
// null` so a { uren: 0 } submission is still decided on merit (RejectZeroUren) and
|
||||
// completeness is moot; placed before the document-ownership check and
|
||||
// ApplicationStore.Submit so a rejected submit leaves the aanvraag a Concept (retryable).
|
||||
if (existing.Type == "intake" && reject is null &&
|
||||
IntakePolicy.RejectIncompleteScholing(req.Uren ?? 0, req.AanvullendeScholing, req.ScholingPunten) is { } incompleteScholing)
|
||||
return Results.Problem(detail: incompleteScholing, statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
var docs = req.Documents;
|
||||
var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
|
||||
|
||||
|
||||
@@ -314,6 +314,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Content",
|
||||
"content": {
|
||||
@@ -2172,6 +2182,15 @@
|
||||
"uren": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"aanvullendeScholing": {
|
||||
"type": "boolean",
|
||||
"nullable": true
|
||||
},
|
||||
"scholingPunten": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2837,6 +2856,15 @@
|
||||
"$ref": "#/components/schemas/DocumentRefDto"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"aanvullendeScholing": {
|
||||
"type": "boolean",
|
||||
"nullable": true
|
||||
},
|
||||
"scholingPunten": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Applications;
|
||||
using BigRegister.Tests.Builders;
|
||||
|
||||
namespace BigRegister.Tests.Acceptance;
|
||||
|
||||
/// <summary>
|
||||
/// Behaviour-level tests for the scholing-threshold enforcement (WP-69) over both live HTTP
|
||||
/// paths — <c>POST /applications/{id}/submit</c> (the wizard's real path) and the legacy
|
||||
/// <c>POST /intakes</c> (dead from the UI, still a live crafted-POST surface). Built through
|
||||
/// the <see cref="Given"/> type-state builder, mirroring <see cref="BesluitLifecycleTests"/>
|
||||
/// rather than the full wizard/upload dance — the builder's default owner IS
|
||||
/// <see cref="BigRegister.Api.Domain.Authorization.StubIdentityProvider"/>'s default caller,
|
||||
/// so no header juggling.
|
||||
/// </summary>
|
||||
public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private static void Persist(Aanvraag aanvraag)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
db.Applications.Add(aanvraag);
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private Task<HttpResponseMessage> Submit(string id, object body) =>
|
||||
_client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", body);
|
||||
|
||||
[Fact]
|
||||
public async Task Below_threshold_without_an_answer_is_rejected_and_stays_a_concept()
|
||||
{
|
||||
// Given an intake-typed Concept aanvraag (not yet submitted).
|
||||
var aanvraag = Given.Concept(type: "intake").Build();
|
||||
Persist(aanvraag);
|
||||
|
||||
// When it is submitted with uren below the threshold and no scholing answer at all...
|
||||
var res = await Submit(aanvraag.Id, new { uren = 500 });
|
||||
|
||||
// Then the request is rejected as a contract violation (400, not a merit rejection)...
|
||||
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
||||
|
||||
// ...and the aanvraag is left a retryable Concept, never marked Submitted.
|
||||
var stillConcept = ApplicationStore.GetAny(aanvraag.Id)!;
|
||||
Assert.False(stillConcept.Submitted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Below_threshold_with_an_answer_succeeds()
|
||||
{
|
||||
// Given an intake-typed Concept.
|
||||
var aanvraag = Given.Concept(type: "intake").Build();
|
||||
Persist(aanvraag);
|
||||
|
||||
// When submitted below the threshold with "niet gevolgd" — a complete, legal answer...
|
||||
var res = await Submit(aanvraag.Id, new { uren = 500, aanvullendeScholing = false });
|
||||
|
||||
// Then the submission succeeds.
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Above_threshold_needs_no_answer()
|
||||
{
|
||||
// Given an intake-typed Concept.
|
||||
var aanvraag = Given.Concept(type: "intake").Build();
|
||||
Persist(aanvraag);
|
||||
|
||||
// When submitted with uren at/above the threshold and no scholing answer...
|
||||
var res = await Submit(aanvraag.Id, new { uren = 1000 });
|
||||
|
||||
// Then it succeeds — the question is moot above the threshold.
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Punten_without_gevolgd_is_rejected()
|
||||
{
|
||||
// Given an intake-typed Concept.
|
||||
var aanvraag = Given.Concept(type: "intake").Build();
|
||||
Persist(aanvraag);
|
||||
|
||||
// When submitted above the threshold with punten but no "gevolgd" answer — the stale
|
||||
// shape §6 fixes on the frontend, still reachable as a crafted POST...
|
||||
var res = await Submit(aanvraag.Id, new { uren = 1500, scholingPunten = 150 });
|
||||
|
||||
// Then it is rejected.
|
||||
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Herregistratie_is_unaffected_by_the_intake_only_gate()
|
||||
{
|
||||
// Given a herregistratie-typed Concept (no scholing question in that wizard).
|
||||
var aanvraag = Given.Concept(type: "herregistratie").Build();
|
||||
Persist(aanvraag);
|
||||
|
||||
// When submitted below the intake threshold with no scholing answer at all...
|
||||
var res = await Submit(aanvraag.Id, new { uren = 500 });
|
||||
|
||||
// Then it still succeeds — the gate is intake-only.
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Zero_uren_is_still_afgewezen_not_a_400()
|
||||
{
|
||||
// Given an intake-typed Concept.
|
||||
var aanvraag = Given.Concept(type: "intake").Build();
|
||||
Persist(aanvraag);
|
||||
|
||||
// When submitted with zero uren and no scholing answer — completeness would also
|
||||
// reject this, but the merit rejection (RejectZeroUren) must win (the ordering guard)...
|
||||
var res = await Submit(aanvraag.Id, new { uren = 0 });
|
||||
|
||||
// Then the submission is accepted and resolves to Afgewezen — not a 400.
|
||||
res.EnsureSuccessStatusCode();
|
||||
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
||||
Assert.Equal("Afgewezen", body.Status.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Legacy_intakes_endpoint_enforces_it_too()
|
||||
{
|
||||
// Given no aanvraag needed — the legacy endpoint mints its own reference.
|
||||
// When a crafted POST hits the dead-from-the-UI /intakes endpoint below threshold,
|
||||
// with no scholing answer...
|
||||
var res = await _client.PostAsJsonAsync("/api/v1/intakes", new { uren = 500 });
|
||||
|
||||
// Then it is rejected too — the crafted-POST surface this WP closes.
|
||||
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using BigRegister.Domain.Intake;
|
||||
|
||||
namespace BigRegister.Tests.Domain;
|
||||
|
||||
public class IntakeRuleTests
|
||||
{
|
||||
// The arguments ARE the Given (WP-69/bdd.mdx) — these degenerate to When/Then.
|
||||
|
||||
[Fact]
|
||||
public void Below_threshold_with_no_answer_is_incomplete() =>
|
||||
Assert.NotNull(IntakePolicy.RejectIncompleteScholing(999, aanvullendeScholing: null, scholingPunten: null));
|
||||
|
||||
[Fact]
|
||||
public void At_the_threshold_no_answer_is_required() =>
|
||||
// Pins `<` vs `<=` — lageUren's own boundary.
|
||||
Assert.Null(IntakePolicy.RejectIncompleteScholing(1000, aanvullendeScholing: null, scholingPunten: null));
|
||||
|
||||
[Fact]
|
||||
public void Niet_gevolgd_is_a_complete_answer_below_threshold() =>
|
||||
// "nee" is legal — this WP is completeness, not merit (§1's scope).
|
||||
Assert.Null(IntakePolicy.RejectIncompleteScholing(500, aanvullendeScholing: false, scholingPunten: null));
|
||||
|
||||
[Fact]
|
||||
public void Gevolgd_without_punten_is_incomplete() =>
|
||||
Assert.NotNull(IntakePolicy.RejectIncompleteScholing(500, aanvullendeScholing: true, scholingPunten: null));
|
||||
|
||||
[Fact]
|
||||
public void Gevolgd_with_zero_punten_is_valid() =>
|
||||
Assert.Null(IntakePolicy.RejectIncompleteScholing(500, aanvullendeScholing: true, scholingPunten: 0));
|
||||
|
||||
[Fact]
|
||||
public void Gevolgd_with_negative_punten_is_refused() =>
|
||||
Assert.NotNull(IntakePolicy.RejectIncompleteScholing(500, aanvullendeScholing: true, scholingPunten: -1));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)] // stale-punten shape (§6): raising uren above threshold left an unanswered
|
||||
// question but punten still set from when it was visible
|
||||
[InlineData(false)]
|
||||
public void Punten_without_gevolgd_is_refused(bool? aanvullendeScholing) =>
|
||||
// uren ABOVE threshold so the "missing answer" branch can't also explain the rejection —
|
||||
// this row isolates the "stale punten" rule on its own.
|
||||
Assert.NotNull(IntakePolicy.RejectIncompleteScholing(1500, aanvullendeScholing, scholingPunten: 150));
|
||||
}
|
||||
@@ -103,7 +103,10 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
|
||||
[InlineData("/api/v1/herregistraties")]
|
||||
public async Task Worked_hours_submission_succeeds(string route)
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync(route, new { uren = 40 });
|
||||
// WP-69: 40 is below IntakePolicy.ScholingThreshold, so /intakes now requires the
|
||||
// scholing question answered — `aanvullendeScholing` is unknown to (and ignored by)
|
||||
// HerregistratieRequest, so this one extra field keeps serving both rows unchanged.
|
||||
var res = await _client.PostAsJsonAsync(route, new { uren = 40, aanvullendeScholing = false });
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
|
||||
@@ -28,21 +28,179 @@ re-validates as authority) is unenforced for the one rule it was written to illu
|
||||
- `backend/src/BigRegister.Api/Program.cs` — the `intakes` and `applications/{id}/submit`
|
||||
endpoints
|
||||
|
||||
## Stale premises in the original placeholder (verified 2026-08-18)
|
||||
|
||||
- **"Both submit paths" is half-stale.** `POST /api/v1/intakes` is **dead from the UI** — the
|
||||
wizard submits via `draft-sync` → `POST /applications/{id}/submit`; no code in `apps/` or
|
||||
`libs/` calls the generated `intakes()`/`herregistraties()` methods. It is still a live
|
||||
crafted-POST surface, so fix both; do **not** delete it here (see Risks).
|
||||
- **The submit endpoint does not distinguish intake from herregistratie** —
|
||||
`Program.cs` lumps them: `_ /* herregistratie | intake */ => (RejectZeroUren(...), true)`.
|
||||
`herregistratie.machine.ts` has no scholing question, so the new check **must** be gated on
|
||||
`existing.Type == "intake"` or the herregistratie wizard starts 400-ing for every low-uren user.
|
||||
- **`RejectMissingScholing(uren, scholing)` is under-specified.** The rule is three-valued
|
||||
(answer present / `true` needs punten / punten without `true` is illegal); two parameters
|
||||
cannot express it.
|
||||
- **A live FE bug shares this rule and must be fixed here.** `intake.machine.ts:116` requires
|
||||
`punten` whenever `scholingGevolgd === 'ja'` **regardless of `lageUren`**, while the template
|
||||
renders both fields only inside `@if (scholingZichtbaar())` (= `lageUren`). Answer scholing
|
||||
`'ja'`, then raise `uren` above the threshold: either the user is blocked by an error on an
|
||||
**invisible** field, or `validateAll` emits `aanvullendeScholing: undefined` **together with**
|
||||
`punten: 150` — exactly the payload the new server rule rejects. Both branches reachable today.
|
||||
|
||||
## Decisions
|
||||
|
||||
Not yet made — this is a placeholder WP opened by WP-68, not a ready-to-implement one. Needs
|
||||
a `planner` pass before work starts. Open questions to resolve then:
|
||||
Made by a `planner` pass on 2026-08-18 — do not relitigate.
|
||||
|
||||
- The request DTOs need a scholing answer field (likely mirroring `intake.machine.ts`'s
|
||||
`ValidIntake.aanvullendeScholing`/`punten`) — this is a wire change, so it touches
|
||||
`contracts/`, the wizard's submit payload, and `npm run gen:api`.
|
||||
- Whether to add the rule to `SubmissionRules` (alongside `RejectZeroUren`) or give
|
||||
`IntakePolicy` its own `RejectMissingScholing(uren, scholing)`, matching the class that
|
||||
already owns the threshold.
|
||||
- Reading the answer out of the wizard's `Draft` JSON was rejected in WP-68 — the backend's
|
||||
documented posture is that the draft is opaque (`AppDbContext`'s header comment) — so the
|
||||
answer must arrive as an explicit request field, not be extracted from the opaque snapshot.
|
||||
### 1. What the rule is (and deliberately is not)
|
||||
|
||||
## Out of scope (for now)
|
||||
The FE rule is **completeness**, not merit: below the threshold the scholing question must be
|
||||
**answered**; `'nee'` is a legal answer that still submits. So the server authority is:
|
||||
|
||||
Implementation — this WP exists to track the gap; do not implement without a Decisions block.
|
||||
- `uren < IntakePolicy.ScholingThreshold` ⇒ an answer must be present;
|
||||
- answer `true` ⇒ punten present and `>= 0` (mirrors `parseUren`);
|
||||
- answer not `true` ⇒ punten must be **absent**.
|
||||
|
||||
**Out of scope, deliberately:** turning "few uren + no scholing" into an `Afgewezen` decision.
|
||||
The wizard accepts that today; inventing a substantive rejection would create a _new_ FE/BE
|
||||
divergence in the WP that closes one. **Boundary is `<`, not `<=`** — mirrors `lageUren`.
|
||||
|
||||
### 2. Wire shape
|
||||
|
||||
Two nullable fields appended (positionally last, defaulted) to both request records in
|
||||
`Contracts/Dtos.cs`: `bool? AanvullendeScholing = null, int? ScholingPunten = null`.
|
||||
|
||||
- **`ScholingPunten`, not `Punten`** — `SubmitApplicationRequest` is shared by all three wizard
|
||||
types and the herregistratie wizard has its own unrelated `punten`.
|
||||
- **Illegal states are representable on the wire, unrepresentable past the boundary.** A JSON
|
||||
DTO consumed by NSwag can't carry a union without hand-written polymorphism, and both fields
|
||||
must be optional for the other wizards anyway. Closure happens at the rule boundary — the same
|
||||
posture WP-68 took for `AanvraagStatus`. _Rejected:_ a nested `ScholingDto` (removes one of
|
||||
three illegal combinations, adds a DTO); a closed `ScholingAnswer` type (one call site, not
|
||||
persisted — ceremony).
|
||||
- **Not persisted.** Submit-time rule input, not aggregate state: no `Aanvraag` column, **no EF
|
||||
migration**. The draft JSON stays opaque (WP-68) — the answer arrives as an explicit field.
|
||||
|
||||
### 3. Rule home — `IntakePolicy`, not `SubmissionRules`
|
||||
|
||||
`public static string? RejectIncompleteScholing(int uren, bool? aanvullendeScholing, int? scholingPunten)`
|
||||
— same "reason or null" idiom as `SubmissionRules`, so endpoints compose both identically.
|
||||
|
||||
The rule _is_ the threshold's enforcement and the class already owns the constant. Putting it in
|
||||
`SubmissionRules` would either re-declare `1000` there (silent drift — exactly what WP-71's
|
||||
`check:seam` exists to catch, and which it would **not** catch outside `IntakePolicy.cs`) or make
|
||||
the generic cross-wizard class depend on one wizard's policy. `SubmissionRules.cs` and
|
||||
`SubmissionRuleTests.cs` are **not modified**.
|
||||
|
||||
**`check:seam` constraint (load-bearing):** `scripts/check-seam.sh` greps _all_
|
||||
`ScholingThreshold\s*=\s*[0-9]+` matches in `IntakePolicy.cs`. The new code must **reference**
|
||||
the const (`uren < ScholingThreshold`, `$"…{ScholingThreshold}…"`) and must never introduce a
|
||||
second literal (e.g. a default parameter `int scholingThreshold = 1000`) — a second match makes
|
||||
`backend_value` two lines and fails with a misleading "drift" message.
|
||||
|
||||
### 4. HTTP shape: 400 ProblemDetails, matching WP-68 F1
|
||||
|
||||
A missing/contradictory conditionally-required field is a **contract violation**, not a business
|
||||
outcome → `Results.Problem(detail: …, statusCode: 400)`. Deliberately unlike `RejectZeroUren`,
|
||||
which is a _merit_ rejection (422 legacy / `Afgewezen` + 200 on the aanvraag path).
|
||||
|
||||
**Ordering: the zero-uren rejection wins.** Guard with `reject is null &&` so `{ uren: 0 }` is
|
||||
decided on merit and completeness is moot — this keeps `EndpointTests`' 422 rows passing
|
||||
**unmodified**. Place the check **before** the document-ownership check and
|
||||
`ApplicationStore.Submit`, so a rejected submit leaves the aanvraag a Concept (retryable).
|
||||
|
||||
Gated on `existing.Type == "intake"`. `/applications/{id}/submit` already declares
|
||||
`.ProducesProblem(400)` (WP-68 F1) — no metadata change; `/intakes` needs one added, with the
|
||||
check _outside_ the `Submit(...)` helper so the 400 is not cached in `IdempotencyStore`.
|
||||
|
||||
Detail copy (Dutch, like all backend ProblemDetails — backend copy is not `$localize`d):
|
||||
missing answer → `$"Beantwoord de vraag over aanvullende scholing: bij minder dan {ScholingThreshold} gewerkte uren is dit verplicht."`;
|
||||
`true` without punten → `"Vul het aantal behaalde nascholingspunten in."`;
|
||||
punten without `true` → `"Nascholingspunten horen alleen bij een gevolgde aanvullende scholing."`
|
||||
|
||||
### 5. Backwards compatibility
|
||||
|
||||
Fields optional on the wire, conditionally required by the rule (the same DTO serves registratie
|
||||
and herregistratie, which never send them). **In-flight Concept drafts (WP-22) are unaffected** —
|
||||
the draft JSON already holds `scholingGevolgd`/`punten`, its format doesn't change, and the new FE
|
||||
derives the request fields at submit time. The one real incompatibility is a **stale FE bundle**
|
||||
submitting a below-threshold intake: it gets a 400 with an actionable Dutch detail via
|
||||
`problemDetail()`. Accepted — the POC has no API versioning, and the alternatives (grace period,
|
||||
inferring from the draft) are what WP-68 forbade. _Rejected:_ a feature flag whose only purpose
|
||||
is to leave a security gap open.
|
||||
|
||||
### 6. Frontend changes
|
||||
|
||||
- Wizard payload gains `aanvullendeScholing` + `scholingPunten` (`undefined` members are dropped
|
||||
by `JSON.stringify` and bind to `null` server-side).
|
||||
- **`intake.machine.ts` needs two narrowing edits** (see Stale premises — this is a live bug):
|
||||
`validateStep('werk')` requires punten only when `lageUren(…) && scholingGevolgd === 'ja'`
|
||||
(matching the template's `@if`), and `validateAll` computes punten from
|
||||
`aanvullendeScholing === true` rather than `scholingGevolgd === 'ja'`, so a stale answer left by
|
||||
raising `uren` can't leak into `ValidIntake`. `Answers` (the raw record) is unchanged — stale
|
||||
raw answers are fine; `ValidIntake`, the _parsed_ type, must be honest.
|
||||
- **No change** to `SCHOLING_THRESHOLD_DEFAULT`, `lageUren`, `SetPolicy`, the policy
|
||||
adapter/store, or the template. **No new `$localize` id ⇒ no `messages.en.xlf` change.**
|
||||
|
||||
### 7. Test plan (WP-71 conventions)
|
||||
|
||||
G/W/T bodies, `Domain/<Aggregate>RuleTests.cs`, `Acceptance/`, fixtures via the `Given` builder —
|
||||
never hand-built initializers.
|
||||
|
||||
**New `Domain/IntakeRuleTests.cs`** (pure rule, no HTTP; the arguments _are_ the Given, so these
|
||||
degenerate to When/Then per `bdd.mdx`): answer required below threshold; not required _at_ the
|
||||
threshold (pins `<` vs `<=`); `niet gevolgd` is a complete answer (pins §1's scope); `gevolgd`
|
||||
requires punten; zero punten valid; negative refused; `[Theory]` — punten without `gevolgd`
|
||||
refused (two rows, incl. the stale-punten shape §6 removes).
|
||||
|
||||
**New `Acceptance/IntakeSubmissionTests.cs`** (HTTP, both paths, `Given.Concept(type: "intake")`
|
||||
|
||||
- a local `Persist` mirroring `BesluitLifecycleTests`; the builder's default owner **is**
|
||||
`StubIdentityProvider`'s default caller, so no header juggling): below threshold without an
|
||||
answer → 400 **and still a Concept**; answered → 200; above threshold → 200; punten without
|
||||
gevolgd → 400; **herregistratie unaffected** (guards the `Type` gate); `{ uren: 0 }` still
|
||||
`Afgewezen` + 200, not 400 (pins the ordering); legacy `/intakes` enforces it too.
|
||||
|
||||
**Not modified:** `EndpointTests.cs` (its 422 rows are the ordering regression net),
|
||||
`SubmissionRuleTests.cs`, `ApplicationTests.cs`, `Builders/AanvraagBuilder.cs`.
|
||||
|
||||
**Frontend:** `intake.machine.spec.ts` — drops punten when raising uren hides the question; does
|
||||
not require punten for a hidden question. `intake.acceptance.spec.ts` — one journey: low uren →
|
||||
`'ja'` + punten → back → raise uren → submit → both fields `undefined`.
|
||||
|
||||
### 8. Sequencing
|
||||
|
||||
1. `IntakePolicy.RejectIncompleteScholing` + `Domain/IntakeRuleTests.cs` (red→green, no wire change).
|
||||
2. `Contracts/Dtos.cs` + both endpoints + `/intakes`' `.ProducesProblem(400)`.
|
||||
3. `Acceptance/IntakeSubmissionTests.cs`; `dotnet test`.
|
||||
4. **`npm run gen:api`** — after step 2, before the FE payload change. Commit `backend/swagger.json`
|
||||
- `libs/shared/src/infrastructure/api-client.ts`. CI's drift job fails if skipped/hand-edited.
|
||||
5. FE: `intake.machine.ts` narrowing + specs, then the wizard payload.
|
||||
6. `npm run gen:snippets` (expect no diff) and **`npm run gen:behaviour-spec`** (will diff — new
|
||||
test names; commit it or CI's drift step fails).
|
||||
7. Docs in the same diff: rewrite `IntakePolicy`'s doc-comment from "gap deferred to WP-69" to what
|
||||
it now guarantees; one line in ADR-0001 §"config value"/worked example B; `backend/README.md`'s
|
||||
`/api/intakes` row (add the 400) + its `IntakePolicy.cs` bullet.
|
||||
8. `npm run ci`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Turning "few uren + no scholing" into an `Afgewezen` **decision** (§1) — a decision flag, an FE
|
||||
change, and a separate WP.
|
||||
- Deleting the dead `/intakes` + `/herregistraties` endpoints (with `EndpointTests`,
|
||||
`backend/README.md`, `gen:api`) — real cleanup, but not this WP's security fix.
|
||||
- The herregistratie wizard's `jaren`/`punten`, equally un-re-validated server-side.
|
||||
- `docs/reference/fp-tea-atomic-design.md:587` / `ARCHITECTURE.md:464` still teach a
|
||||
`visibleSteps`-with-a-`'scholing'`-step intake the fixed-3-step wizard no longer matches.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Ordering regression (highest).** Running completeness before `RejectZeroUren` silently turns
|
||||
`{ uren: 0 }` from 422/`Afgewezen` into 400 and breaks two existing endpoint tests. The
|
||||
`reject is null &&` guard is load-bearing — keep the comment saying why.
|
||||
- **`check:seam` false failure** if a second `ScholingThreshold = <digits>` literal lands in
|
||||
`IntakePolicy.cs` (§3). The message will say "FE/BE seam drift" and mislead.
|
||||
- **Missing the `Type == "intake"` gate** breaks the herregistratie wizard for every low-uren
|
||||
user; the `herregistratie is unaffected` test is the only net.
|
||||
- **Stale-bundle 400 loop:** the wizard's `Retry` re-sends the identical payload, so a pre-deploy
|
||||
tab loops until reloaded. Acceptable for a POC.
|
||||
|
||||
@@ -101,6 +101,10 @@ would compute. Two slices were implemented to demonstrate **both** policy shapes
|
||||
machine state and is set via a `SetPolicy` message. A `SCHOLING_THRESHOLD_DEFAULT`
|
||||
remains only as the offline fallback.
|
||||
- `intake-wizard.component.ts` fetches the policy and dispatches `SetPolicy`.
|
||||
- WP-69: the backend re-validates the threshold as the authority on submit —
|
||||
`IntakePolicy.RejectIncompleteScholing` runs before `POST /applications/{id}/submit`
|
||||
(intake-typed) and the legacy `POST /intakes` write anything, 400ing an incomplete
|
||||
scholing answer instead of silently accepting a crafted POST that skips it.
|
||||
|
||||
## Migration sequence (for the real app)
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ tested where._
|
||||
|
||||
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
|
||||
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
||||
**is** the suite, reshaped for a business reader. 401 frontend behaviours across
|
||||
8 contexts; 206 backend behaviours across 33 test
|
||||
**is** the suite, reshaped for a business reader. 404 frontend behaviours across
|
||||
8 contexts; 219 backend behaviours across 35 test
|
||||
classes.
|
||||
|
||||
## Frontend (by context)
|
||||
@@ -312,6 +312,7 @@ classes.
|
||||
- low uren requires the scholing question, and punten only once scholing is followed
|
||||
- buitenland gewerkt requires land and hours abroad before advancing
|
||||
- gaNaarStap corrects an earlier answer without losing later ones
|
||||
- raising uren above the threshold after answering scholing drops both fields (WP-69 §6)
|
||||
- SetPolicy (server-owned threshold) can turn an already-answered uren into one that now requires scholing
|
||||
|
||||
#### intake hasProgress
|
||||
@@ -349,6 +350,8 @@ classes.
|
||||
- reaches Submitting ONLY with valid answers
|
||||
- punten is required only when aanvullende scholing was gevolgd
|
||||
- low hours requires the scholing answer before submit
|
||||
- does not require punten for a hidden question (WP-69 §6)
|
||||
- drops punten when raising uren hides the question (WP-69 §6)
|
||||
- resolve maps Submitting to Submitted on a successful submit
|
||||
- resolve maps Submitting to Failed on a failed submit
|
||||
|
||||
@@ -957,6 +960,25 @@ classes.
|
||||
- Different idempotency keys are independent submissions
|
||||
- A rejected submission replays the same rejection not a retry
|
||||
|
||||
### IntakeRuleTests
|
||||
|
||||
- Below threshold with no answer is incomplete
|
||||
- At the threshold no answer is required
|
||||
- Niet gevolgd is a complete answer below threshold
|
||||
- Gevolgd without punten is incomplete
|
||||
- Gevolgd with zero punten is valid
|
||||
- Gevolgd with negative punten is refused
|
||||
|
||||
### IntakeSubmissionTests
|
||||
|
||||
- Below threshold without an answer is rejected and stays a concept
|
||||
- Below threshold with an answer succeeds
|
||||
- Above threshold needs no answer
|
||||
- Punten without gevolgd is rejected
|
||||
- Herregistratie is unaffected by the intake only gate
|
||||
- Zero uren is still afgewezen not a 400
|
||||
- Legacy intakes endpoint enforces it too
|
||||
|
||||
### LetterHtmlTests
|
||||
|
||||
- Render matches the golden file
|
||||
|
||||
@@ -483,6 +483,12 @@ export class ApiClient {
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse;
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 400) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result400: any = null;
|
||||
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Bad Request", status, _responseText, _headers, result400);
|
||||
});
|
||||
} else if (status === 422) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result422: any = null;
|
||||
@@ -2210,6 +2216,8 @@ export interface IntakePolicyDto {
|
||||
|
||||
export interface IntakeRequest {
|
||||
uren?: number;
|
||||
aanvullendeScholing?: boolean | undefined;
|
||||
scholingPunten?: number | undefined;
|
||||
}
|
||||
|
||||
export interface LetterBlockDto {
|
||||
@@ -2419,6 +2427,8 @@ export interface SubmitApplicationRequest {
|
||||
diplomaHerkomst?: string | undefined;
|
||||
uren?: number | undefined;
|
||||
documents?: DocumentRefDto[] | undefined;
|
||||
aanvullendeScholing?: boolean | undefined;
|
||||
scholingPunten?: number | undefined;
|
||||
}
|
||||
|
||||
export interface SubmitApplicationResponse {
|
||||
|
||||
Reference in New Issue
Block a user