Skip to content

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 CommunicationRecipient row 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 /communications stays the sent archive; /communications/inbox is the new received surface); read/unread state ships day one (mark-read + unread count); SKIPPED_NO_EMAIL people 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 surfaceJwtAuthGuard 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]) on CommunicationRecipient — 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.ts mirroring notifications.swagger.ts (auth-only — 401 example, no 403); hidden-404 example for the new code.
  • hasAttachments derived per page via one File groupBy 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 …/read only (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 NO recipients / recipientCounts keys; PATCH read twice → identical readAt, 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.email is required in the schema, Student.schoolEmail is not, so the email-less loggable person is a student); S2's inbox shows the send+sweep communication whose email leg answered SKIPPED_NO_EMAIL, and the detail opens (in-app leg). A foreign recipient-row id → 404 COMMUNICATION_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.