# Notifications Design — Push + In-App (Mobile + Backend)

*Research date: 2026-05-01 · Author: notif-research agent*

---

## 1. Linear Ticket Summary

### Core Tickets (all Done except NAN-58)

| ID | Title | Status | Assignee | Key Scope |
|---|---|---|---|---|
| **NAN-195** | [Backend] Push notification service (APNS + FCM) | ✅ Done | Ibra | Device token reg, NotificationService abstraction, APNS/FCM providers, retry + dead-letter, GCP Secret Manager credentials |
| **NAN-85** | [Backend] Notification preferences + push infra | ✅ Done | Ibra | 22-toggle preference CRUD, notification history (CRUD, pagination, read/unread), unread count endpoint |
| **NAN-88** | [Mobile] Notification settings + notification center | ✅ Done | Fathoni | NotificationSettingsScreen (22 toggles), NotificationCenterScreen (paginated list), push setup, unread dot, device token registration |
| **NAN-57** | [Backend] Push notifications (Expo Push + triggers) | ✅ Done | Ibra | Earlier spec — largely superseded by NAN-195 + NAN-85 |
| **NAN-310** | [Backend] Price, earnings, and volume alerts | ✅ Done | Ibra | 3 alert types, background checkers (60s price/volume, daily earnings), atomic dedup, preference gate before push |
| **NAN-58** | [Mobile] Price alerts UI + push notification integration | 🔴 Todo | Fathoni | SetAlertSheet, AlertsList, push integration — **BUT the alerts feature is already largely built** |

### Related Tickets

| ID | Title | Status | Notes |
|---|---|---|---|
| NAN-124 | [Backend] User settings endpoint | ✅ Done | Personalization + notification prefs |
| NAN-87 | [Mobile] Personalization screen | ✅ Done | Knowledge level + notification prefs toggle |
| NAN-168 | [Mobile] Fix jest config / mocks | ✅ Done | Added `expo-notifications` manual mock |

### Dependency Chain

```
NAN-195 (push infra) ─┬─→ NAN-85 (prefs + history) ─→ NAN-88 (mobile UI)
                       └─→ NAN-310 (price/vol/earnings alerts)
NAN-88 + NAN-57 ─→ NAN-58 (price alerts mobile UI)
```

---

## 2. Current State — What's Already Built

### Mobile Codebase (`src/features/notification/`)

**Fully implemented:**
- Complete feature module with layered architecture (business/data/presentation/service)
- `PushTokenService` — requests permission, gets native device push token (NOT Expo push token — uses `getDevicePushTokenAsync`), registers with backend via API
- `NanoStreetNotificationAdapter` — full API client with all endpoints wired:
  - `GET/PUT /api/notifications/preferences`
  - `GET /api/notifications` (paginated)
  - `PUT /api/notifications/{id}/read`, `PUT /api/notifications/read-all`
  - `GET /api/notifications/unread-count`
  - `POST/DELETE /api/notifications/device-token`
- `NotificationSettingsScreen` — 22 toggles across 5 sections with master toggle sync
- `NotificationCenterScreen` — paginated notification list with mark-read, mark-all-read
- `useUnreadNotificationCountData` — exported via barrel for cross-feature unread dot
- DI wiring in `container.ts` — `notificationApi`, `pushTokenProvider`, `notificationRepository`
- App routes: `/notifications` (center), `/notification-settings`
- Onboarding flow includes notifications permission screen as final step

### Mobile Codebase (`src/features/alerts/`)

**Fully implemented:**
- Complete alerts feature with use-cases, repository, API adapter
- `PriceAlertSheet`, `EarningsAlertSheet`, `VolumeAlertSheet` — bottom sheets for creating alerts
- `ActiveAlertsList` — displays user's active alerts
- `StockAlertFlow` + `SetAlertTypePicker` — picker for alert type selection
- `StockHeaderActionMenu` — action menu on stock detail header (includes alert trigger)
- `PushPermissionBanner` — banner when push is denied, deep-links to OS settings
- `usePushPermissionStatus` hook
- Mutation hooks for create/delete alerts
- Full test coverage

### Mobile — NOT yet wired:
- **No `setNotificationHandler`** — foreground notification display behavior undefined
- **No `addNotificationResponseReceivedListener`** — tapping a push notification does NOT deep-link to a screen
- **No background notification handling** — `expo-background-fetch` and `expo-task-manager` are in deps but not wired for notifications
- **No Supabase realtime** — no live in-app notification feed (only poll-on-mount via React Query)
- **Token registration timing** — `PushTokenService.register()` exists but unclear when it's called (not visible in AppProviders or startup)

