Skip to content

NotificationManager

Manages in-app notification storage, retrieval, seen/unseen tracking, and real-time broadcasting via WebSocket.

Purpose

NotificationManager handles the full lifecycle of in-app notifications within a user's portfolio. Domain managers create notifications when events occur (trades executed, challenges passed/failed, subscriptions expiring, etc.), which are stored in SQLite and broadcast to connected WebSocket clients in real-time.

High-Level Design

Data Flow

Domain Manager (e.g. ChallengeManager, PriceAlertManager)

  │ createNotification({ type, insertId, context, priorityGroup? })

NotificationManager

  ├─► SQLite: insert/upsert into in_app_notifications
  │     └─► Update cached unseen_notification_count in account table

  └─► WebSocketManager.broadcast({ type: "NOTIFICATION_CREATED", ... })
        └─► Connected clients receive real-time update

Idempotent Creation via Insert ID

Every notification carries an insertId (e.g. a trade ID or challenge ID) paired with its type. The (insert_id, type) unique constraint enables upsert semantics — calling createNotification twice with the same key updates the existing row rather than creating a duplicate. This makes notification creation safe to retry.

Priority Group Replacement

Notifications can belong to a priority group (e.g. CHALLENGE_PROGRESS, SUBSCRIPTION_REMINDER). Within a group sharing the same insertId, replacement follows these rules:

  1. No existing notification → insert normally
  2. Existing is already seen → skip (don't disturb the user)
  3. New has higher priority (lower number) → delete existing, insert new
  4. Same priority, same type → update in place (progress update)
  5. Otherwise → skip

This means a CHALLENGE_NEAR_PROFIT_TARGET (priority 2) replaces CHALLENGE_STARTED (priority 4), but CHALLENGE_PASSED (priority 1) replaces everything. Once the user has seen a notification, it's never replaced regardless of priority.

Cached Unseen Count

The account.unseen_notification_count column acts as a materialized count for O(1) badge queries. Every insert, delete, and seen-status change atomically updates this counter within the same SQLite transaction. MAX(0, count - N) guards prevent negative drift. A recalculateUnseenCount() method exists as a recovery mechanism if the cache ever gets out of sync.

Transaction Boundaries

All database mutations that touch both the notification table and the unseen count are wrapped in transactionSync(). WebSocket broadcasts happen outside the transaction as fire-and-forget side effects — a broadcast failure never rolls back the database write.

WebSocket Event Types

EventTrigger
NOTIFICATION_CREATEDNew notification inserted
NOTIFICATION_SEENSingle notification marked seen
NOTIFICATIONS_MARKED_SEENBatch mark seen (includes UUID list)
NOTIFICATIONS_ALL_SEENAll notifications marked seen
NOTIFICATION_DELETEDNotification deleted

All events include the current unseenCount so clients can update badges without a separate query.

Cursor-Based Pagination

getNotifications() uses the auto-increment id column as a cursor, fetching limit + 1 rows to detect whether more pages exist. Supports filtering by seen status and notification type array.

Edge Cases & Error Handling

  • UUID-based new-vs-update detection: After an ON CONFLICT upsert, the manager checks whether the generated UUID exists in the table. If it does, this was a true insert (increment count). If not, the conflict path fired and the old row was updated (no count change).
  • Corrupted JSON context: Returns a minimal fallback context (userId: "", accountId, createdAt) instead of crashing, so the notification can still be displayed or deleted.
  • Batch seen operations: Processes UUIDs individually within a single transaction rather than using IN (...) with large parameter lists, avoiding SQLite's ~999 parameter limit.
  • Account reset: clearNotificationsForAccount() bulk-deletes all notifications and resets the counter atomically.

See Also