Notifications¶
Purpose¶
The notification domain is the single fan-out point for every user-facing notification. A domain service (friend, event, moment, chat, presence) hands it one trigger; the service decides which delivery surfaces apply (inbox row, app-level WebSocket, OS-level push) and routes accordingly. Inbox state lives in Postgres; live updates ride /ws/client; push delivery is interface-isolated behind PushDispatcher so an APNs / FCM / Web Push provider can land later without touching any caller.
Package notification (backend/internal/services/notification/) exposes Service (bound to the notify.Notifier port so callers depend on the interface), Store, and Handler. The notify platform package (backend/internal/platform/notify/) owns the shared contracts: Notifier, NotificationInput, PushDispatcher, PushPayload.
Responsibilities¶
- Persist inbox rows and group repeated activity on the same
(user_id, group_key)into one aggregate (avatars + actor count), with the open-window rule keyed on whether the kind is actionable. - Emit
notification:new,notification:unread,notification:resolved, andnotification:transientover/ws/client. - Hand off to
PushDispatcherfor kinds whose channel mask includes Push. - Serve list + mark-seen + unread-count endpoints and per-device push-token register / unregister.
- Hard-delete inbox rows past the 90-day retention window via the
notification_purgecron.
HTTP endpoints¶
Notification routes mount under /notifications; push-token routes under /push-tokens. Both JWT-verified.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/notifications |
Cursor-paginated inbox page + unread_count. cursor (RFC3339Nano), limit (1..100, default 30). |
| GET | /api/v1/notifications/unread |
{unread_count} only (cheap badge endpoint). |
| POST | /api/v1/notifications/seen |
Flips every seen_at IS NULL row to now; idempotent. |
| POST | /api/v1/push-tokens |
Register a device push credential ({platform, token, app_version}). |
| DELETE | /api/v1/push-tokens?platform=ios&token=... |
Unregister a device push credential. |
Resolving a single actionable row (accepted / declined / approved / rejected / viewed) is not an HTTP surface. It is invoked from the domain service that owns the action (e.g. friend accept, event approve) via AcknowledgeByGroupKey, so the resolution is atomic with the underlying state change.
Hybrid seen / resolved model¶
Two timestamps plus a resolution discriminator drive the lifecycle:
| Field | Set by | Meaning |
|---|---|---|
seen_at |
MarkAllSeen (panel open) |
The user has glanced at the inbox since this row arrived. Drives the tab-bell badge. |
resolved_at |
AcknowledgeByGroupKey |
The user has taken an inline action. Closes the grouping window for actionable kinds. |
resolution |
AcknowledgeByGroupKey |
What the user did: accepted | declined | approved | rejected | viewed. Drives the FE row morph. |
The badge asks "is there anything you haven't laid eyes on yet?" (seen_at). The grouping window for actionable kinds asks "is there still a CTA the user owes an answer to?" (resolved_at).
Grouping rule¶
Store.UpsertGrouped tries to INSERT a fresh row; the partial unique index on (user_id, group_key) (scoped to open rows) blocks a second open row per group, at which point the store fetches the open row and merges. There is no transaction: the unique index is the source of truth for "one open row per group". Open differs per kind:
| Kind type | Open predicate | Closes when |
|---|---|---|
Actionable (friend.request_received, event.join_requested) |
resolved_at IS NULL |
The user acts on the inline CTA. |
| Passive (everything else inboxed) | seen_at IS NULL |
The user opens the panel (MarkAllSeen). |
While open, new activity merges: the new actor is prepended (cap NotificationMaxActors = 3), actor_count increments (the true unique count drives the "and N others" copy), payload merges shallowly so transient fields refresh, and updated_at bumps. Once closed, the next event on the same key opens a fresh row. Cancellation flows (friend-request cancel / reject) call DeleteByGroupKey to drop the recipient's pending row so the UI matches the resolved state.
The group key is {kind}:{target_type}:{target_id} (NotificationInput.GroupKey()), e.g. moment.liked:moment:<id>, checkin.tagged:checkin:<id>, friend.request_received:user:<requester>.
Resolution -> row morph¶
When the user taps the inline button, the owning domain service does its real work and immediately calls AcknowledgeByGroupKey:
| Kind | Action | Resolution | FE row becomes |
|---|---|---|---|
friend.request_received |
Accept | accepted |
Row stays; CTA morphs to "Message" -> opens the new DM |
friend.request_received |
Decline | declined |
Row removed locally (decline = silent dismiss) |
event.join_requested |
Approve | approved |
Row stays; CTA morphs to "Manage" -> participant management |
event.join_requested |
Reject | rejected |
Row removed locally |
Store.Resolve writes resolved_at + resolution (and seen_at if still null) via UpdateColumns so updated_at is not bumped: resolution is metadata about what the user did, not a new event, so the row's display time keeps pointing at when it fired.
Kinds and channels¶
Every kind has a default channel bitmask in models.NotificationKindChannels. Channels are Inbox | Push | WS, overridable per emit via NotificationInput.Channels.
| Kind | Inbox | Push | WS | Actionable |
|---|---|---|---|---|
friend.request_received |
yes | yes | yes | yes |
friend.request_accepted |
yes | yes | yes | no |
moment.liked |
yes | no | yes | no |
moment.tagged |
yes | yes | yes | no |
checkin.tagged |
yes | yes | yes | no |
event.join_requested |
yes | yes | yes | yes |
event.join_approved |
yes | yes | yes | no |
event.details_changed |
yes | yes | yes | no |
event.host_cancelled |
yes | yes | yes | no |
plan.invited |
yes | yes | yes | no |
plan.promoted |
yes | yes | yes | no |
plan.item_added |
yes | no | yes | no |
plan.item_commented |
yes | yes | yes | no |
plan.poll_option_proposed |
yes | no | yes | no |
chat.message |
no | yes | yes | no |
chat.mention |
no | yes | yes | no |
chat.reaction |
no | yes | yes | no |
Chat kinds never persist an inbox row; they flow through the pipeline so push and the live WS update share one code path, but ChannelInbox is unset, so the WS payload arrives as a notification:transient envelope the FE renders without writing to the inbox cache. NotificationKind.IsActionable() is the single source of truth for whether a kind groups on resolved_at vs seen_at; adding an actionable kind is two edits (the IsActionable switch and the channel map).
WebSocket events¶
| Event | When | Payload |
|---|---|---|
notification:new |
Every successful Inbox-channel emit (created or merged) | { notification } (the full stored row) |
notification:unread |
After every Inbox write and after MarkAllSeen |
{ unread_count } |
notification:resolved |
AcknowledgeByGroupKey resolves a row |
{ notification } (the patched row) |
notification:transient |
Non-Inbox kinds (chat) | { kind, target_type, target_id, actor, payload } |
The chat:* events (chat:new_message, chat:mark_read), friend:graph_changed, and event:updated also ride /ws/client but are produced directly by the owning service via Service.EmitClient, not by the inbox pipeline.
Dispatcher interface¶
// backend/internal/platform/notify/
type PushDispatcher interface {
Dispatch(ctx context.Context, recipientUserID uuid.UUID, notif PushPayload) error
}
NoopPushDispatcher is wired today; it logs at debug and returns nil. PushPayload carries Kind, GroupKey, target ids, Actor, Payload, and a Badge count (unread chats + unseen notifications) for the app-icon badge. A real provider drops in at wiring time and reads per-device credentials from push_tokens; callers never change.
Data model¶
| Table | Notes |
|---|---|
notifications |
user_id, kind, group_key, actors (jsonb), actor_count, target_type, target_id, payload (jsonb), seen_at, resolved_at, resolution, timestamps. Composite indexes on (user_id, created_at desc) and (user_id, group_key). |
push_tokens |
user_id, platform (ios / android / web), token, app_version, last_seen_at. (user_id, platform, token) is unique so re-registration is idempotent and touches last_seen_at. |
Notable behavior¶
Best-effort everywhere
Domain code calls Notify inside the success path of the triggering action. The service swallows persistence / WS / push errors so a hub hiccup never fails a friend request. Tests inject NoopNotifier to exercise trigger sites without the full graph.
Self-actor short-circuit
Notify drops any input where ActorID == UserID before any work, so domain code can always call it without checking whether the actor is the target.
Asset keys, not URLs, in the payload
Writers persist asset keys (thumbnail_key, image_key); hydratePayloadURLs swaps each for a freshly-resolved storage.PublicURL just before the payload leaves the service, so a base-url or CDN change heals every stored row immediately.
Localization at the edge
Rows store i18n keys + interpolation vars in payload, never localized strings. The FE renderer maps each kind to a translation key under notifications.*; live username resolution keeps display names current after a rename without rewriting stored rows.
Where to look¶
backend/internal/models/notification.go—Notification,PushToken,NotificationKind, channel map,IsActionable.backend/internal/services/notification/store.go—UpsertGrouped(hybrid open predicate),Resolve,MarkSeen,PurgeOlderThan, push-token CRUD.backend/internal/services/notification/service.go—Notify,EmitClient,AcknowledgeByGroupKey, WS emits, push fan-out.backend/internal/services/notification/handler.go— HTTP surface.backend/internal/platform/notify/—Notifier,NotificationInput,PushDispatcher,PushPayload,GroupKey().backend/internal/services/friend/,backend/internal/services/event/— call sites that resolve actionable rows.backend/internal/async/handlers/cron.go—HandleNotificationPurge(PurgeOlderThan(now - 90d),@every 24h).frontend/services/notificationService.ts,frontend/contexts/NotificationsContext.tsx.