### Backend

**Fully implemented:**
- `NotificationService` singleton — APNS (HTTP/2 + JWT) and FCM (HTTP v1 API) providers
- Device token CRUD with upsert on `device_id`, auto-cleanup of invalid tokens
- Notification preferences — 22-key JSONB with atomic partial merge via `merge_notification_preferences` RPC
- Notification history table with paginated list, read/unread, mark-all-read
- `notification_failures` dead-letter table
- Alert checker service:
  - `run_price_volume_checker` — 60s loop during market hours, uses snapshot cache
  - `run_earnings_checker` — daily at 13:00 UTC, fires 1 day before earnings
  - Atomic `UPDATE ... RETURNING` for dedup across instances
- Alert CRUD endpoints (`POST /api/alerts/{price,earnings,volume}`, `GET`, `DELETE`, `PATCH`)
- `_should_send_push()` preference gate before sending
- Credentials loaded from GCP Secret Manager at startup

### Backend — NOT yet wired:
- **No notification triggers beyond alerts** — the `create_notification` function exists but is only called for alert-type pushes. No triggers for: watchlist movers, smart summaries, social interactions, portfolio summaries, learning reminders, product updates
- **No scheduled/cron notifications** — only real-time alert checkers exist
- **No rate limiting** — NAN-57 spec mentions "max 20/day per user" but this is not implemented
- **No batch sending** — notifications are sent one-at-a-time per device; no batching for bulk sends

### App Configuration

- `expo-notifications` plugin registered in `app.json`
- `expo-device`, `expo-constants` installed
- Android notification channel configured: `default` with MAX importance
- No Firebase `google-services.json` or APNS entitlements visible in repo (likely managed via EAS credentials)

---

## 3. Architecture Decisions

### Axis 1: Push Provider

**Decision: Native APNS + FCM (already implemented)**

The backend already sends directly to APNS and FCM — NOT through Expo Push Service. This was a deliberate choice (NAN-195). Trade-offs:

| | Native APNS/FCM | Expo Push Service |
|---|---|---|
| Control | Full control over payload, headers, priority | Abstracted, simpler API |
| Cost | Free (direct to Apple/Google) | Free tier has limits, then paid |
| Reliability | Must handle retries ourselves (done) | Expo handles retries |
| Token format | Native device token (platform-specific) | Expo push token (unified) |
| Lock-in | None | Tied to Expo Push Service |

**Current implementation uses native tokens** (`getDevicePushTokenAsync` not `getExpoPushTokenAsync`). This is consistent — mobile sends native tokens, backend sends via native providers.

**Recommendation: Keep as-is.** The implementation is production-hardened with retry logic, invalid token cleanup, and dead-letter logging. Migrating to Expo Push Service would add a dependency with no clear benefit.

### Axis 2: Token Registration Flow

**Decision: Already implemented end-to-end**

```
Mobile → PushTokenService.register() → POST /api/notifications/device-token → Supabase device_tokens table
```

- `device_tokens` table: `(id, user_id, token, platform, device_id, created_at, updated_at)`
- Upsert on `device_id` — device reassigns to current user on login
- Token auto-cleanup on APNS 410 / FCM UNREGISTERED errors

**Gap: When is `register()` called?** The onboarding screen has Allow/Skip buttons but the connection between permission grant → token registration → backend call is not visible in the startup flow. This needs to be verified and potentially wired into `AppProviders` on auth state change.

**Recommendation: Wire token registration into app startup** — call `pushTokenProvider.register()` after successful auth, not just during onboarding. Tokens rotate; the app should re-register on every launch.

### Axis 3: In-App Notification Storage

**Decision: Database-backed persistent feed (already implemented)**

- `notifications` table in Supabase: `(id, user_id, type, title, body, icon, read, created_at)`
- Paginated list endpoint with unread count
- Mark single/all as read

**Gap: No real-time delivery to open app.** Currently, notifications only appear when the user navigates to the Notification Center (React Query fetch on mount). There's no mechanism to push new notifications to a user who has the app open.

**Recommendation: Add Supabase Realtime channel for live in-app updates.**

Option A (recommended): Subscribe to `postgres_changes` on the `notifications` table filtered by `user_id`. When a row is inserted, the mobile client receives it instantly and can:
- Update the unread badge count
- Show an in-app toast/banner
- Prepend to the notification list if the user is on that screen

