Skip to content

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, and notification:transient over /ws/client.
  • Hand off to PushDispatcher for 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_purge cron.

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