Skip to main content

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 Case aggregate 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-001 Creating a reminder with a label and a due date persists it and answers its id.
  • BHV-reminder-unit-002 The label is stored trimmed — " call counsel " is persisted as "call counsel".

Adversarial

  • BHV-reminder-adv-001 A create with no label is refused 400 ReminderLabelRequired, and nothing is persisted. Whitespace-only is not a label.
  • BHV-reminder-adv-002 A create with no due date is refused 400 ReminderDueDateRequired, and nothing is persisted.
  • BHV-reminder-adv-017 A create whose label exceeds 200 characters is refused 400 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-003 A caller who may not manage the case is refused 403 NotAuthorizedForReminder, and nothing is persisted.

Completing a reminder

Unit

  • BHV-reminder-unit-003 Completing a reminder records its completer and its timestamp, and answers the reminder as completed.

Adversarial

  • BHV-reminder-adv-004 Completing an already-completed reminder is refused 409 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 into 500 ErrorSaving.
  • BHV-reminder-adv-005 A request asking to un-complete (completed: false) is refused 400 ReminderNotCompletable. Un-completing is out of scope, and accepting the value silently would make the wire contract wider than the feature.
  • BHV-reminder-adv-006 Completing a reminder the caller may not manage is refused 403 NotAuthorizedForReminder.
  • BHV-reminder-adv-007 Completing an unknown reminder is refused 404 ReminderNotFound.
  • BHV-reminder-adv-015 Completing a reminder through another case's route is refused 404 ReminderNotFound. The reminder id is not a capability: the route's caseId must be the case the reminder actually belongs to, or the reminder is treated as not existing. Without this a caller with CanManageCases anywhere 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-004 The read answers outstanding and completed reminders alike, each carrying dayKind: "calendar" and a server-computed remainingDays. A completed reminder keeps its count rather than reporting zero — the count says when it was due, and completed is the separate flag beside it.
  • BHV-reminder-unit-005 remainingDays is signed and counted from today: a reminder due yesterday answers -1, one due tomorrow answers 1. Negative means overdue.

Adversarial

  • BHV-reminder-adv-008 A caller who cannot see the case is refused 404, not given an empty list.
  • BHV-reminder-adv-009 A caller in another company is refused 404, 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 on CanManageCases (BHV-reminder-adv-003, -006).


The statutory clocks are untouched

Integration

  • BHV-reminder-int-001 GET …/cases/{caseId}/deadlines answers 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-010 No 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-006 A reminder that falls due notifies the account that created it, with the reminder's label and due date.

Adversarial

  • BHV-reminder-adv-011 A 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-012 A reminder deleted before its due date notifies nobody and raises nothing.
  • BHV-reminder-adv-016 A 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 one HtmlDocumentRenderer already 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 is text/plain, where entities would render literally and corrupt any label containing an apostrophe or ampersand.

Every write is audited

Invariant (property)

  • BHV-reminder-inv-001 Every reminder write leaves an audit fact on the case's ledger stream: creating raises CaseReminderCreated, completing raises CaseReminderCompleted, and no sequence of valid operations produces a persisted change with no corresponding fact.

Excluded from disclosure

Adversarial

  • BHV-reminder-adv-013 A reminder never appears in GET /portal/{token}/items. It is not a portal-disclosable item kind, so no disclosure act can select one.
  • BHV-reminder-adv-014 A 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-001 A completed reminder always carries its completer and its timestamp. Both setters are private and Complete sets the pair together, so "completed by nobody" is unrepresentable — the same shape as ChecklistItem.Verify.
  • PROOF-reminder-002 A reminder is constructible only through Create, 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-003 IsCompleted is derived from CompletedAt rather 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 completed field is currently indistinguishable from false. CompleteReminderRequest declares bool Completed, so an omitted field deserialises to false and answers 400 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 it bool? needs a third error code, and that is a contract change rather than a fix. Not specified above; no code assigned.