Communications iteration 3 — the recipient inbox¶
1. Problem distillation¶
- The archive today answers only "what did I send". Product wants the other half: what was sent TO me — a received-communications inbox.
- The model already records receipt: every send persists one
CommunicationRecipientrow per person as a(personType, personId)ref (kinds included, iteration 2). An inbox is a pure read surface over those rows — the write path, sweeper, and webhook are untouched. - Decided in chat (2026-08-05): two endpoints (
GET /communicationsstays the sent archive;/communications/inboxis the new received surface); read/unread state ships day one (mark-read + unread count);SKIPPED_NO_EMAILpeople DO see the message in-app — the inbox is a real second delivery leg that quietly fixes the no-address gap. - The receiving population includes students and referents who never hold
communications.send— the inbox must be an auth-only, self-scoped surface (the notification-center precedent), not another send-gated route.
Success criteria (observable behavior that proves this works):
- A logged-in user sees every non-PENDING recipient row addressed to any
of their person refs (Teacher/Staff/Referent/Student profile rows +
USER:<userId>), newest first, with kind and readAt.
- A student with zero communications grants can read their inbox.
- Recipients see subject/body/sender/attachments — never the ledger,
the other recipients, or any counts.
- PATCH …/read is idempotent; unread-count matches the badge; a
SKIPPED_NO_EMAIL person finds the message in-app.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- No reply/forward, no in-app composition changes.
- No change to the sent archive (GET /communications) or its gating.
- No inbox push/email notification about received communications (the email
IS the notification; in-app badge covers the rest).
- No search/filters on the inbox beyond unread + pagination (parity with
the notification center; add later if product asks).
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/notifications/notifications.controller.ts + notifications-read.service.ts |
THE template: auth-only @UseGuards(JwtAuthGuard) self-scoped controller; GET / with unread=true filter, GET /unread-count, PATCH /:id/read (idempotent), POST /read-all; UnreadCountDto {count}, ReadAllResultDto {updated} shapes |
Notifications rows are keyed by userId directly; inbox rows are keyed by person ref — the service must first resolve the caller's refs |
docs/superpowers/specs/2026-08-05-communications-module-design.md (+iteration 2) |
CommunicationRecipient as the per-person receipt record (kind, statuses, snapshots); signed attachment URLs via FilesService.mintSignedUrlForFile; hidden-404 discipline |
The existing detail exposes the full ledger — the inbox needs a privacy-reduced projection |
src/auth/ profile machinery (person-profiles.ts, profile lookups by userId) |
The user→profile-rows mapping (≤2 person profiles per user + the bare-account case) | We need it as refs (personType, personId) incl. USER:<userId> — a small module-local query, not a session concern |
src/communications/mailing-groups/ hidden 404 (MAILING_GROUP_NOT_FOUND) + NOTIFICATION_NOT_FOUND |
Existence-hiding 404 keyed on an own-scoped opaque id, internal-param'd | Fits cleanly — new sibling code for the inbox item |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | every query WHERE carries tenantId (rows already tenant-bearing, RLS in place) |
no new table |
| Academic-year scope | no | n/a — communications are not AY-scoped | unchanged |
| RBAC entity key | none | auth-only surface — JwtAuthGuard only, no @RequireAction (notification-center precedent: self-scoped data needs no grant) |
receivers include students/referents without communications.send |
| Scopes | none | no delta | |
| Actions | none | no delta | |
| Service base | custom | new CommunicationsInboxService (read + mark-read only) |
own-rows fencing is the whole service |
queries.ts shape |
module-local named functions | findCallerPersonRefs, findInboxPage, findInboxItem, countUnread, markRead, markAllRead in communications.queries.ts (or a sibling inbox.queries.ts if it crowds the file) |
|
| Error codes | one new | COMMUNICATION_INBOX_ITEM_NOT_FOUND ({ inboxItemId }, internal param) — existence-hiding 404, same user copy as COMMUNICATION_NOT_FOUND |
the id is a recipient-row id, not a communication id; reusing COMMUNICATION_NOT_FOUND would lie in the internal param |
| DTO conventions | list-query + response DTOs under dto/ |
InboxListQueryDto (PaginationQueryDto + unread? boolean, notification-center decorators), InboxItemDto, InboxDetailDto, InboxUnreadCountDto {count}, InboxReadAllResultDto {updated} |
|
| File-backed sub-resources | reuse | detail mints signed URLs for the communication's attachments (existing query + FilesService) |
|
| Custom fields | no | n/a | |
| Profile completeness | no | n/a |
4. Data model plan¶
Schema deltas¶
CommunicationRecipient.readAt DateTime?— null while unread; set once by the first mark-read (notification precedent).- New index
@@index([tenantId, personType, personId])onCommunicationRecipient— the inbox lookup key (nothing indexes person refs today).
Migration shape¶
- Additive: one nullable column + one index. New migration (repair-forward rule; the three prior uncommitted migrations ride the same batch).
- Data backfill: none — historical rows start unread, which is honest.
- Hazards from chapter 12 checklist: none (nullable column, plain index on a small table).
Indexes and uniqueness¶
- The new
[tenantId, personType, personId]index above; everything else unchanged.
5. API surface¶
New controller communications/inbox (registered BEFORE
CommunicationsController in the module so the literal path beats
communications/:id), @UseGuards(JwtAuthGuard) only:
| Verb | Path | Request | Response |
|---|---|---|---|
| GET | /communications/inbox |
InboxListQueryDto (page/limit, unread?=true) |
{ data: InboxItemDto[], meta } |
| GET | /communications/inbox/unread-count |
— | InboxUnreadCountDto { count } |
| GET | /communications/inbox/:id |
id = recipient-row id |
InboxDetailDto |
| PATCH | /communications/inbox/:id/read |
— | InboxItemDto (idempotent) |
| POST | /communications/inbox/read-all |
— | InboxReadAllResultDto { updated } |
Semantics locked:
- Caller resolution: refs = the caller's Teacher/Staff/Referent/Student
rows by (tenantId, userId) (≤2 exist per the profile constraint) ∪
USER:<userId> — resolved fresh per request, session-profile-agnostic:
your inbox is YOURS whichever view you are in (narrowed activeRole does
not filter receipts).
- Visibility: status != 'PENDING' — covers SENT/DELIVERED/… AND
SKIPPED_NO_EMAIL + FAILED (terminal at or after queue time): the
in-app leg delivers even where the email leg couldn't. PENDING rows
surface seconds later once the sweeper hands them to the provider.
- List item (InboxItemDto): id (recipient-row id), subject,
senderDisplayName, kind (TO | CC), sentAt
(communication sentAt ?? queuedAt), readAt, hasAttachments.
Ordered newest-first by the communication's queuedAt.
- Detail (InboxDetailDto): item fields + bodyHtml, replyTo,
signed attachments[]. Privacy-reduced: no ledger, no counts, no
other recipients, no per-row email statuses — a recipient sees the email
they got, nothing about anyone else.
- Unread = readAt IS NULL (within the visible set). markRead sets
readAt once via an updateMany predicate (readAt: null) — idempotent
and race-safe; re-reads return the row unchanged. read-all is the same
predicate over the caller's visible refs.
- Misses: unknown id, foreign row, or a PENDING row → hidden 404
COMMUNICATION_INBOX_ITEM_NOT_FOUND.
- The sent archive (GET /communications, GET /communications/:id) is
untouched, still send-gated, still own-sender-only.
Swagger considerations¶
- New
inbox.swagger.tsmirroringnotifications.swagger.ts(auth-only — 401 example, no 403); hidden-404 example for the new code. hasAttachmentsderived per page via oneFilegroupBy on the page's communication ids — documented as a boolean, not a count.
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
none |
ScopeFieldMapping (rbac-catalogue.ts) |
none |
| Role grants (roles.ts) | none — the surface is auth-only by design |
*_SCOPES runtime constant |
none |
No reseed needed.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
Module's every-route @RequireAction(COMMUNICATIONS, 'send') gate |
The inbox controller carries NO permission gate (JwtAuthGuard only) | Receivers include students/referents who never hold send; self-scoped own-data needs no grant (notification-center precedent) |
A revoked-role user still reads received mail — correct: it was addressed to them |
| Sent-archive detail shape | A second, privacy-reduced detail projection keyed on the recipient-row id | A recipient must never see the ledger or the audience | Two detail DTOs for one aggregate |
Session view semantics (narrowed ctx.roles drive reach) |
Inbox ignores the active view — all of the caller's person refs, always | Receipts are personal mail, not role-scoped authority; splitting the inbox per view would strand mail addressed to the "other" profile | A teacher-view user sees mail addressed to their referent profile too |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| "GET communications should be split in 2" (suggests reshaping the existing route) | FE already consumes GET /communications as the sent archive |
Keep the sent route untouched; add /communications/inbox — user confirmed "two endpoints, /inbox is ok" |
Resolved 2026-08-05 |
9. Deferrals¶
- Inbox search / date filters — notification-center parity for v1; add on product ask.
- Auto-mark-read on detail GET — explicit
PATCH …/readonly (notification precedent, FE controls when a message counts as read). - In-app notification (ch23 engine) about a received communication — the email is the notification; revisit if a no-email-population school signs.
- Sender-side "seen by recipient" indicators — deliberately NOT exposed (readAt is the recipient's private state; showing it to senders is a product decision nobody asked for).
10. Open questions¶
None — endpoint shape ("two endpoints, /inbox"), read-state (yes, day one) and skipped-row visibility (yes) resolved in chat 2026-08-05.
11. Verification plan¶
- Unit specs: new
communications-inbox.service.spec.ts— ref resolution (profile rows ∪ USER, session-view-agnostic), visibility predicate (PENDING hidden, SKIPPED_NO_EMAIL/FAILED visible), unread filter + count, idempotent markRead (updateMany predicate, count 0 → row re-fetched not re-stamped), read-all scope, hidden 404 on foreign/unknown /PENDING ids, hasAttachments mapping, privacy projection (no ledger fields on the detail). - E2E specs: extend
test/communications.e2e-spec.ts— the loggable receivers in the existing fixture are the referent and teacher B (S1–S3 are students without User rows): the referent lists their inbox and finds the send+sweep communication (kind: 'TO',readAt: null); unread-count ≥ 1; detail shows body/sender/replyTo and has NOrecipients/recipientCountskeys; PATCH read twice → identicalreadAt, unread count drops; teacher B finds their iteration-2 CC copy (kind: 'CC'). Skipped-leg check: S2 — the fixture's no-email student — gains a User login (Referent.emailis required in the schema,Student.schoolEmailis not, so the email-less loggable person is a student); S2's inbox shows the send+sweep communication whose email leg answeredSKIPPED_NO_EMAIL, and the detail opens (in-app leg). A foreign recipient-row id → 404COMMUNICATION_INBOX_ITEM_NOT_FOUND; the sender's sent archive is unchanged. - Manual verification: none beyond the suite.
12. Sign-off¶
- Approved by: Fabio
- Date: 2026-08-05
- Chat reference: "go on with planning" in chat 2026-08-05, after the spec walkthrough (endpoint split, read-state, skipped-row decisions all made in the same conversation)
Until this section is filled, no implementation code is written. When you
fill it, flip the frontmatter status: to Approved in the same edit.