V1 Web PWA · Journey

Journey: Messages center dispatch, follow, and subscription control

A source-reconciled journey record for V1 Web PWA — shipped behavior, state boundaries, failure modes, and the automation evidence available today.

automated · 2 specs verdict: pass· 2026-07-02
9sections7 minread

On this page
Journey at a glance
ActorsSigned-in recipientNotification-drawer userSubscription-conscious userMessaging operator or producer
Automation2 E2E spec(s) · verdict: pass· 2026-07-02

Journey flow#

Generated from the authored steps below — click a node to jump to that section.

flowchart TD s1["1. Dispatch into the message-center seam"] s2["2. Open the in-app inbox"] s3["3. Inspect receipts honestly"] s4["4. Follow a registered deep link"] s5["5. Mark read and reconcile the shell"] s6["6. Archive a row"] s7["7. Unsubscribe one pair"] s8["8. Prove suppression and isolation"] s9["9. Re-subscribe and restore dispatch"] s1 --> s2 --> s3 --> s4 --> s5 --> s6 --> s7 --> s8 --> s9 click s1 href "#1-dispatch-into-the-message-center-seam" click s2 href "#2-open-the-in-app-inbox" click s3 href "#3-inspect-receipts-honestly" click s4 href "#4-follow-a-registered-deep-link" click s5 href "#5-mark-read-and-reconcile-the-shell" click s6 href "#6-archive-a-row" click s7 href "#7-unsubscribe-one-pair" click s8 href "#8-prove-suppression-and-isolation" click s9 href "#9-re-subscribe-and-restore-dispatch" s9 --> v(["verdict: pass"]) click v href "../results/messages-center-deliver-and-unsubscribe.html" style v stroke:#3fb950,color:#3fb950

The shipped journey begins at the admin/system dispatch seam, not at an email, SMS, or push provider. A message enters the BFF's required durable customer-message snapshot, appears in /messages and the shell notification drawer, records a deep-link click, can be marked read or archived, and honors a channel × category unsubscribe until the user re-subscribes. Delayed work also survives restart, but an admin call still has to invoke replay.

Personas#

  • Signed-in recipient — uses /messages as the in-app history and acts on a domain deep link.
  • Notification-drawer user — expects the shell badge and drawer to agree with the full inbox.
  • Subscription-conscious user — disables one channel/category pair and later restores it without affecting other pairs.
  • Messaging operator or producer — calls the admin-scoped dispatch seam on behalf of an upstream domain event.

Pre-conditions#

  • The recipient has an authenticated customer session.
  • The producer has admin:* or admin:messaging and a valid message payload.
  • Deployable BFF startup requires and hydrates the customer message-center snapshot before traffic. Messages, delayed jobs, receipts, suppressions, revisions, and deletion fences share that store; acknowledged mutations fail closed if its sink cannot commit.
  • External email, SMS, voice, and OS-push transports are outside this journey. The request's channel labels the intended channel but does not prove that a provider delivered it.

Steps#

1. Dispatch into the message-center seam#

  • Call POST /v1/admin/customer-messages/dispatch with recipient, channel, category, priority, subject, body, and registered deep links.
  • The route applies active crisis-frame and quiet-hours decisions before the message store.
  • The message-center engine rejects duplicate ids and a currently suppressed channel/category pair.
  • A normal accepted dispatch durably commits a message with an initial queued receipt before returning. It does not call an external channel adapter.

2. Open the in-app inbox#

  • Navigate to /messages; the client fetches /api/messages/inbox, which proxies the authenticated BFF snapshot.
  • The header reports complete total and unread counts. The list contains the newest 25 rows by default, while an explicit lower limit returns a smaller newest-first window.
  • Filters, search, snooze display state, and grouping are computed by the shell model in the browser. Snooze is lost on reload. Server mark-read, archive, unsubscribe, and re-subscribe remain separate mutations.

3. Inspect receipts honestly#

  • Expand Delivery receipts on a row.
  • A direct dispatch is guaranteed to start with queued; it must not be described as externally delivered on that evidence alone.
  • The explicit quiet-hours replay path appends sent and delivered markers when it replays the queued message. Those markers are produced by the BFF replay boundary, not a provider callback.
  • Following a registered deep link appends clicked.
  • Click a row link. The browser uses /api/messages/:messageId/follow?url=<encoded-url>.
  • The BFF verifies that the requested URL is one of that recipient's registered links, records clicked, and resolves the internal target.
  • A link owned by another user or absent from the message returns 404 without mutating the owner's row.
  • Follow does not mark the message read; the unread chip remains until the explicit read action.

5. Mark read and reconcile the shell#

  • Click Mark read. The browser posts { messageId } to /api/messages/mark-read, then refreshes the inbox.
  • The unread count decrements and a shell inbox-change event prompts the badge to reconcile.
  • The notification drawer projects the same customer-message rows through /v1/notifications; drawer mark_read writes back through /v1/notifications-write to the same store.
  • When its inbox request succeeds, the shell badge shows customer-message unread count rather than a combined count of every domain-feed row also visible in the drawer.

6. Archive a row#

  • Click Archive. The browser posts to /api/messages/archive, refreshes, and removes the archived row from latestMessages.
  • Repeating archive is idempotent and preserves the existing archive timestamp.

