Skip to content

PR 8 — Notification Subscriptions

This PR introduces a small, decoupled notification-subscription system so roles like compliance_admin and infra_ops can opt into receiving alerts (worker failures, discrepancy spikes, billing anomalies) without those subscriptions being part of the RBAC catalog.

Independent. No dependency on PR1–7. Designed orthogonal so any role (or any user, regardless of role) can subscribe.


1. Goal

  • Schema: notification_subscriptions keyed by (user_id, event_type).
  • Catalog of supported event types in code (small, stable list — adding a new type is a deliberate code change).
  • Backend hook from existing event sources (worker failure, discrepancy scan, billing job) to fan out to subscribers.
  • User-facing UI under /account/notifications to manage own subscriptions.
  • Default subscriptions seeded for compliance_admin and infra_ops on initial role assignment (overridable by user).

Non-goals

  • Per-customer notification routing (notifications are platform-internal for now; partner-facing notifications are a future phase).
  • Slack / PagerDuty / SMS — email + in-app only in this PR. Channel fan-out abstracted but only email + in-app implemented.
  • Notification digesting / rate-limiting (basic dedup only — same event hash within 1h not re-sent to same user).

2. Schema

New migration: internal/db/migrations/010_notification_subscriptions.sql

sql
CREATE TABLE notification_subscriptions (
  user_id     UUID NOT NULL,                 -- internal_users.id (portal users not supported here)
  event_type  TEXT NOT NULL,                 -- e.g. 'worker.failure', 'discrepancy.scan_complete'
  channels    TEXT[] NOT NULL DEFAULT ARRAY['in_app'],   -- subset of {'email', 'in_app'}
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, event_type)
);

CREATE INDEX idx_notification_subs_event ON notification_subscriptions(event_type);

-- Seed defaults at user-role assignment time (not a SQL trigger; handled in
-- application code so it's reversible / discoverable).

-- Notification log (delivered notifications; in-app inbox).
CREATE TABLE notifications (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID NOT NULL,
  event_type  TEXT NOT NULL,
  payload     JSONB NOT NULL,
  read_at     TIMESTAMPTZ,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_notifications_user_unread
  ON notifications(user_id, created_at DESC)
  WHERE read_at IS NULL;

3. Event catalog

In code, internal/notif/events.go:

go
const (
    EventWorkerFailure         = "worker.failure"
    EventDiscrepancyScanFailed = "discrepancy.scan_failed"
    EventDiscrepancySpike      = "discrepancy.spike"           // count > threshold
    EventBillingJobFailed      = "billing.job_failed"
    EventInstanceUnhealthy     = "instance.unhealthy"
    // adding a new event = code change + migration if seeding default subs
)

var EventCatalog = []EventDefinition{
    {Type: EventWorkerFailure, Description: "A system worker failed.",
     DefaultRoles: []string{"compliance_admin", "infra_ops"}},
    {Type: EventDiscrepancyScanFailed, ...},
    // ...
}

Catalog is the source of truth for the UI subscription picker and for seeding defaults on role assignment.


4. Publisher / subscriber

Publisher

Existing event sources (worker, scanner, billing engine) call:

go
notif.Publish(ctx, notif.EventWorkerFailure, payload)

notif.Publish:

  1. Looks up subscribers: SELECT user_id, channels FROM notification_subscriptions WHERE event_type = $1.
  2. Dedup: skip if same (user_id, event_type, hash(payload)) delivered in last 1h.
  3. For each subscriber × channel: enqueue a delivery job.
  4. Fan-out is async via a small worker (separate from publisher's request).

Channels

go
type Channel interface {
    Deliver(ctx context.Context, userID uuid.UUID, eventType string, payload any) error
}

type EmailChannel struct{ ... }     // uses existing email_logs path
type InAppChannel struct{ ... }     // INSERT into notifications

PR8 ships EmailChannel + InAppChannel. Slack/PagerDuty/SMS are additional Channel implementations later.


5. UI

Account settings — subscriptions

/account/notifications:

  • Table: rows per event type from EventCatalog.
  • Columns: Event, Description, In-app toggle, Email toggle.
  • Default values reflect seed for the user's role; user override is persisted to notification_subscriptions.

In-app inbox

Top-bar bell icon with unread count.

  • Click → drawer with recent unread notifications.
  • Each notification: title (from event payload), timestamp, optional click-through (e.g. failed worker → discrepancy detail page).
  • Mark read on open or via menu.

Uses existing Drawer, Table, Badge, User components per CLAUDE.md.


6. Default subscriptions on role assignment

When internal_users.roles is updated (invite, edit), reconcile defaults:

  1. For each role in user's roles:
  2. For each event in EventCatalog whose DefaultRoles includes this role:
  3. If user has no row in `notification_subscriptions` for this event:
    
  4.   INSERT default subscription with default channels.
    

The seeding is opt-in by default but never re-applied if the user has already touched their subscription preferences (so unsubscribing sticks).

Implementation detail: track "user has customized subscriptions" implicitly — if a row exists for (user, event), don't auto-add. Any explicit unsubscribe writes a row with channels = '{}' (empty array) to mark it as "user said no" rather than "default applies."


7. Audit log

Notification subscription changes are user-driven and don't need high-fidelity audit. Skip dedicated audit log entries; rely on the generic notifications row history for diagnostic.

If compliance later requires "who subscribed to what when," extend with an audit_log row at subscribe / unsubscribe time. Defer.


8. Tests

LayerTest
DBMigration up/down
PublisherMultiple subscribers — all receive (per channel)
PublisherDedup — same payload hash within 1h not re-sent
ChannelEmail channel routes through existing email_logs
ChannelIn-app channel inserts into notifications table
DefaultsNew compliance_admin user gets worker.failure subscription seeded
DefaultsUser unsubscribes; role re-assigned doesn't re-seed
APIList subscriptions returns user's effective subscriptions
APIToggle in-app off persists and stops in-app delivery
FrontendSubscriptions table renders catalog; toggles persist
FrontendBell icon shows unread count; drawer opens; mark-read works

9. Implementation order

StepLayerDescription
1DBMigration 010_notification_subscriptions.sql
2Backendinternal/notif/events.go — catalog
3Backendinternal/notif/publisher.go + worker queue
4Backendinternal/notif/channels/email.go, in_app.go
5BackendHook publishers from worker / scanner / billing job paths
6BackendDefault seeding on role-assignment hook in user-invite / edit flow
7BackendSubscription CRUD endpoints under /api/v1/me/notifications
8BackendIn-app inbox endpoints (GET, PATCH /:id/read)
9Frontend/account/notifications settings page
10FrontendTop-bar bell + drawer
11TestsPer Section 8
12DocsUpdate docs/roles-and-permissions.md with notifications section

10. Risk register

RiskMitigation
Publisher slow in request path holding up worker writePublish is fire-and-forget (enqueue); failure to enqueue logged + retried
Email storm during a spike of worker failuresDedup hash + 1h window; per-user-per-event-type cap (e.g. 10/hour)
New event type added without DB seed for default subscribersCatalog change = code change; CI test asserts every catalog event has either explicit DefaultRoles (possibly empty) field set
User unsubscribes; role re-applied via re-invite re-adds subscriptionImplementation detail (Section 6) — empty-channels row marks "explicit no"
In-app inbox grows unboundedBackground job archives read_at IS NOT NULL AND created_at < now() - 90d
Notification leaks data the user shouldn't see (e.g. customer they're not scoped to)Payload sanitized to event-relevant fields only; sensitive resource details fetched only when user clicks through (auth-checked there)