A hybrid notification system supporting both in-app (real-time) and push notifications across iOS and PWA clients.
Core Components:
┌─────────────────┐
│ Notification │
│ Created │
└────────┬────────┘
│
↓
┌─────────────────────────────────┐
│ Route by Delivery & User State │
│ - Check: is_user_online()? │
│ - Delivery: 'push' or 'in_app' │
└────────┬────────────────────────┘
│
┌────┴────┐
│ │
↓ ↓
┌─────────┐ ┌──────────┐
│Socket.io│ │ FCM │
│(online) │ │(offline) │
└─────────┘ └──────────┘
│ │
↓ ↓
┌──────────────────────┐
│ Client Devices │
│ - iOS App │
│ - PWA │
└──────────────────────┘
Decision: Build on open-source components (Socket.io, FCM) rather than use all-in-one managed services
Managed Services Considered:
Why Self-Hosted Won:
Cost at Scale:
Flexibility and Control:
severity + delivery model to our exact needsdispatch.py event systemInfrastructure Alignment:
Feature Parity:
Vendor Lock-in Avoidance:
When Managed Services Make Sense:
Our Context:
Hybrid Approach Considered: Could use managed service for rapid launch, then migrate to self-hosted. Rejected because:
Result: Build notification system on open-source foundations (Socket.io, Redis, FCM) with full control and minimal ongoing costs.
Chosen: Socket.io (Python library) with Redis backend
Alternatives Considered:
Rationale: Socket.io integrates directly into the FastAPI application as a Python library, eliminating the need for binary installation or separate service deployment. The Redis backend ($13-30/month ElastiCache) provides multi-instance coordination we need anyway. Socket.io’s mature ecosystem, client libraries, and automatic reconnection logic provide production-ready features without building custom infrastructure.
Chosen: AWS ElastiCache Redis
Alternatives Considered:
Rationale: Redis pub-sub is purpose-built for real-time message coordination across distributed systems. ElastiCache provides:
Role in System:
Development parity: Local development uses a simple Redis container in docker-compose, staging/prod use ElastiCache with identical API.
Note on PostgreSQL alternative: While PostgreSQL LISTEN/NOTIFY could theoretically work (as proven by Supabase Realtime), it’s not designed for high-frequency pub-sub and would require building a custom Socket.io client manager. Redis is the standard, battle-tested solution for this pattern.
Chosen: Firebase Cloud Messaging
Alternatives Considered:
Rationale: FCM is completely free with no message or device limits, handles both iOS (via APNs proxy) and Web Push through a single API, and provides mature SDKs. The engineering cost of alternatives far exceeds any vendor lock-in concerns.
Design Choice: In-app notifications for online users, push notifications for offline/backgrounded users
Rationale: This “opportunistic fallforward” pattern optimizes for three goals:
The delivery field indicates notification importance: delivery='push' means “reach user even if offline” (but fallforward to in-app if online), while delivery='in_app' means “in-app only, no interruption.”
Design Choice: All notifications persisted to PostgreSQL, regardless of delivery channel
Rationale: Neither Socket.io nor FCM provides reliable persistent storage. PostgreSQL serves as:
Socket.io and FCM are transport layers, not storage systems. This separation allows reliable delivery guarantees and historical access.
Design Choice: severity (alert/warning/info) separate from delivery (push/in-app)
Rationale: These are orthogonal concerns:
Examples:
severity='warning', delivery='in_app' - Important but not interruptiveseverity='info', delivery='push' - Low urgency but needs guaranteed deliveryseverity='alert', delivery='push' - Critical and must reach userInfrastructure Complexity: Adding ElastiCache Redis introduces one more managed service (~$13-30/month). This is acceptable because:
Client-Side Implementation: Hybrid system requires client-side state management, Socket.io integration, and FCM setup. This is acceptable because:
No Delivery Receipts: FCM doesn’t provide reliable delivery confirmation. This is acceptable because:
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
org_id UUID,
-- Classification
type VARCHAR NOT NULL, -- 'payment_received', 'invoice_created', etc.
severity VARCHAR NOT NULL, -- 'alert', 'warning', 'info'
delivery VARCHAR NOT NULL, -- 'push' (can fallforward to in-app) or 'in_app' (only)
-- Content
title VARCHAR(255) NOT NULL,
body TEXT,
data JSONB, -- Type-specific payload
-- State
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
read_at TIMESTAMP,
-- Delivery tracking (optional, for analytics)
sent_via VARCHAR, -- 'socket', 'push', NULL
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id),
INDEX idx_user_unread (user_id, read_at) WHERE read_at IS NULL,
INDEX idx_created_at (created_at DESC)
);
CREATE TABLE push_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
-- Device identification
fcm_token VARCHAR(255) NOT NULL UNIQUE,
platform VARCHAR(50) NOT NULL, -- 'ios', 'web', 'android'
device_id VARCHAR(255), -- Client-provided device identifier
-- Metadata
user_agent TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id),
INDEX idx_user_tokens (user_id)
);
CREATE TABLE notification_preferences (
user_id UUID NOT NULL,
notification_type VARCHAR NOT NULL,
-- Channel preferences
in_app_enabled BOOLEAN NOT NULL DEFAULT TRUE,
push_enabled BOOLEAN NOT NULL DEFAULT TRUE,
-- Settings
quiet_hours_start TIME,
quiet_hours_end TIME,
PRIMARY KEY (user_id, notification_type),
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id)
);
GET /api/notifications
Query params: offset, limit, unread_only
Returns: List of notifications with pagination
GET /api/notifications/unread-count
Returns: { count: number }
POST /api/notifications/{id}/read
Marks notification as read
Broadcasts read event to other devices via Socket.io
POST /api/notifications/bulk-read
Body: { notification_ids: string[] }
Marks multiple notifications as read
DELETE /api/notifications/{id}
Soft delete or hard delete notification
POST /api/push/register
Body: { fcm_token, platform, device_id }
Registers device for push notifications
DELETE /api/push/unregister
Body: { fcm_token }
Removes device from push notifications
GET /api/push/devices
Returns: List of user's registered devices
GET /api/notifications/preferences
Returns: User's notification preferences by type
PUT /api/notifications/preferences
Body: { type, in_app_enabled, push_enabled }
Updates user preferences
// New notification
'notification' → { id, type, severity, delivery, title, body, data, created_at }
// Another device marked notification as read
'notification_read' → { notification_id, read_at }
// Bulk read event
'notifications_bulk_read' → { notification_ids: string[] }
// Initial sync response
'sync' → { notifications: Notification[] }
// Subscribe to user's notification channel
'subscribe' → { user_id }
Response: { status: 'subscribed' }
// Request sync of unread notifications
'resync' → { last_sync_at?: timestamp }
Response: via 'sync' event
// Mark as read (alternative to REST)
'mark_read' → { notification_id }
Response: { status: 'ok' }
Side effect: Broadcasts 'notification_read' to user's other devices
// Bulk mark as read
'bulk_read' → { notification_ids: string[] }
Response: { status: 'ok' }
Side effect: Broadcasts to other devices
// Get unread count
'get_unread_count' → {}
Response: { count: number }
// On connect
@sio.on('connect')
- Authenticate user
- Join user's room (user:{user_id})
- Optional: Send unread count or recent notifications
// On disconnect
@sio.on('disconnect')
- Clean up room membership
- Redis coordinates state across instances
Concept: Notifications classified with delivery='push' are delivered via the most appropriate channel based on user connection state. This means delivery='push' notifications may actually be delivered via in-app Socket.io if the user is online - we “fallforward” from push to the more efficient real-time channel when possible.
async def send_notification(user_id, notification):
# 1. Persist to database (source of truth)
await Notification.create(user_id, notification)
# 2. Check user preferences
prefs = await get_user_preferences(user_id, notification.type)
if not prefs.enabled:
return
# 3. Route based on delivery setting and connection state
if notification.delivery == 'push':
# Important: try to reach user via best channel
if await is_user_online(user_id):
# FALLFORWARD: User is active → use in-app (TCP-like)
await sio.emit('notification', notification, room=f'user:{user_id}')
else:
# User offline/backgrounded → use push (UDP-like)
await send_fcm_push(user_id, notification)
elif notification.delivery == 'in_app':
# In-app only: no interruption
if await is_user_online(user_id):
await sio.emit('notification', notification, room=f'user:{user_id}')
# If offline: user will see on reconnect via sync
@sio.on('connect')
async def on_connect(sid, environ):
user_id = authenticate(environ)
# Sync all unread notifications (both priorities)
unread = await Notification.get_by(
user_id=user_id,
read_at=None
)
await sio.emit('sync', {'notifications': unread}, to=sid)
Result: User always sees complete notification history, regardless of delivery method used.
# Environment variables
REDIS_URL = os.environ.get('REDIS_URL')
FIREBASE_CREDENTIALS_PATH = os.environ.get('FIREBASE_CREDENTIALS')
# Socket.io setup
sio = AsyncServer(
async_mode='asgi',
cors_allowed_origins='*',
client_manager=socketio.AsyncRedisManager(REDIS_URL)
)
# FCM setup
cred = credentials.Certificate(FIREBASE_CREDENTIALS_PATH)
firebase_admin.initialize_app(cred)
For Backend (Python):
firebase-credentials.json)Set environment variable:
# Development
export FIREBASE_CREDENTIALS_PATH=/path/to/firebase-credentials.json
# Production (EB)
eb setenv FIREBASE_CREDENTIALS_PATH=/var/app/config/firebase-credentials.json
Deploy credentials to EB:
# Copy to .ebextensions or use AWS Secrets Manager
# Option 1: S3 + .ebextensions script to download
# Option 2: AWS Secrets Manager (recommended for production)
FCM uses Apple Push Notification service (APNs) for iOS. You need to upload your APNs certificate/key to Firebase.
Prerequisites:
Steps:
.p8 file (can only download once!).p12 file)com.modfin.app)GoogleService-Info.plistFCM handles Web Push via VAPID (Voluntary Application Server Identification).
Steps:
# Next.js .env.local
NEXT_PUBLIC_FIREBASE_API_KEY=AIza...
NEXT_PUBLIC_FIREBASE_PROJECT_ID=modfin-notifications
NEXT_PUBLIC_FIREBASE_SENDER_ID=123456789
NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123
NEXT_PUBLIC_FIREBASE_VAPID_KEY=BG7xX... (public VAPID key)
Install Python SDK:
pip install firebase-admin
Initialize in Application:
# app/notifications/firebase.py
import firebase_admin
from firebase_admin import credentials, messaging
import os
# Initialize once at startup
def init_firebase():
cred_path = os.environ.get('FIREBASE_CREDENTIALS_PATH')
if not cred_path:
raise ValueError("FIREBASE_CREDENTIALS_PATH not set")
cred = credentials.Certificate(cred_path)
firebase_admin.initialize_app(cred)
# Call in main.py startup
init_firebase()
# Send notification
async def send_fcm_push(fcm_token: str, notification: dict):
message = messaging.Message(
notification=messaging.Notification(
title=notification['title'],
body=notification['body']
),
data={
'notification_id': notification['id'],
'type': notification['type'],
},
token=fcm_token
)
try:
response = messaging.send(message)
return response
except messaging.UnregisteredError:
# Token invalid - remove from database
await remove_invalid_token(fcm_token)
except Exception as e:
logging.error(f"FCM send failed: {e}")
raise
Test from Firebase Console:
Test from Backend:
# Test script
import asyncio
from app.notifications.firebase import send_fcm_push
async def test():
token = "device-fcm-token-here"
notification = {
'id': 'test-123',
'type': 'test',
'title': 'Test Notification',
'body': 'This is a test'
}
response = await send_fcm_push(token, notification)
print(f"Message sent: {response}")
asyncio.run(test())
Credentials:
firebase-credentials.json to git.gitignoreToken Storage:
API Access:
Development:
Staging:
Production:
Per-Environment Credentials:
# .env.development
FIREBASE_CREDENTIALS_PATH=./firebase-dev-credentials.json
# .env.staging (EB)
FIREBASE_CREDENTIALS_PATH=/var/app/config/firebase-staging-credentials.json
# .env.production (EB)
FIREBASE_CREDENTIALS_PATH=/var/app/config/firebase-prod-credentials.json
# Example configuration
NOTIFICATION_CONFIG = {
'payment_received': {
'severity': 'alert', # Severity: alert, warning, info
'delivery': 'push', # Delivery: push or in_app
'default_enabled': True
},
'invoice_overdue': {
'severity': 'warning',
'delivery': 'push',
'default_enabled': True
},
'invoice_created': {
'severity': 'info',
'delivery': 'in_app',
'default_enabled': True
},
'profile_updated': {
'severity': 'info',
'delivery': 'in_app',
'default_enabled': True
},
'system_maintenance': {
'severity': 'warning',
'delivery': 'in_app',
'default_enabled': False # Opt-in
}
}
id for idempotencyis_user_online() works across all instances(user_id, read_at) for unread queriescreated_at for paginationuser:{user_id} channel{
"dependencies": {
"socket.io-client": "^4.x",
"firebase": "^10.x"
}
}
// lib/notifications/socket.ts
import { io, Socket } from 'socket.io-client';
class NotificationSocket {
private socket: Socket | null = null;
private userId: string | null = null;
connect(userId: string, authToken: string) {
this.userId = userId;
this.socket = io(process.env.NEXT_PUBLIC_API_URL, {
auth: { token: authToken },
transports: ['websocket', 'polling'],
});
this.socket.on('connect', () => {
// Subscribe to user's notification channel
this.socket?.emit('subscribe', { user_id: userId });
});
this.socket.on('notification', this.handleNotification);
this.socket.on('notification_read', this.handleNotificationRead);
this.socket.on('sync', this.handleSync);
this.socket.on('disconnect', () => {
// Handle reconnection automatically
});
}
disconnect() {
this.socket?.disconnect();
}
markAsRead(notificationId: string) {
this.socket?.emit('mark_read', { notification_id: notificationId });
}
requestResync(lastSyncAt?: string) {
this.socket?.emit('resync', { last_sync_at: lastSyncAt });
}
private handleNotification = (data: Notification) => {
// Update state, show toast, etc.
};
private handleNotificationRead = (data: { notification_id: string }) => {
// Update state across tabs
};
private handleSync = (data: { notifications: Notification[] }) => {
// Hydrate notification list
};
}
export const notificationSocket = new NotificationSocket();
// Using Zustand, Redux, Context, or similar
interface NotificationStore {
notifications: Map<string, Notification>;
unreadCount: number;
isConnected: boolean;
addNotification: (notification: Notification) => void;
markAsRead: (id: string) => void;
setNotifications: (notifications: Notification[]) => void;
}
// Hook for components
export function useNotifications() {
const store = useNotificationStore();
useEffect(() => {
// Connect on mount (if authenticated)
if (userId && authToken) {
notificationSocket.connect(userId, authToken);
}
return () => notificationSocket.disconnect();
}, [userId, authToken]);
return {
notifications: Array.from(store.notifications.values()),
unreadCount: store.unreadCount,
markAsRead: async (id: string) => {
await fetch(`/api/notifications/${id}/read`, { method: 'POST' });
store.markAsRead(id);
}
};
}
Firebase Cloud Messaging is free with no limits on devices or message volume.
// lib/notifications/webpush.ts
import { initializeApp } from 'firebase/app';
import { getMessaging, getToken, onMessage } from 'firebase/messaging';
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};
const app = initializeApp(firebaseConfig);
const messaging = getMessaging(app);
export async function requestPushPermission() {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
// Get FCM token
const token = await getToken(messaging, {
vapidKey: process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY
});
// Register with backend
await fetch('/api/push/register', {
method: 'POST',
body: JSON.stringify({
fcm_token: token,
platform: 'web',
device_id: getDeviceId(),
})
});
return token;
}
return null;
}
// Handle foreground messages
onMessage(messaging, (payload) => {
// App is open - notification already came via Socket.io
// Can optionally show toast here
console.log('Foreground message:', payload);
});
// Handle background push notifications
self.addEventListener('push', (event) => {
const data = event.data.json();
const options = {
body: data.notification.body,
icon: '/icon.png',
badge: '/badge.png',
data: data.data, // Pass through notification_id, type, etc.
};
event.waitUntil(
self.registration.showNotification(data.notification.title, options)
);
});
// Handle notification click
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const notificationId = event.notification.data.notification_id;
event.waitUntil(
clients.openWindow(`/notifications/${notificationId}`)
);
});
// app/layout.tsx or _app.tsx
export default function RootLayout({ children }) {
useEffect(() => {
// Initialize on app load
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
// Request push permission (after user interaction)
// requestPushPermission();
}, []);
return (
<>
<NotificationProvider>
{children}
</NotificationProvider>
</>
);
}
Components would include:
<NotificationBell> - Icon with unread badge<NotificationDropdown> - List of recent notifications<NotificationToast> - Temporary alert for new notifications<NotificationList> - Full page notification center// Package.swift or CocoaPods
dependencies: [
.package(url: "https://github.com/socketio/socket.io-client-swift", from: "16.0.0"),
.package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.0.0")
]
// NotificationSocketManager.swift
import SocketIO
class NotificationSocketManager {
static let shared = NotificationSocketManager()
private var manager: SocketManager?
private var socket: SocketIOClient?
private var userId: String?
func connect(userId: String, authToken: String) {
self.userId = userId
manager = SocketManager(
socketURL: URL(string: Config.apiURL)!,
config: [
.log(false),
.compress,
.auth(["token": authToken])
]
)
socket = manager?.defaultSocket
socket?.on(clientEvent: .connect) { [weak self] data, ack in
self?.socket?.emit("subscribe", ["user_id": userId])
}
socket?.on("notification") { [weak self] data, ack in
self?.handleNotification(data)
}
socket?.on("notification_read") { [weak self] data, ack in
self?.handleNotificationRead(data)
}
socket?.on("sync") { [weak self] data, ack in
self?.handleSync(data)
}
socket?.connect()
}
func disconnect() {
socket?.disconnect()
}
func markAsRead(notificationId: String) {
socket?.emit("mark_read", ["notification_id": notificationId])
}
func requestResync(lastSyncAt: String? = nil) {
if let lastSync = lastSyncAt {
socket?.emit("resync", ["last_sync_at": lastSync])
} else {
socket?.emit("resync", [:])
}
}
private func handleNotification(_ data: [Any]) {
guard let notification = parseNotification(data) else { return }
NotificationCenter.default.post(
name: .newNotification,
object: notification
)
}
private func handleNotificationRead(_ data: [Any]) {
// Update local state
}
private func handleSync(_ data: [Any]) {
// Hydrate notification list
}
}
Firebase Cloud Messaging is free with no limits on devices or message volume.
// AppDelegate.swift
import UIKit
import FirebaseCore
import FirebaseMessaging
import UserNotifications
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Firebase setup
FirebaseApp.configure()
// Push notification registration
UNUserNotificationCenter.current().delegate = self
let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
UNUserNotificationCenter.current().requestAuthorization(
options: authOptions) { granted, _ in
if granted {
DispatchQueue.main.async {
application.registerForRemoteNotifications()
}
}
}
Messaging.messaging().delegate = self
return true
}
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
}
}
// MARK: - MessagingDelegate
extension AppDelegate: MessagingDelegate {
func messaging(_ messaging: Messaging,
didReceiveRegistrationToken fcmToken: String?) {
guard let token = fcmToken else { return }
// Register token with backend
Task {
await APIClient.shared.registerPushToken(
fcmToken: token,
platform: "ios",
deviceId: UIDevice.current.identifierForVendor?.uuidString
)
}
}
}
// MARK: - UNUserNotificationCenterDelegate
extension AppDelegate: UNUserNotificationCenterDelegate {
// Handle foreground notifications
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
// App is active - notification came via Socket.io already
// Optionally show banner anyway
completionHandler([.banner, .sound])
}
// Handle notification tap
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
let userInfo = response.notification.request.content.userInfo
if let notificationId = userInfo["notification_id"] as? String {
// Navigate to notification
NotificationCenter.default.post(
name: .openNotification,
object: notificationId
)
}
completionHandler()
}
}
// NotificationViewModel.swift
import Combine
class NotificationViewModel: ObservableObject {
@Published var notifications: [Notification] = []
@Published var unreadCount: Int = 0
@Published var isConnected: Bool = false
private var cancellables = Set<AnyCancellable>()
init() {
// Listen for new notifications
NotificationCenter.default.publisher(for: .newNotification)
.sink { [weak self] notification in
self?.addNotification(notification.object as! Notification)
}
.store(in: &cancellables)
}
func connect(userId: String, authToken: String) {
NotificationSocketManager.shared.connect(
userId: userId,
authToken: authToken
)
isConnected = true
}
func disconnect() {
NotificationSocketManager.shared.disconnect()
isConnected = false
}
func markAsRead(notificationId: String) {
Task {
// Call REST API
await APIClient.shared.markNotificationRead(id: notificationId)
// Update local state
if let index = notifications.firstIndex(where: { $0.id == notificationId }) {
notifications[index].readAt = Date()
unreadCount = max(0, unreadCount - 1)
}
}
}
private func addNotification(_ notification: Notification) {
notifications.insert(notification, at: 0)
if notification.readAt == nil {
unreadCount += 1
}
}
}
// Main App or SceneDelegate
@main
struct ModfinApp: App {
@StateObject private var notificationVM = NotificationViewModel()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(notificationVM)
.onChange(of: scenePhase) { phase in
switch phase {
case .active:
// App foregrounded - connect Socket.io
if let userId = AuthManager.shared.currentUserId,
let token = AuthManager.shared.authToken {
notificationVM.connect(userId: userId, authToken: token)
}
case .background, .inactive:
// App backgrounded - disconnect Socket.io
notificationVM.disconnect()
@unknown default:
break
}
}
}
}
}
SwiftUI views would include:
NotificationBellView - Icon with unread badgeNotificationListView - Scrollable list of notificationsNotificationRowView - Individual notification itemNotificationDetailView - Full notification detailsThe existing SMS notification system in accounts/notify_logic.py remains separate and is NOT replaced by this architecture.
| Aspect | New Notification System | Existing SMS System |
|---|---|---|
| Audience | App users (org members) | External customers (account contacts) |
| Channel | In-app + Push (FCM) | SMS (Twilio) + Email (planned) |
| Timing | Real-time, event-driven | Batch, scheduled (periodic webhooks) |
| Use Cases | App events, alerts, updates | Account reminders (unpaid balances, overdue invoices) |
| Delivery Model | Opportunistic (online → in-app, offline → push) | Scheduled cadence (weekly, etc.) |
| State Tracking | Per-notification read/unread | Per-account notification state (last sent, count) |
| Integration | Socket.io + FCM | Twilio SMS + shortlink generation |
1. Different Audiences:
2. Different Communication Patterns:
3. Different Delivery Requirements:
4. Different Business Logic:
5. Existing SMS System is Working:
While the systems remain separate, there could be future touchpoints:
Notify app users about customer notifications:
Unified notification preferences:
Shared infrastructure:
Keep systems separate for now:
The new notification system is for internal app communications. Customer-facing notifications (account reminders, collection messages) remain in the existing SMS system.
The existing LLM chat WebSocket implementation in llm/ remains separate and is NOT integrated with Socket.io.
| Aspect | Notification System | LLM WebSocket |
|---|---|---|
| Protocol | Socket.io (rooms, namespaces) | Raw FastAPI WebSocket |
| Connection Model | App-wide, persistent | Per-chat session |
| Use Case | Broadcast notifications to users | Streaming LLM responses, function calls |
| Message Pattern | Pub-sub (one-to-many) | Request-response (one-to-one) |
| Payload | Discrete notifications | Streaming conversation chunks |
| Lifecycle | Connect on app start, persist | Connect per chat, disconnect when done |
| Processing | Instant delivery | Queue-based with workers |
1. Different Protocols:
2. Different Connection Patterns:
3. Different Message Semantics:
4. Different Infrastructure Needs:
5. No Benefit to Integration:
Both systems run on the same FastAPI server with separate endpoints:
# LLM WebSocket (existing)
/org/id/{org_id}/chat/id/{chat_id}/session
# Socket.io Notifications (new)
/socket.io
Clients connect to both independently, each serving its specific purpose without coupling failures or complexity.