Appearance
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 updateIdempotent 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:
- No existing notification → insert normally
- Existing is already seen → skip (don't disturb the user)
- New has higher priority (lower number) → delete existing, insert new
- Same priority, same type → update in place (progress update)
- 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
| Event | Trigger |
|---|---|
NOTIFICATION_CREATED | New notification inserted |
NOTIFICATION_SEEN | Single notification marked seen |
NOTIFICATIONS_MARKED_SEEN | Batch mark seen (includes UUID list) |
NOTIFICATIONS_ALL_SEEN | All notifications marked seen |
NOTIFICATION_DELETED | Notification 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 CONFLICTupsert, 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.