Skip to content

In-app notifications (the header bell) — design

Audit row 2.4; Figma 77:4062 (20px bell left of the user chip, frame 123:7183). In-app only in v1: no email, no push.

  1. One table, notification (migration 00270): recipient_member_id, kind, record_kind, record_id, actor_member_id, params (JSONB), event_id, created_at, read_at. Composite FKs to organization_member; forced RLS + org_isolation. kind and record_kind are string vocabularies with named CHECKs. No prose is stored: the client renders the line from kind
    • record_kind + params.reference (en/ms/zh) and the actor’s name from useMemberNames.
  2. Kinds. awaiting_action (a record now waits for you), request_approved, request_paid, request_rejected, request_returned (your own record), comment_added. Record kinds: expense_claim, petty_cash_spend, float_topup, advance — the four the approvals page lists. Other workflow kinds (close-outs, advance top-ups/refunds) are ignored.
  3. Triggers ride the existing event bus. workflow.Transition — the one path every claim, spend, top-up and advance moves through — publishes workflow.transitioned, and so does workflow.Start (the instance id standing in for a decision), because a top-up’s draft “requested” is decided by the approver from the moment it exists; AddComment publishes expenses.comment_added. platform/notifications/notify subscribes (apart from the store, so the /me routes do not link every record package). The bus dispatches only after commit, so a rolled-back move never notifies; the subscriber opens its own transaction, on context.WithoutCancel, and logs and swallows its own errors and panics (recover), so a notification fault never fails the request (the OCR subscriber’s shape). The API and the worker both subscribe. Accepted cost: a crash between commit and dispatch loses that notification — the approvals queue stays the source of truth.
  4. Who is told what, on a move (reacting to semantics, never state names; the actor is never notified of their own act):
    • Owner (the instance’s reporter), when someone else moved it: into payout → paid; rejected → rejected; draft from a non-draft state → returned; into the last approval step → approved. The last approval step (Definition.FinalApproval) is a pending state whose every forward exit (not reversing, draft or dispute) leads to an accrual or payout state: a claim’s “approved”, a top-up’s “approved”, an advance’s “approved”. Other moves (earlier reviews, disputes) are silent: noise, and visible on the record.
    • Actors: workflow.ActorsNow — every member a non-reporter exit’s grants resolve to, plus their active delegates, each kept only if AvailableTransitions allows them a move (the approvals queue’s own question). The reporter is excluded, as the queue excludes them. Escalation past an SLA is not followed (v1).
    • Broad grants are capped: if more than 20 members pass the eligibility check, nobody is notified for that step; at most 50 candidates are checked at all (a bigger pool is skipped unchecked, bounding the cost). Either way an Info log names the instance, step and pool size. A pool that large is a shared queue; the badge on Approvals already shows it.
    • Stale asks are retired: every move marks the record’s earlier unread awaiting_action rows read, and only the event that is still the record’s latest decision asks anyone, so a late delivery never asks about a step already left.
  5. On a comment: the claim’s claimant and its current actors (same function, same cap), minus the author.
  6. Idempotent. UNIQUE (org_id, recipient_member_id, kind, event_id) with ON CONFLICT DO NOTHING; event_id is the decision id or comment id. A redelivered event writes nothing; a retried HTTP request is already replayed by the transition idempotency keys without a second decision. A pending sign-off (no decision, instance did not move) is silent.
  7. Only the recipient reads or marks. Routes are the caller’s own — GET /me/notifications (30 newest + unread_count, unread within 90 days, capped at 100 so the badge reads “99+”), POST /me/notifications/{id}/read, POST /me/notifications/read-all (every unread row, not only the 30 listed) — with recipient_member_id = <caller> in every WHERE. Like /me, they carry no permission (api/public_routes.txt): a member holding no grant still gets told their claim was paid. Another member’s id is 404.
  8. Retention: operational. A notification is a pointer to a record that is retained elsewhere (the decision log, audit_log, the comment). erp_app keeps DELETE and the table is on retention_test.go’s operational list. Kept 90 days: a daily River periodic job (notification_prune, queue maintenance, run on worker start too) deletes older rows. The job has no tenant and forced RLS would hide every row, so it calls notification_prune() (migration 00271), a SECURITY DEFINER function owned by the migration role that takes no argument and can only delete rows past 90 days; it refuses to run unless its owner bypasses RLS, rather than silently deleting nothing.
  9. UI. A 20 px bell left of the user chip with a red unread badge (“99+” cap); a Radix popover: header with “Mark all as read”, then rows (unread: bold + dot + tinted), each a line of text and relative time; click marks read and navigates (actors → the approvals drawer; owner/comment → the record’s own page). Empty state: icon + “You’re all caught up”. Polling: react-query every 60 s and on window focus, the approvals badge’s cadence.