Skip to main content

Implementation Plan: Notifications

Feature ID: notifications Spec: ./spec.md Status: Done (Retrospective) Last updated: 2026-05-08


1. Architecture

2. Tech Choices

ConcernChoiceRationale
StoragePostgres via TypeORM entitySame primary store as the rest of the platform
DeduplicationOptional deduplicationKey + service-side check + DB unique constraintDefence in depth against in-process and cross-process races
Mutual exclusionDistributedTaskLockService.runExclusiveConstitution Principle IV; cleanup only
CronPer-instance scheduled service (@Cron) + lockMultiple replicas safe; cleanup runs once per window
APINestJS REST controller behind AuthSessionGuardMatches the rest of the platform
Persistent bannerisPersistent flag + dedicated /persistent endpointUI can render a global banner without scanning the list

3. Data Model

@Entity('notifications')
class Notification {
id: string;
userId: string; // FK -> users
type: 'info' | 'warning' | 'error' | 'success';
category: 'ai_credits' | 'subscription' | 'generation' | 'system' | 'security';
title: string;
message: string;
actionUrl?: string;
actionLabel?: string;
metadata?: Record<string, any>; // jsonb
isPersistent: boolean; // default false
isDismissed: boolean; // default false
isRead: boolean; // default false
expiresAt?: Date;
deduplicationKey?: string;
createdAt: Date;
}

Indexes (additive, non-breaking migration):

  • (userId, createdAt desc) — primary list query.
  • (userId, isPersistent) partial — persistent banner query.
  • Partial unique (userId, deduplicationKey) WHERE deduplicationKey IS NOT NULL — enforces dedup at the DB layer.

4. API Surface

MethodEndpointDescription
GET/api/notificationsList w/ unreadOnly/limit/offset/category. limit capped at 100.
GET/api/notifications/unread-count{count} envelope.
GET/api/notifications/persistentReturns only isPersistent=true rows for the current user.
POST/api/notifications/:id/readMark single notification read; 400 if cross-user / missing.
POST/api/notifications/read-allMark all current-user notifications read.
POST/api/notifications/:id/dismissDismiss; 400 with explanation if isPersistent=true.

All endpoints return JSON envelopes:

  • List endpoints: { notifications: Notification[] }.
  • Mutation endpoints: { success: true } (or 400 with BadRequestException body).

5. Plugin / Web / CLI

  • Plugin: no plugin surface — internal cross-cutting service.
  • Web: header bell icon polling /unread-count every 30s; full panel uses /api/notifications; persistent banner subscribes to /persistent. Dismissal is optimistic; on 400 the row is restored and the error toast is shown.
  • CLI: not exposed (CLI is for the work / generator surface).

6. Background Jobs

NotificationCleanupService runs every 60 minutes (configurable per deployment). Each invocation:

  1. Calls DistributedTaskLockService.runExclusive('notifications:cleanup', {ttlMs: 5 * 60_000, onLocked: () => debug log}).
  2. Inside the lock: service.cleanup() → 3 deletes:
    • deleteExpired()
    • deleteOlderThan({ olderThanDays: 7, isDismissed: true })
    • deleteOlderThan({ olderThanDays: 30 })
  3. Logs a single line with the three counts.
  4. On error: swallowed and logged; the next window retries.

7. Security & Permissions

  • All endpoints behind AuthSessionGuard.
  • All mutations route through findByIdAndUserId(id, userId) so a user cannot dismiss / read / target another user's notifications.
  • Producer methods take only safe strings (provider name, work name, free-form error message) — no API keys, tokens, or PII other than the user-supplied workName.

8. Observability

  • Producer log on create: Created notification <id> for user <userId>: <title>.
  • Cleanup log: Notification cleanup: <expired> expired, <dismissed> dismissed (>7d), <old> old (>30d).
  • Dedup race log (debug): Race condition detected for deduplication key <key>, fetching existing.

9. Risks & Mitigations

RiskMitigation
Notification flood from a stuck error loopdeduplicationKey collapses repeated emissions to a single row
Cleanup running on multiple replicasDistributedTaskLockService lock
User dismisses a critical security notificationisPersistent=true notifications refuse dismissal w/ explanatory message
Race condition on parallel inserts of same keyCatch unique-constraint code (23505 / ER_DUP_ENTRY / SQLITE_CONSTRAINT) and recover
Notification table unbounded growth30-day expiry sweep + 7-day dismissed sweep
Cleanup error wedges the next windowOuter try/catch logs and swallows; lock TTL caps held lock to 5 min

10. Constitution Reconciliation

See spec.md §9.

11. References

  • Spec: ./spec.md
  • Service: packages/agent/src/notifications/notification.service.ts
  • Controller: apps/api/src/notifications/notifications.controller.ts
  • Cleanup worker: apps/api/src/notifications/notification-cleanup.service.ts
  • Tests: apps/api/src/notifications/{notifications.controller.spec.ts,notification-cleanup.service.spec.ts}
  • Lock service: packages/agent/src/cache/distributed-task-lock.service.ts