Option B: Use the existing WebSocket connection (used for live ticker prices) to add a `notification` message type. More efficient but requires backend changes to the WS handler.

**Recommendation: Option A first** — it's purely additive, uses existing Supabase infra, and doesn't require backend code changes. Option B is a v2 optimization.

### Axis 4: Notification Types and Routing

**Current notification type enum:** `portfolio_summary | smart_summary | activity | price_alert`

**22 preference toggles exist but only alert-type pushes are wired.** The remaining notification types need backend trigger logic:

| Notification Type | Trigger Source | Backend Work Needed |
|---|---|---|
| Price alerts (±3%, ±10%) | Alert checker (done) | ✅ Already wired |
| Earnings alerts | Alert checker (done) | ✅ Already wired |
| Volume alerts | Alert checker (done) | ✅ Already wired |
| Custom price alerts | Alert checker (done) | ✅ Already wired |
| SmartScore changes | Needs new checker | New checker + score change detection |
| Major news | Needs news ingestion trigger | Hook into news pipeline |
| Smart summaries | Scheduled (daily) | New cron job |
| Portfolio performance | Scheduled (daily/weekly) | New cron job |
| Portfolio insights | AI-generated | New service |
| Learning reminders | Scheduled | New cron job |
| Streak alerts | XP service hooks | Hook into streak tracking |
| Competition invitations | Event-driven | Hook into competition creation |
| Leaderboard ranking | Event-driven | Hook into leaderboard updates |
| Product updates | Admin-triggered | Admin endpoint + broadcast |

**Recommendation: Generic event bus pattern.**

```python
# Backend: notification_dispatcher.py
async def dispatch(event_type: str, user_id: str, payload: dict):
    # 1. Check user preferences for this event type
    if not await _is_preference_enabled(user_id, event_type):
        return
    # 2. Store in notifications table
    notification = await create_notification(user_id, ...)
    # 3. Send push
    await notification_service.send_to_user(user_id, ...)
```

This replaces the current pattern where each checker directly calls `notification_service.send_to_user`. The dispatcher becomes the single choke point for preference checking, rate limiting, and history storage.

### Axis 5: iOS/Android Specifics

**Current state:**
- APNS: P8 key-based auth (JWT tokens, not certificate). Key stored in GCP Secret Manager. ✅
- FCM: Service account JSON in GCP Secret Manager. ✅
- Android: Notification channel `default` with MAX importance. ✅
- iOS: No provisional auth requested (uses standard permission request). Could be improved.
- EAS Build: `expo-notifications` plugin handles entitlements automatically.

**Recommendation:**
- Consider iOS provisional authorization for "quiet" notifications that appear in Notification Center without prompting. This would allow the app to show notifications to users who haven't explicitly opted in.
- Android 13+ `POST_NOTIFICATIONS` runtime permission: `expo-notifications` handles this automatically via `requestPermissionsAsync`.

### Axis 6: Push Notification Response Handling (Deep Linking)

**Gap: Not implemented at all.**

When a user taps a push notification, nothing happens — no navigation, no deep link. This is a critical missing piece.

**Recommendation:**

```typescript
// In AppProviders or a dedicated NotificationRouter:
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

// Handle notification tap
const lastResponse = Notifications.useLastNotificationResponse();
useEffect(() => {
  if (lastResponse) {
    const data = lastResponse.notification.request.content.data;
    // Route based on notification type
    if (data.type === 'price_alert') router.push(`/stock/${data.ticker}`);
    if (data.type === 'portfolio_summary') router.push('/portfolio');
    // etc.
  }
}, [lastResponse]);
```

The backend already supports `data` in the push payload — just need to populate it with routing info and handle it on the mobile side.

### Axis 7: Quiet Hours / Preferences

**Preferences: Already implemented** (22 toggles, server-side gate via `_should_send_push()`).

**Quiet hours: Not implemented.**

**Recommendation: Defer quiet hours to Phase D.** The preference toggles already give users granular control. Quiet hours add complexity (timezone handling, per-user schedule) with relatively low ROI for v1.

---

## 4. Phased Delivery Plan

### Phase A: Wire the Gaps (Mobile) — ~2 days

**Goal:** Make the existing notification infrastructure actually work end-to-end.

