## What & why
S-17: a BIG inscription is valid for a fixed term; before it lapses the zorgprofessional must herregistreren. This adds a **daily herregistratie reminder sweep**.
- **Domain:** `Approve(ingeschrevenOp)` now stamps the inscription moment; `HerregistratieVoor` derives the deadline (inscription + 5-year validity); `HerregistratieReminderDue(asOf)` is the single rule (inside the 90-day window, inscribed, not yet reminded); `MarkHerregistratieReminderVerstuurd()` is idempotent.
- **Store:** `FindDueForHerregistratieReminderAsync(asOf)` — the sweep's candidate set, filtered on the aggregate's own rule (no duplicated policy).
- **Application:** `HerregistratieReminderSweep` — pure over the store + an injected `TimeProvider`; flags + persists each due inscription, returns the reminded ids.
- **Infra/API:** `HerregistratieReminderJob` (Quartz `IJob`) fires the sweep on a daily cron (03:00, overridable via `Quartz__Cron`) and logs the count. `GET /registrations/{id}` surfaces `herregistratieVoor` + `herregistratieReminderVerstuurd`.
**Decisions (both raised with you before coding):** use Quartz.NET as the PRD names it — a genuine cron concern, distinct from the queue-draining pumps, which stay as-is (**ADR-0022**, proposal #120); and the reminder's observable effect is a flag on the aggregate + a log line (no outbound notification infra in v1). No coupling rule (§8) is touched — Quartz is internal to the Domain Service.
Closes #18
Closes #120
## Definition of Done
- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation (red→green per layer: domain rule, store query, sweep).
- [x] Implementation makes the test pass; refactor commit for the 90-day knob.
- [x] Conventional Commits referencing the issue (`refs #18`).
- [ ] CI green — awaiting Gitea Actions.
- [ ] `docker compose up` reaches green health checks within 3 minutes — API boots locally with Quartz initialised; verified in CI compose smoke.
- [x] Docs updated — ADR-0022, demo-script, BACKLOG.
- [x] ADR added — `docs/architecture/adr-0022-quartz-scheduler.md`.
- [x] Demo note in `docs/demo-script.md`.
## Notes for reviewers
- **Ripple:** `Approve()` gained the inscription moment, so the two approving handlers (`ApproveRegistration`, `BeoordeelRegistratie`) now take an injected `TimeProvider`; existing tests pass a fixed clock. All three `IRegistrationStore` implementers (prod, unit fake, acceptance) got the new query.
- **Calibration knobs:** validity (5y) and reminder lead time (90d) are domain constants marked with `ponytail:` comments; promotion path to beheer config (S-15) noted in the ADR.
- **Mutation:** the Quartz job shell is excluded from Stryker, mirroring the pumps; all rule/sweep/query logic is covered.
- Local: 152 domain unit tests green; API boots with the Quartz scheduler and `/health` green.
Reviewed-on: #121
210 lines
10 KiB
C#
210 lines
10 KiB
C#
namespace Big.Domain;
|
|
|
|
/// <summary>
|
|
/// The Registration aggregate root (CLAUDE.md §2.2): a zorgprofessional's submission to the BIG
|
|
/// register. It owns its lifecycle invariants — it starts <see cref="RegistrationStatus.Ingediend"/>
|
|
/// on submission, remembers the Flowable process that drives it, and records the zaak the ACL opens.
|
|
/// </summary>
|
|
public sealed class Registration
|
|
{
|
|
private Registration(RegistrationId id, string bsn, DiplomaOrigin diplomaOrigin)
|
|
{
|
|
Id = id;
|
|
Bsn = bsn;
|
|
DiplomaOrigin = diplomaOrigin;
|
|
Status = RegistrationStatus.Ingediend;
|
|
}
|
|
|
|
public RegistrationId Id { get; }
|
|
|
|
/// <summary>The citizen-service number of the submitting zorgprofessional. Handed to the ACL
|
|
/// as the domain payload; the domain never constructs ZGW concepts from it (§8.1).</summary>
|
|
public string Bsn { get; }
|
|
|
|
/// <summary>Where the diploma was issued. Rides along to the process as a start variable and
|
|
/// drives the diploma-eligibility DMN's foreign→CBGV-advies routing (S-13, ADR-0016).</summary>
|
|
public DiplomaOrigin DiplomaOrigin { get; }
|
|
|
|
public RegistrationStatus Status { get; private set; }
|
|
|
|
/// <summary>The Flowable process instance driving this registration, once started.</summary>
|
|
public string? ProcessInstanceId { get; private set; }
|
|
|
|
/// <summary>The zaak the ACL opened for this registration, once the external task has run.</summary>
|
|
public Uri? ZaakUrl { get; private set; }
|
|
|
|
/// <summary>Submit a new registration. It begins in <see cref="RegistrationStatus.Ingediend"/>.
|
|
/// The diploma origin defaults to <see cref="DiplomaOrigin.Binnenlands"/> — the common DigiD path;
|
|
/// a foreign (eIDAS) submission passes <see cref="DiplomaOrigin.Buitenlands"/>.</summary>
|
|
public static Registration Submit(string bsn, DiplomaOrigin diplomaOrigin = DiplomaOrigin.Binnenlands)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(bsn);
|
|
return new Registration(RegistrationId.New(), bsn, diplomaOrigin);
|
|
}
|
|
|
|
/// <summary>Record that the registratie workflow process has been started for this registration.</summary>
|
|
public void RecordProcessStarted(string processInstanceId)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(processInstanceId);
|
|
ProcessInstanceId = processInstanceId;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attach the zaak the ACL opened. The external-task worker may deliver the same job more than
|
|
/// once (at-least-once), so re-attaching the identical URL is a no-op; a different URL signals a
|
|
/// genuine conflict and is rejected. The status stays <see cref="RegistrationStatus.Ingediend"/>:
|
|
/// opening the zaak does not advance the registration's lifecycle in this slice.
|
|
/// </summary>
|
|
public void AttachZaak(Uri zaakUrl)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
|
|
|
if (ZaakUrl is not null)
|
|
{
|
|
if (ZaakUrl != zaakUrl)
|
|
throw new InvalidOperationException(
|
|
$"Registration {Id} already has zaak {ZaakUrl}; cannot attach a different zaak {zaakUrl}.");
|
|
// Stryker disable once Statement : equivalent — re-assigning the identical URL below is a
|
|
// no-op, so removing this early return is behaviourally indistinguishable.
|
|
return;
|
|
}
|
|
|
|
ZaakUrl = zaakUrl;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A behandelaar picks the registration up for beoordeling. Advances
|
|
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.InBehandeling"/>.
|
|
/// Re-taking one already <see cref="RegistrationStatus.InBehandeling"/> is a no-op (the same or
|
|
/// another behandelaar re-opens it); a decided registration can no longer be taken into behandeling.
|
|
/// </summary>
|
|
public void TakeIntoBehandeling()
|
|
{
|
|
if (Status == RegistrationStatus.InBehandeling)
|
|
return;
|
|
|
|
if (Status != RegistrationStatus.Ingediend)
|
|
throw new InvalidOperationException(
|
|
$"Registration {Id} is {Status}; only an INGEDIEND registration can be taken into behandeling.");
|
|
|
|
Status = RegistrationStatus.InBehandeling;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances a
|
|
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Ingeschreven"/> and
|
|
/// records <paramref name="ingeschrevenOp"/> as the moment of inscription, which starts the
|
|
/// herregistratie clock (S-17). Requires an opened zaak (the approval sets that zaak's status via
|
|
/// the ACL); a registration that has already been decided cannot be approved again.
|
|
/// </summary>
|
|
public void Approve(DateTimeOffset ingeschrevenOp)
|
|
{
|
|
if (ZaakUrl is null)
|
|
throw new InvalidOperationException(
|
|
$"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened.");
|
|
|
|
RequireOpenForDecision(nameof(Approve));
|
|
Status = RegistrationStatus.Ingeschreven;
|
|
IngeschrevenOp = ingeschrevenOp;
|
|
}
|
|
|
|
// --- Herregistratie (S-17) — RED stubs, implemented in the green commit ---------------------
|
|
|
|
/// <summary>How long a BIG inscription stays valid before herregistratie is required.</summary>
|
|
// ponytail: fixed 5-year term — a calibration knob, not a config surface. If a demo needs it
|
|
// per-catalogus, promote it to policy passed in from the beheer config (S-15).
|
|
public static readonly TimeSpan HerregistratieGeldigheid = TimeSpan.FromDays(365 * 5);
|
|
|
|
/// <summary>How long before the deadline the herregistratie reminder is sent (S-17: 90 days).</summary>
|
|
// ponytail: fixed 90-day lead time — calibration knob; same promotion path as HerregistratieGeldigheid.
|
|
public static readonly TimeSpan Herinneringstermijn = TimeSpan.FromDays(90);
|
|
|
|
/// <summary>When the registration was entered in the register, once approved; the start of its
|
|
/// herregistratie clock. Null until it is <see cref="RegistrationStatus.Ingeschreven"/>.</summary>
|
|
public DateTimeOffset? IngeschrevenOp { get; private set; }
|
|
|
|
/// <summary>The date by which herregistratie must happen: inscription + validity. Null until
|
|
/// inscribed.</summary>
|
|
public DateTimeOffset? HerregistratieVoor =>
|
|
IngeschrevenOp is DateTimeOffset ingeschrevenOp ? ingeschrevenOp + HerregistratieGeldigheid : null;
|
|
|
|
/// <summary>Whether the herregistratie reminder has been sent for this inscription (S-17).</summary>
|
|
public bool HerregistratieReminderVerstuurd { get; private set; }
|
|
|
|
/// <summary>Whether, as of <paramref name="asOf"/>, this registration is due a herregistratie
|
|
/// reminder: it is inscribed, the reminder window before its deadline has opened, and it has not
|
|
/// already been reminded. Once inside the window it stays due until reminded (an overdue inscription
|
|
/// is still due). This is the single rule the store query and the sweep both build on.</summary>
|
|
public bool HerregistratieReminderDue(DateTimeOffset asOf) =>
|
|
Status == RegistrationStatus.Ingeschreven
|
|
&& !HerregistratieReminderVerstuurd
|
|
&& IngeschrevenOp is DateTimeOffset ingeschrevenOp
|
|
&& asOf >= ingeschrevenOp + HerregistratieGeldigheid - Herinneringstermijn;
|
|
|
|
/// <summary>Record that the herregistratie reminder has been sent. Idempotent — a re-sweep is a
|
|
/// no-op (§8.6); only an inscribed registration can be reminded.</summary>
|
|
public void MarkHerregistratieReminderVerstuurd()
|
|
{
|
|
if (HerregistratieReminderVerstuurd)
|
|
return;
|
|
|
|
if (Status != RegistrationStatus.Ingeschreven)
|
|
throw new InvalidOperationException(
|
|
$"Registration {Id} is {Status}; only an INGESCHREVEN registration can be sent a herregistratie reminder.");
|
|
|
|
HerregistratieReminderVerstuurd = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reject the registration — the behandelaar's decision not to enter it in the register. Advances a
|
|
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Afgewezen"/>. Unlike
|
|
/// approval this needs no zaak: a registration can be rejected before or after its zaak is opened.
|
|
/// A registration that has already been decided cannot be rejected again.
|
|
/// </summary>
|
|
public void Reject()
|
|
{
|
|
RequireOpenForDecision(nameof(Reject));
|
|
Status = RegistrationStatus.Afgewezen;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Withdraw the registration — the zorgprofessional pulls their own submission back (S-11). Allowed
|
|
/// while it is still open (INGEDIEND or IN_BEHANDELING) and needs no zaak; a registration that has
|
|
/// already been decided (INGESCHREVEN/AFGEWEZEN) can no longer be withdrawn. Re-withdrawing one
|
|
/// already <see cref="RegistrationStatus.Ingetrokken"/> is a no-op.
|
|
/// </summary>
|
|
public void Withdraw()
|
|
{
|
|
if (Status == RegistrationStatus.Ingetrokken)
|
|
return;
|
|
|
|
RequireOpenForDecision(nameof(Withdraw));
|
|
Status = RegistrationStatus.Ingetrokken;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Expire the registration — the 30-day document-wait timer fired before the required documents
|
|
/// were supplied, so the registratie process cancels the case (S-10a). Allowed while it is still
|
|
/// open (INGEDIEND or IN_BEHANDELING) and needs no zaak; a decided (INGESCHREVEN/AFGEWEZEN) or
|
|
/// withdrawn (INGETROKKEN) registration can no longer expire. Re-expiring one already
|
|
/// <see cref="RegistrationStatus.Verlopen"/> is a no-op — the worker job may be redelivered (§8.6).
|
|
/// </summary>
|
|
public void Expire()
|
|
{
|
|
if (Status == RegistrationStatus.Verlopen)
|
|
return;
|
|
|
|
RequireOpenForDecision(nameof(Expire));
|
|
Status = RegistrationStatus.Verlopen;
|
|
}
|
|
|
|
// A decision (or withdrawal, or expiry) is only valid while the registration is still open
|
|
// (INGEDIEND or IN_BEHANDELING).
|
|
private void RequireOpenForDecision(string decision)
|
|
{
|
|
if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
|
|
throw new InvalidOperationException(
|
|
$"Registration {Id} is {Status}; only an INGEDIEND or IN_BEHANDELING registration can be decided ({decision}).");
|
|
}
|
|
}
|