BEHAVIOUR — Case reminders (module: reminder)
Scope: #277 (sub-task of #274). A persisted, case-scoped working note: a free-text label plus a due date, created and completed by anyone who may manage the case, and notified to its creator on the due date. Lifted from #277's acceptance criteria, which are the approved spec.
Why a reminder is not a deadline. The statutory clocks are computed on read from an anchor on the
Caseaggregate and are never persisted, so they cannot be completed, edited, or deleted — a statutory deadline elapses or is superseded, it is never "done". A reminder is the opposite: persisted, free-text, and completable. The two therefore live behind separate endpoints rather than one union, which is what makes a statutory deadline structurally non-completable rather than merely absent from the UI.Why reminders are excluded from disclosure. A reminder is an internal working note ("chase HR for the signed termo"). Portal disclosure is explicit per item, per party, so nothing leaks by default — but the exclusion is specified here so it is enforced rather than incidental.
Implementation:
backend/MyLegalTeam.Domain/Cases/Reminders/,backend/MyLegalTeam.Application/Features/Cases/{CreateReminder,CompleteReminder,GetReminders}/,backend/MyLegalTeam.Api/Controllers/RemindersController.cs.
Creating a reminder
Unit
BHV-reminder-unit-001Creating a reminder with a label and a due date persists it and answers its id.BHV-reminder-unit-002The label is stored trimmed —" call counsel "is persisted as"call counsel".
Adversarial
BHV-reminder-adv-001A create with no label is refused400 ReminderLabelRequired, and nothing is persisted. Whitespace-only is not a label.BHV-reminder-adv-002A create with no due date is refused400 ReminderDueDateRequired, and nothing is persisted.BHV-reminder-adv-017A create whose label exceeds 200 characters is refused400 ReminderLabelTooLong, and nothing is persisted. The column is bounded to match, so an unbounded label cannot be stored, returned on every list read, and mailed.BHV-reminder-adv-003A caller who may not manage the case is refused403 NotAuthorizedForReminder, and nothing is persisted.
Completing a reminder
Unit
BHV-reminder-unit-003Completing a reminder records its completer and its timestamp, and answers the reminder as completed.
Adversarial
BHV-reminder-adv-004Completing an already-completed reminder is refused409 ReminderAlreadyCompleted, and neither the original completer nor the original timestamp moves. The conflict is the answer even when the surrounding commit fails — a storage failure must not convert a rejected request into500 ErrorSaving.BHV-reminder-adv-005A request asking to un-complete (completed: false) is refused400 ReminderNotCompletable. Un-completing is out of scope, and accepting the value silently would make the wire contract wider than the feature.BHV-reminder-adv-006Completing a reminder the caller may not manage is refused403 NotAuthorizedForReminder.BHV-reminder-adv-007Completing an unknown reminder is refused404 ReminderNotFound.BHV-reminder-adv-015Completing a reminder through another case's route is refused404 ReminderNotFound. The reminder id is not a capability: the route'scaseIdmust be the case the reminder actually belongs to, or the reminder is treated as not existing. Without this a caller withCanManageCasesanywhere in the company could complete a reminder on a case they are not on the team for, since the write authorises on a company-wide role while the read resolves case visibility.
Reading a case's reminders
Unit
BHV-reminder-unit-004The read answers outstanding and completed reminders alike, each carryingdayKind: "calendar"and a server-computedremainingDays. A completed reminder keeps its count rather than reporting zero — the count says when it was due, andcompletedis the separate flag beside it.BHV-reminder-unit-005remainingDaysis signed and counted from today: a reminder due yesterday answers-1, one due tomorrow answers1. Negative means overdue.
Adversarial
BHV-reminder-adv-008A caller who cannot see the case is refused404, not given an empty list.BHV-reminder-adv-009A caller in another company is refused404, not given an empty list — the tenant filter makes the case indistinguishable from one that does not exist.
Deliberate asymmetry. The read is gated on visibility of the case, not on
CanManageCases: anyone who may see a case may see its working notes, while only a manager may write one. The writes are gated onCanManageCases(BHV-reminder-adv-003,-006).
The statutory clocks are untouched
Integration
BHV-reminder-int-001GET …/cases/{caseId}/deadlinesanswers exactly the response it answered before reminders existed — no field added, removed, or renamed. Reminders are a separate endpoint precisely so this contract does not move.
Adversarial
BHV-reminder-adv-010No endpoint completes, edits, or deletes a statutory deadline. There is no route that accepts a deadline identifier for a write.
Notification
Unit
BHV-reminder-unit-006A reminder that falls due notifies the account that created it, with the reminder's label and due date.
Adversarial
BHV-reminder-adv-011A reminder completed before its due date notifies nobody. The scheduled job re-reads the row rather than trusting its arguments, which is what makes cancelling the job unnecessary.BHV-reminder-adv-012A reminder deleted before its due date notifies nobody and raises nothing.BHV-reminder-adv-016A label containing markup is HTML-encoded in the notification email's HTML body, so</span><a href="http://evil">reaches the recipient as text rather than as a link. The label is the first user-controlled string to reach an email template —CaseOpened's placeholder is a Guid — and the encoder is the oneHtmlDocumentRendereralready uses,HtmlEncoder.Create(UnicodeRanges.All), so Portuguese characters stay readable while the HTML-sensitive ones are escaped.
The plain-text body is not encoded: it istext/plain, where entities would render literally and corrupt any label containing an apostrophe or ampersand.
Every write is audited
Invariant (property)
BHV-reminder-inv-001Every reminder write leaves an audit fact on the case's ledger stream: creating raisesCaseReminderCreated, completing raisesCaseReminderCompleted, and no sequence of valid operations produces a persisted change with no corresponding fact.
Excluded from disclosure
Adversarial
BHV-reminder-adv-013A reminder never appears inGET /portal/{token}/items. It is not a portal-disclosable item kind, so no disclosure act can select one.BHV-reminder-adv-014A reminder never appears in an assembled dossier. Pending — the dossier assembler is #98 and does not exist yet. The test is written with its full intended body and skipped against that issue, so it reads as pending rather than inflating coverage.
Interface shape (the contract)
BHV-reminder-int-002 — the three endpoints end to end. This shape is the contract: to change it,
change this section first, then the test, then the code.
POST /companies/{companyId}/cases/{caseId}/reminders
req: { label: string, dueOn: "YYYY-MM-DD" }
201: { reminderId: guid }
400 ReminderLabelRequired · 400 ReminderDueDateRequired
403 NotAuthorizedForReminder · 404 CaseNotFound
GET /companies/{companyId}/cases/{caseId}/reminders
200: { items: [ { id: guid, label: string, dueOn: "YYYY-MM-DD", dayKind: "calendar",
remainingDays: int, completed: bool, completedAt: datetime|null } ] }
404 CaseNotFound
PATCH /companies/{companyId}/cases/{caseId}/reminders/{reminderId}
req: { completed: true }
200: { reminderId: guid, completed: true, completedAt: datetime }
400 ReminderNotCompletable · 403 NotAuthorizedForReminder
404 ReminderNotFound · 409 ReminderAlreadyCompleted
Type-enforced (no test)
PROOF-reminder-001A completed reminder always carries its completer and its timestamp. Both setters are private andCompletesets the pair together, so "completed by nobody" is unrepresentable — the same shape asChecklistItem.Verify.PROOF-reminder-002A reminder is constructible only throughCreate, which sets its company, case, label, due date and creator (private constructor plus a static factory), so a reminder with no case or no creator is unrepresentable.PROOF-reminder-003IsCompletedis derived fromCompletedAtrather than stored, so a reminder that claims to be completed while carrying no timestamp cannot exist.
Deliberately out of scope
Editing a reminder · un-completing · assignees · recurrence · reminders not attached to a case ·
configured firm-policy deadlines (the source: "internal" kind needed at Pareceres 7/8) · persisting
statutory deadlines · the frontend panel.
Deleting a reminder is out of scope for #277, which is why BHV-reminder-adv-012 specifies only that
a deleted reminder notifies nobody — the delete path itself is not specified here. If deletion is never
built, retire that code rather than recycling it.
Open — needs a decision before its test is written
- A missing
completedfield is currently indistinguishable fromfalse.CompleteReminderRequestdeclaresbool Completed, so an omitted field deserialises tofalseand answers400 ReminderNotCompletable— "a reminder can only be completed, not reopened" — which is a misleading answer to a request that said nothing. The repo's convention (OpenCaseRequest) is that every field is nullable so a missing value is distinguishable from a defaulted one. Making itbool?needs a third error code, and that is a contract change rather than a fix. Not specified above; no code assigned.