1. **Wire token registration on app startup** — call `pushTokenProvider.register()` after auth succeeds (in `AppProviders` or startup hook). Re-register on every cold start to handle token rotation.
2. **Add `setNotificationHandler`** — configure foreground notification display (show alert + sound).
3. **Add notification tap handler** — `useLastNotificationResponse` → route to relevant screen based on `data.type` and `data.ticker`/`data.screen`.
4. **Verify backend push payload includes routing data** — ensure `NotificationPayload.data` has `type`, `ticker`, `screen` fields.
5. **Test E2E:** onboarding permission → token registered → backend test push → notification appears → tap routes correctly.

**Shippable independently:** Yes. Users can receive and interact with push notifications from existing alert checkers.

### Phase B: In-App Real-Time Feed — ~3 days

**Goal:** Notifications appear instantly in the app without requiring screen navigation.

1. **Add Supabase Realtime subscription** for `notifications` table filtered by `user_id` (mobile).
2. **Build in-app toast/banner component** — shows briefly when a new notification arrives while app is foregrounded.
3. **Update unread badge reactively** — when realtime insert arrives, increment badge count without refetching.
4. **Optimistic prepend** to notification list if user is on NotificationCenterScreen.

**Shippable independently:** Yes. Enhances UX for users already receiving notifications.

### Phase C: Notification Triggers — ~5 days (backend-heavy)

**Goal:** Wire all 22 notification types that have toggles but no backend triggers.

Priority order (by user impact):
1. **Daily portfolio summary** — cron at market close, summarize top movers in user's portfolio
2. **Watchlist movers ±3%/±10%** — piggyback on existing 60s snapshot cache, similar pattern to price alerts but auto-configured from watchlist
3. **SmartScore changes** — detect changes in daily score calculation, notify if delta > threshold
4. **Learning streak alerts** — cron check for streak about to expire
5. **Major news** — hook into news ingestion pipeline
6. **Product updates** — admin broadcast endpoint

Each trigger follows the dispatcher pattern (check prefs → store in `notifications` → push).

**Shippable independently:** Yes, each trigger can ship as its own PR.

### Phase D: Polish — ~2 days

**Goal:** Quality-of-life improvements.

1. **Rate limiting** — max N notifications/day per user (server-side)
2. **Quiet hours** — per-user time range where pushes are suppressed (stored as UTC offset + hours)
3. **Notification grouping** — collapse multiple price alerts for same ticker into one push
4. **Badge count sync** — set iOS/Android app badge number from unread count
5. **Re-engagement push** — "You haven't checked your portfolio in 3 days" (if product wants this)

---

## 5. Open Questions for Product/Design

1. **Token registration timing:** The onboarding flow shows Allow/Skip but there's no confirmation of what happens when the user taps "Maybe later." Should we re-prompt later? If so, when? (e.g., first time they create a price alert without push permission)

2. **Notification tap routing:** The Figma designs show notification cards with different icons/types but no spec for where each type should navigate to on tap. Need a routing table from product:
   - Portfolio Summary → Portfolio tab?
   - Smart Summary → where?
   - Price Alert → Stock Detail page for that ticker?
   - Activity (social) → which screen?

3. **In-app notification toast:** Should there be a visible toast/banner when a notification arrives while the user is using the app? The Figma has no design for this. If yes, does it look like the notification cards from the Notification Center?

4. **Notification Center entry point:** Currently accessible from Settings → Notifications. Should there also be a bell icon on the home screen header with unread badge? The Figma shows "Notification dot indicator" on settings but not on home screen.

5. **Watchlist auto-alerts:** NAN-85 has toggles for `watchlist_price_alert_3` and `watchlist_price_alert_10` which imply automatic alerts for all watchlisted stocks. This is different from NAN-310's user-configured per-stock alerts. Confirm: should watchlist alerts automatically fire for ALL stocks in the user's watchlist at ±3%/±10% thresholds without explicit alert creation?

6. **Smart Summaries content:** Toggle exists but no spec for what an AI-powered market insight notification looks like. Is this tied to the existing AI summary fields on `/market/indices` etc. (NAN-292), or something new?

7. **Portfolio achievements / insights / performance:** Three separate toggles with no backend trigger spec. What data drives each? Are these daily summaries or event-driven?

8. **Rate limiting thresholds:** NAN-57 mentioned "max 20/day per user" — is this still the target? Should it be per-type or global? What happens when limit is hit — suppress silently or batch into digest?

9. **Notification retention policy:** How long should notifications stay in the history table? Indefinitely? 30 days? Should there be a "clear all" in the UI?

10. **Expo Push vs Native:** The backend uses native APNS/FCM which requires managing credentials. Expo Push Service would simplify this significantly. Was native chosen deliberately? If credentials management becomes a burden, should we consider migrating?
