Appearance
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_subscriptionskeyed 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/notificationsto manage own subscriptions. - Default subscriptions seeded for
compliance_adminandinfra_opson 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:
- Looks up subscribers:
SELECT user_id, channels FROM notification_subscriptions WHERE event_type = $1. - Dedup: skip if same
(user_id, event_type, hash(payload))delivered in last 1h. - For each subscriber × channel: enqueue a delivery job.
- 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 notificationsPR8 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:
- For each role in user's roles:
- For each event in
EventCatalogwhoseDefaultRolesincludes this role: If user has no row in `notification_subscriptions` for this event: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
| Layer | Test |
|---|---|
| DB | Migration up/down |
| Publisher | Multiple subscribers — all receive (per channel) |
| Publisher | Dedup — same payload hash within 1h not re-sent |
| Channel | Email channel routes through existing email_logs |
| Channel | In-app channel inserts into notifications table |
| Defaults | New compliance_admin user gets worker.failure subscription seeded |
| Defaults | User unsubscribes; role re-assigned doesn't re-seed |
| API | List subscriptions returns user's effective subscriptions |
| API | Toggle in-app off persists and stops in-app delivery |
| Frontend | Subscriptions table renders catalog; toggles persist |
| Frontend | Bell icon shows unread count; drawer opens; mark-read works |
9. Implementation order
| Step | Layer | Description |
|---|---|---|
| 1 | DB | Migration 010_notification_subscriptions.sql |
| 2 | Backend | internal/notif/events.go — catalog |
| 3 | Backend | internal/notif/publisher.go + worker queue |
| 4 | Backend | internal/notif/channels/email.go, in_app.go |
| 5 | Backend | Hook publishers from worker / scanner / billing job paths |
| 6 | Backend | Default seeding on role-assignment hook in user-invite / edit flow |
| 7 | Backend | Subscription CRUD endpoints under /api/v1/me/notifications |
| 8 | Backend | In-app inbox endpoints (GET, PATCH /:id/read) |
| 9 | Frontend | /account/notifications settings page |
| 10 | Frontend | Top-bar bell + drawer |
| 11 | Tests | Per Section 8 |
| 12 | Docs | Update docs/roles-and-permissions.md with notifications section |
10. Risk register
| Risk | Mitigation |
|---|---|
| Publisher slow in request path holding up worker write | Publish is fire-and-forget (enqueue); failure to enqueue logged + retried |
| Email storm during a spike of worker failures | Dedup hash + 1h window; per-user-per-event-type cap (e.g. 10/hour) |
| New event type added without DB seed for default subscribers | Catalog 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 subscription | Implementation detail (Section 6) — empty-channels row marks "explicit no" |
| In-app inbox grows unbounded | Background 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) |