7. Unsubscribe one pair#

  • In Manage notifications, choose a channel and category, then click Unsubscribe.
  • /api/messages/unsubscribe durably records a suppression policy for that exact user, channel, and category.
  • Success returns the component to idle without a success toast or inbox refresh. The active policy is not hydrated back into the page after reload; its local topic state returns to defaults even while the BFF still suppresses that pair. Errors use the shared action alert.
  • Row-level topic controls block categories labeled required, while the general manage form can still submit those categories to the current server. Voice is also collapsed to webhook in the browser display model.

8. Prove suppression and isolation#

  • Re-dispatch the same channel/category pair. The BFF returns 422 suppressed and creates no inbox row.
  • Dispatch a different channel or category to prove the policy does not widen unintentionally.
  • Another user's inbox, follow, mark-read, and archive requests cannot observe or mutate the recipient's message.

9. Re-subscribe and restore dispatch#

  • Click Re-subscribe with the same channel/category values.
  • The suppression policy clears through an awaited durable mutation.
  • A subsequent same-pair dispatch returns 201; after refresh, the new row appears in both /messages and the notification projection.

Post-conditions#

  • The recipient can inspect, follow, mark read, and archive acknowledged messages after restart.
  • /messages, the shell badge, and the notification drawer converge on the same customer-message store.
  • One exact channel/category opt-out suppresses subsequent dispatches until re-subscribe.
  • No outcome in this journey proves delivery by email, SMS, voice, or an OS push provider.
  • Account-level continuity export includes the user's message-center partition; account deletion erases and fences that exact subject without removing an adjacent user's state.

Failure modes#

  • Missing auth or scope — customer reads/mutations return 401; producer dispatch without messaging-admin scope returns 403.
  • Malformed payload — returns 400 with validation issues.
  • Duplicate message id — returns 409 without a second row.
  • Unsubscribed pair — returns 422 suppressed and writes no row.
  • Active non-essential crisis frame — returns 202 dispatch.suppressed.crisis_frame; no inbox row is written.
  • Quiet hours — returns 202 dispatch.delayed.quiet_hours only after storing a durable delayed dispatch; a separate replay call is required.
  • Unknown or cross-user link/message — returns 404 without leaking ownership.
  • Inbox load or action request fails — the component keeps the last snapshot and exposes an alert; it does not claim the mutation succeeded.
  • Snapshot persistence failure — the route returns 503 and keeps the last committed state; failed replay leaves the delayed job queued.
  • Erased subject — stale sends, delayed schedules, and recipient mutations return a conflict instead of resurrecting deleted continuity state.
  • Replay never invoked — a due durable job remains queued indefinitely; there is no automatic timer or distributed claim lease.

E2E coverage#

  • apps/oshun/web/e2e/messages-center-bff.spec.ts drives real HTTP dispatch, inbox, follow, mark-read, archive idempotency, unsubscribe/suppression/re-subscribe, bounded snapshots, dedupe, privacy isolation, quiet-hours replay, crisis suppression, and auth/validation gates.
  • apps/oshun/web/e2e/messages-center.spec.ts drives the live browser and Next proxies: BFF-seeded rows, receipts, follow, shell badge and drawer convergence, mark-read, archive, unsubscribe, suppressed re-dispatch, re-subscribe, restored dispatch, and component error states.
  • The focused message-center durability unit and gated real-Postgres suites cover candidate-before-publish rollback, restart restoration, exact-subject erasure fences, adjacent-user preservation, send/delete ordering, and replay failures that retain delayed work.
  • Provider-side transport, notification permission, OS tray behavior, and an upstream domain event producing the dispatch are not exercised.

Per-view files touched#

Cross-references#

  • apps/oshun/bff/src/routes/customer-message-center.ts — producer and recipient HTTP seams.
  • apps/oshun/bff/src/customer/customer-message-center-store.ts — durable snapshot wrapper, delayed queue, ordered mutations, and deletion fence.
  • apps/oshun/bff/src/data-export/bundle-builder.ts — continuity export of the exact user's message-center partition.
  • libs/oshun/customer-message-center/src/message-center.ts — receipt, unsubscribe, inbox, and deep-link rules.
  • apps/oshun/web/src/components/MessageCenter.tsx — browser state machine.
  • messages-quiet-hours-and-channel-binding.md — preference and channel-binding boundary.

Open questions#

  • Connect the dispatch seam to real provider adapters and ingest provider delivery/bounce/open callbacks without fabricating receipt stages.
  • Add an automatic scheduler with a safe cross-instance claim/lease for the already durable quiet-hours jobs.
  • Define cross-instance snapshot writer arbitration rather than assuming a database-backed snapshot is itself a multi-writer transaction log.
  • Define canonical audit-event names and persist them; this route currently exposes decisions and receipts, not the previously documented audit sequence.
  • Add explicit pagination or cursor navigation for histories beyond the newest snapshot window.
  • Reconcile the customer-message-only badge with the drawer's combined domain-feed projection.
  • Hydrate active suppressions into the page and align required-topic and voice-channel semantics across both controls and the BFF contract.