# Phase A — Wire the Gaps (Status Report)

*Status: In-flight · PRs: mobile #261 (draft), backend #362 (going ready)*

---

## Goal

Make the existing notification infrastructure work end-to-end: a push notification sent from the backend arrives on-device, displays correctly in foreground/background, and tapping it navigates to the right screen. Also wire device token registration into the app lifecycle so tokens stay fresh.

## Scope

Phase A does NOT add new notification types or triggers. It completes the plumbing so the existing alert checkers (price, earnings, volume from NAN-310) deliver a working push experience.

---

## What Landed

### 1. Device Token Registration on App Startup

**Contract:** `POST /api/notifications/device-token`

```json
{
  "token": "<native_device_push_token>",
  "platform": "ios" | "android",
  "device_id": "<stable_device_identifier>"
}
```

- `token` — native APNS/FCM token from `Notifications.getDevicePushTokenAsync()` (NOT Expo push token)
- `platform` — `ios` or `android`, derived from `Platform.OS`
- `device_id` — stable per-device: Android uses `Application.getAndroidId()`, iOS uses `Application.getIosIdForVendorAsync()` with `Crypto.randomUUID()` fallback, persisted in SecureStore under key `nanostreet_device_id`

**Mobile changes:**
- `PushTokenService.register()` wired into app startup — called after successful auth, not only during onboarding
- Re-registers on every cold start to handle token rotation
- Upsert on `device_id` — backend reassigns device to current user on login switch

**Backend (existing from NAN-195):**
- `device_tokens` table: `(id UUID, user_id UUID, token TEXT, platform VARCHAR(10), device_id TEXT, created_at, updated_at)` with `UNIQUE(user_id, device_id)`
- Auto-cleanup: APNS 410/ExpiredToken and FCM NOT_FOUND/UNREGISTERED errors trigger `DELETE FROM device_tokens WHERE token = $1`

### 2. Foreground Notification Handler

```typescript
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});
```

Registered in app providers so push notifications display as banners even when the app is in the foreground. Without this, `expo-notifications` silently swallows foreground pushes.

### 3. Notification Tap Routing Table

`useLastNotificationResponse()` hook wired to navigate based on `data.type` in the push payload:

| `data.type` | Navigation Target | `data` Fields |
|---|---|---|
| `price_alert` | Stock Detail (`/stock/[ticker]`) | `ticker` |
| `earnings_alert` | Stock Detail (`/stock/[ticker]`) | `ticker` |
| `volume_alert` | Stock Detail (`/stock/[ticker]`) | `ticker` |
| `portfolio_summary` | Portfolio tab | — |
| `smart_summary` | Notification Center | — |
| `activity` | Notification Center | — |
| (fallback) | Notification Center | — |

### 4. Badge Sync

- iOS app badge number set from `unread_count` on notification fetch
- `Notifications.setBadgeCountAsync(count)` called when:
  - App launches (initial unread count fetch)
  - User marks notification(s) as read
  - New notification arrives (foreground handler increments)

### 5. Backend: Schema Migration `20260504000100`

New migration adding any Phase A schema changes (broadcast support, payload data columns, etc.) to the existing notification tables. Applied on top of:
- `20260323000000_push_notifications.sql` — `device_tokens` + `notification_failures`
- `20260326000100_notification_preferences.sql` — `notification_preferences` with 22-key JSONB
- `20260326000200_notifications_history.sql` — `notifications` table
- `20260326000300_merge_notification_preferences_rpc.sql` — atomic JSONB merge RPC
- `20260416000000_price_alerts.sql` — unified alerts table

### 6. Backend: Broadcast Endpoint

Admin-only endpoint for sending push notifications to specific users or all users (for product updates, announcements):

```
POST /api/admin/notifications/broadcast
Authorization: Bearer <admin_token>
```

```json
{
  "user_ids": ["uuid1", "uuid2"],   // omit for all users
  "title": "New Feature!",
  "body": "Check out price alerts on stock detail.",
  "type": "product_update",
  "icon": "bulb-16",
  "data": { "type": "product_update" }
}
```

Stores a row in `notifications` table per recipient AND sends push via existing `NotificationService.send_to_user()`.

### 7. Alert Payload Contract

Alert checker now includes routing `data` in the `NotificationPayload` sent to APNS/FCM:

```python
NotificationPayload(
    title="AAPL Price Alert",
    body="AAPL crossed your $180.00 target (now $182.35)",
    data={
        "type": "price_alert",
        "ticker": "AAPL",
        "alert_id": "uuid",
    }
)
```

This `data` dict is:
- Included in APNS payload as top-level custom keys (outside `aps`)
- Included in FCM payload under `message.data`
- Read by mobile `useLastNotificationResponse` for tap routing

---

## Affected Components

### Mobile
| File / Area | Change |
|---|---|
| `src/core/providers/AppProviders.tsx` (or startup hook) | Wire `PushTokenService.register()` on auth |
| `src/core/providers/NotificationHandler.tsx` (new) | `setNotificationHandler` + tap routing |
| `src/features/notification/service/push/PushTokenService.ts` | Already existed — called from new location |
| `app.json` | `expo-notifications` plugin (already present) |

### Backend
| File / Area | Change |
|---|---|
| `services/main/app/routers/notifications.py` | Broadcast endpoint added |
| `services/main/app/services/alert_service.py` | Payload now includes `data` dict |
| `services/main/app/services/alert_checker_service.py` | Passes enriched payload |
| `supabase/migrations/20260504000100_*.sql` | Phase A schema changes |

---

## Test Plan

1. **Token registration:** Sign in → verify `device_tokens` row created in Supabase → sign out → sign in with different account → verify row `user_id` updated
2. **Foreground push:** Send test push via broadcast endpoint while app is foregrounded → banner appears
3. **Background push:** Send push while app is backgrounded → OS notification center shows it
4. **Tap routing:** Tap price_alert notification → app opens to Stock Detail for that ticker
5. **Badge count:** Verify iOS badge number matches unread count, decrements on mark-read
6. **Token rotation:** Force-rotate token (reinstall) → verify old token cleaned up on next push attempt

---

## Open Questions / Notes

- The broadcast endpoint is admin-only — no public user access. Rate limiting deferred to Phase D.
- iOS provisional authorization (quiet delivery without prompt) was considered but deferred — standard permission request via onboarding flow is sufficient for v1.
- The `data.type` routing table above may need expansion as Phase C adds more notification types — the fallback-to-Notification-Center ensures forward compatibility.
