notes

Notification System Design Document

Overview

A hybrid notification system supporting both in-app (real-time) and push notifications across iOS and PWA clients.

Core Components:


Architecture

┌─────────────────┐
│   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               │
└──────────────────────┘

Design Rationale

Why This Architecture?

Managed Service vs Self-Hosted

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:

Infrastructure 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.

Socket.io Over Alternatives

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.

Redis/ElastiCache for Coordination

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.

FCM for Push Notifications

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.

Hybrid Delivery Model

Design Choice: In-app notifications for online users, push notifications for offline/backgrounded users

Rationale: This “opportunistic fallforward” pattern optimizes for three goals:

  1. User Experience: Instant in-app delivery when user is active (TCP-like reliability)
  2. Battery Efficiency: Avoid push notifications when user already has app open
  3. Guaranteed Delivery: Push notifications reach offline users (UDP-like best-effort)

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.”

PostgreSQL as Source of Truth

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.

Separation of Concerns: Severity vs Delivery

Design Choice: severity (alert/warning/info) separate from delivery (push/in-app)

Rationale: These are orthogonal concerns:

Examples:

Trade-offs Accepted

Infrastructure 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:


Database Schema

Notifications Table

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)
);

Push Device Tokens Table

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)
);

Notification Preferences Table

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)
);

REST API Endpoints

Notification Management

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

Push Device Registration

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

Notification Preferences

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

Socket.io Communication

Server → Client (Push Events)

// 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[] }

Client → Server (Bidirectional Operations)

// 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 }

Connection Lifecycle

// 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

Opportunistic Fallforward

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.

Delivery Logic

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

Benefits

On Reconnection

@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.


Infrastructure Requirements

Development

Production

Configuration

# 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)

Firebase Cloud Messaging Setup

1. Create Firebase Project

  1. Go to Firebase Console
  2. Click “Add project”
  3. Enter project name (e.g., “modfin-notifications”)
  4. Disable Google Analytics (optional)
  5. Create project

2. Generate Service Account Credentials

For Backend (Python):

  1. In Firebase Console, go to Project Settings (gear icon)
  2. Navigate to “Service accounts” tab
  3. Click “Generate new private key”
  4. Download JSON file (e.g., firebase-credentials.json)
  5. Store securely (never commit to git)

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)

3. iOS Configuration (APNs)

FCM uses Apple Push Notification service (APNs) for iOS. You need to upload your APNs certificate/key to Firebase.

Prerequisites:

Steps:

  1. Generate APNs Authentication Key (recommended) or Certificate
    • Go to Apple Developer Portal
    • Certificates, Identifiers & Profiles → Keys
    • Create new key with “Apple Push Notifications service” enabled
    • Download .p8 file (can only download once!)
    • Note the Key ID and Team ID
  2. Upload to Firebase:
    • Firebase Console → Project Settings → Cloud Messaging tab
    • Under “Apple app configuration”
    • Upload APNs Authentication Key:
      • Key ID
      • Team ID
      • .p8 file
    • Or upload APNs Certificate (.p12 file)
  3. Add iOS app to Firebase project:
    • Click “Add app” → iOS
    • Enter iOS bundle ID (e.g., com.modfin.app)
    • Download GoogleService-Info.plist
    • Add to iOS project

4. Web Push Configuration (PWA)

FCM handles Web Push via VAPID (Voluntary Application Server Identification).

Steps:

  1. Generate Web Push Certificates:
    • Firebase Console → Project Settings → Cloud Messaging tab
    • Under “Web configuration”
    • Click “Generate key pair”
    • Copy the public key (VAPID key)
  2. Get Web Credentials:
    • In same section, note:
      • Web Push certificate public key (VAPID public key)
      • Web API Key (from General tab)
      • Project ID
      • Messaging Sender ID
  3. Set Environment Variables (Frontend):
    # 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)
    
  4. Add Web App to Firebase:
    • Click “Add app” → Web
    • Enter app nickname
    • Copy configuration object

5. Backend Integration

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

6. Testing FCM

Test from Firebase Console:

  1. Go to Cloud Messaging section
  2. Click “Send test message”
  3. Enter FCM token from device
  4. Send notification

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())

7. Security Considerations

Credentials:

Token Storage:

API Access:

8. Environment-Specific Setup

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

Notification Classification

# 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
    }
}

Client Implementation Notes

Connection State Management

Deduplication

Read State Synchronization


Scalability Considerations

Multi-Instance Coordination

Database Queries

FCM Rate Limits


Security Considerations


Monitoring & Observability

Key Metrics

Logging


Client-Side Integration

Next.js / React (PWA)

Dependencies

{
  "dependencies": {
    "socket.io-client": "^4.x",
    "firebase": "^10.x"
  }
}

Socket.io Connection Manager

// 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();

State Management Integration

// 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);
    }
  };
}

Web Push (PWA) Setup

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);
});

Service Worker (public/sw.js)

// 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 Integration

// 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>
    </>
  );
}

Presentation Layer

Components would include:


iOS / Swift

Dependencies

// 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")
]

Socket.io Manager

// 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
    }
}

Push Notification Setup (AppDelegate)

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()
    }
}

State Management (ViewModel)

// 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
        }
    }
}

App Lifecycle Integration

// 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
                    }
                }
        }
    }
}

Presentation Layer

SwiftUI views would include:


Scope: What This System Does NOT Replace

Existing SMS Customer Notifications

The existing SMS notification system in accounts/notify_logic.py remains separate and is NOT replaced by this architecture.

Key Differences

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

Why They Remain Separate

1. Different Audiences:

2. Different Communication Patterns:

3. Different Delivery Requirements:

4. Different Business Logic:

5. Existing SMS System is Working:

Potential Future Integration Points

While the systems remain separate, there could be future touchpoints:

Notify app users about customer notifications:

Unified notification preferences:

Shared infrastructure:

Recommendation

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.


Existing LLM WebSocket System

The existing LLM chat WebSocket implementation in llm/ remains separate and is NOT integrated with Socket.io.

Key Differences

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

Why They Remain Separate

1. Different Protocols:

2. Different Connection Patterns:

3. Different Message Semantics:

4. Different Infrastructure Needs:

5. No Benefit to Integration:

Deployment

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.


Future Enhancements