# Phase C — Backend Notification Trigger Types

*Estimated: 5 days · Depends on: Phase A merged (Phase B can run in parallel)*

---

## Goal

Wire backend trigger logic for all notification types that have UI toggles but no sender. Currently only price/earnings/volume alerts fire pushes. Phase C adds triggers for portfolio summaries, learning reminders, competition updates, and product updates — all routed through a generic `NotificationDispatcher` abstraction.

---

## Scope

- New `NotificationDispatcher` service — single choke point for preference check → store → push
- Per-type notification builder functions
- New cron jobs / event hooks for each trigger type
- Does NOT touch mobile UI (notification types are already supported by existing card component + routing table)

---

## Architecture: NotificationDispatcher

```python
# services/main/app/services/notification_dispatcher.py

class NotificationDispatcher:
    """Central dispatch point for all notification types.

    Every notification passes through here:
    1. Check user preference for this notification type
    2. Store in notifications table (in-app feed)
    3. Send push via NotificationService (APNS/FCM)
    """

    async def dispatch(
        self,
        user_id: str,
        notification_type: str,
        preference_key: str,
        title: str,
        body: str,
        icon: str,
        data: dict | None = None,
    ) -> bool:
        # 1. Preference gate
        prefs = await notification_repository.get_preferences(user_id)
        if not prefs.get("enable_all", True):
            return False
        if not prefs.get(preference_key, True):
            return False

        # 2. Store in notifications table
        await notification_repository.create_notification(
            user_id=user_id,
            type=notification_type,
            title=title,
            body=body,
            icon=icon,
        )

        # 3. Push
        payload = NotificationPayload(
            title=title,
            body=body,
            data={"type": notification_type, **(data or {})},
        )
        await notification_service.send_to_user(user_id, payload)
        return True

    async def dispatch_bulk(
        self,
        user_ids: list[str],
        notification_type: str,
        preference_key: str,
        title: str,
        body: str,
        icon: str,
        data: dict | None = None,
    ) -> int:
        """Send to multiple users. Returns count of successful dispatches."""
        results = await asyncio.gather(
            *(self.dispatch(uid, notification_type, preference_key, title, body, icon, data)
              for uid in user_ids),
            return_exceptions=True,
        )
        return sum(1 for r in results if r is True)
```

### Refactor: Alert checker → Dispatcher

The existing `alert_checker_service.py` calls `notification_service.send_to_user()` directly and does its own preference check via `alert_service._should_send_push()`. Refactor to route through `NotificationDispatcher` for consistency:

```python
# Before (alert_checker_service.py):
if await alert_service._should_send_push(row["user_id"]):
    await notification_service.send_to_user(row["user_id"], payload)

# After:
await dispatcher.dispatch(
    user_id=row["user_id"],
    notification_type="price_alert",
    preference_key="custom_price_alerts",
    title=payload.title,
    body=payload.body,
    icon="trend-up-01",
    data={"ticker": row["ticker"], "alert_id": row["id"]},
)
```

This also fixes the current gap where alert pushes are NOT stored in the `notifications` table (they only send a push but leave no in-app history).

---

## Notification Type Builders

Each type has a builder function that computes `title`, `body`, `icon`, and `data` from the trigger context.

### Type 1: `price_alert` (existing — refactor only)

| Field | Value |
|---|---|
| preference_key | `custom_price_alerts` |
| icon | `trend-up-01` |
| data | `{ type: "price_alert", ticker }` |
| title | `"{ticker} Price Alert"` |
| body | `"{ticker} crossed your ${target} target (now ${current})"` |

### Type 2: `earnings_alert` (existing — refactor only)

| Field | Value |
|---|---|
| preference_key | `watchlist_earnings_announcements` |
| icon | `calendar-03` |
| data | `{ type: "earnings_alert", ticker }` |
| title | `"{ticker} Earnings Tomorrow"` |
| body | `"{company} reports earnings tomorrow after market close"` |

### Type 3: `volume_alert` (existing — refactor only)

| Field | Value |
|---|---|
| preference_key | `custom_price_alerts` |
| icon | `activity` |
| data | `{ type: "volume_alert", ticker }` |
| title | `"{ticker} Volume Spike"` |
| body | `"Today's volume ({volume}) exceeded your {threshold} threshold"` |

### Type 4: `portfolio_summary` (NEW)

| Field | Value |
|---|---|
| preference_key | `sim_portfolio_performance` |
| icon | `file-05` |
| data | `{ type: "portfolio_summary" }` |
| Trigger | Daily cron at market close (20:05 UTC / 4:05 PM ET) |
| title | `"Portfolio Summary"` |
| body | `"Your top movers today: {ticker1} {pct1}%, {ticker2} {pct2}%, {ticker3} {pct3}%"` |

**Implementation:** New cron job `run_portfolio_summary_sender()`. For each user with an active SimTrade portfolio:
1. Fetch portfolio positions from `portfolio_holdings` table
2. Compute daily P&L from snapshot cache
3. Pick top 3 movers
4. Dispatch via `NotificationDispatcher`

### Type 5: `competition_update` (NEW)

| Field | Value |
|---|---|
| preference_key | `sim_competition_invitations` |
| icon | `trophy-01` |
| data | `{ type: "competition_update", competition_id }` |
| Trigger | Event-driven: when a competition is created/completed or user's ranking changes |
| title | varies: `"New Competition"` / `"Competition Results"` / `"Leaderboard Update"` |
| body | varies by sub-event |

**Implementation:** Hook into competition service — after competition creation, completion, or leaderboard recalculation, call `dispatcher.dispatch()` for affected users.

### Type 6: `learning_reminder` (NEW)

| Field | Value |
|---|---|
| preference_key | `learning_general_reminders` |
| icon | `book-open-01` |
| data | `{ type: "learning_reminder" }` |
| Trigger | Daily cron at 18:00 UTC for users who haven't completed daily quest |
| title | `"Don't forget your daily quest!"` |
| body | `"Complete today's quest to keep your {streak}-day streak alive"` |

**Implementation:** New cron job `run_learning_reminder_sender()`. Query users with active streaks who haven't completed today's quest. Dispatch reminders.

**Sub-types sharing the learning preference keys:**

| Sub-type | preference_key | Trigger |
|---|---|---|
| General reminder | `learning_general_reminders` | Daily 18:00 UTC if quest incomplete |
| Streak alert | `learning_streak_alerts` | Daily 21:00 UTC if streak about to expire (0 quests today) |
| Streak protection | `learning_streak_protection` | When streak would break but protection item is applied |
| Learning summary | `learning_summary` | Weekly summary of XP earned, lessons completed |

### Type 7: `product_update` (NEW)

| Field | Value |
|---|---|
| preference_key | `product_new_features` / `product_tips_insights` / `product_general_updates` |
| icon | `bulb-16` |
| data | `{ type: "product_update" }` |
| Trigger | Admin-initiated via broadcast endpoint (Phase A) |
| title | Set by admin |
| body | Set by admin |

**Implementation:** Refactor Phase A's broadcast endpoint to route through `NotificationDispatcher`. Admin selects which preference_key to gate on.

---

## Affected Components

### Backend — New Files

| File | Purpose |
|---|---|
| `services/main/app/services/notification_dispatcher.py` | Central dispatcher: prefs → store → push |
| `services/main/app/services/notification_builders.py` | Per-type title/body/icon builder functions |
| `services/main/app/services/portfolio_summary_sender.py` | Daily portfolio summary cron job |
| `services/main/app/services/learning_reminder_sender.py` | Learning streak / reminder cron jobs |

### Backend — Modified Files

| File | Change |
|---|---|
| `services/main/app/services/alert_checker_service.py` | Route through dispatcher instead of direct push |
| `services/main/app/services/alert_service.py` | Remove `_should_send_push` / `_build_payload_*` (moved to dispatcher + builders) |
| `services/main/app/routers/notifications.py` | Broadcast endpoint routes through dispatcher |
| `services/main/app/main.py` | Register new cron jobs in lifespan |

### Mobile — No Changes

All notification types render using the existing `NotificationCard` component. The `icon` field maps to existing icons. The `data.type` field routes via the Phase A tap handler (unknown types fall back to Notification Center).

---

## Concrete Tasks (commit-sized)

### Task 1: NotificationDispatcher service (~0.5 day)

- New file `notification_dispatcher.py`
- `dispatch()` and `dispatch_bulk()` methods
- Unit tests: mock preferences, mock notification_repository, mock notification_service
- Verify: preference gate blocks dispatch when key is `false`
- Verify: notification stored in `notifications` table
- Verify: push sent via `notification_service.send_to_user`

### Task 2: Refactor alert checker to use dispatcher (~0.5 day)

- Replace direct `notification_service.send_to_user()` calls in `alert_checker_service.py`
- Replace `alert_service._should_send_push()` with dispatcher's preference gate
- Move payload builders to `notification_builders.py`
- Now alerts appear in the in-app notification history (not just as pushes)
- Update existing unit tests

### Task 3: Refactor broadcast endpoint to use dispatcher (~0.5 day)

- Route admin broadcast through `dispatcher.dispatch_bulk()`
- Admin selects `preference_key` in request body
- Each recipient gets a `notifications` table row + push

### Task 4: Portfolio summary sender (~1 day)

- New cron job: `run_portfolio_summary_sender()`
- Runs daily at 20:05 UTC (after market close)
- For each user with SimTrade positions:
  - Fetch positions + snapshot prices
  - Compute daily P&L per position
  - Pick top 3 movers by absolute % change
  - Build notification body
  - Dispatch
- Unit tests: mock positions, verify body format
- Idempotency: track `last_sent_date` to prevent double-send on restart

### Task 5: Learning reminder sender (~1 day)

- New cron job: `run_learning_reminder_sender()`
- Sub-jobs at different times:
  - 18:00 UTC — general reminder for incomplete daily quest
  - 21:00 UTC — streak alert for at-risk streaks
  - Weekly — learning summary
- Query: users with `streak_days > 0` AND no quest completion today
- Dispatch via appropriate preference_key
- Unit tests: mock quest/streak data

### Task 6: Competition update hooks (~0.5 day)

- Hook into competition service (if exists) — after:
  - Competition created → notify eligible users
  - Competition completed → notify participants with results
  - Leaderboard recalculated → notify users whose rank changed significantly
- If competition service doesn't exist yet: stub the hook, add TODO for when it's built

### Task 7: Watchlist auto-alerts (~1 day)

- The ±3% and ±10% watchlist toggles imply automatic monitoring of ALL watchlisted stocks
- This is different from user-created alerts (NAN-310) — these are system-generated
- Implementation: extend `tick_price_volume()` to also check watchlist prices:
  1. Fetch all users' watchlists (or do this per-user in a batch)
  2. For each watchlisted stock, check if daily change exceeds ±3% or ±10%
  3. Dispatch with `watchlist_price_alert_3` or `watchlist_price_alert_10` preference key
  4. Dedup: track last-sent per user+ticker+threshold+date to avoid repeat alerts same day
- New table or column: `watchlist_alert_log (user_id, ticker, threshold, sent_date)` for dedup

---

## Test Plan

1. **Dispatcher unit tests:** preference gate, store, push — all three stages independently mockable
2. **Builder tests:** each type produces correct title/body/icon for given inputs
3. **Cron job tests:** mock data sources, verify correct users are notified
4. **Idempotency:** run the same cron job twice in the same period → no duplicate notifications
5. **Integration:** trigger a portfolio summary for a test user → verify notification appears in `notifications` table + push received on device
6. **Watchlist dedup:** alert fires once per day per ticker per threshold, not on every 60s tick

---

## Open Questions / Dependencies

1. **Watchlist auto-alerts performance:** If each user has 20 watchlisted stocks and there are 10,000 users, that's 200,000 price checks per 60s tick. May need to batch by ticker (check price once, fan out to all users watching it) rather than by user. Design for this.

2. **Competition service status:** Is there a competition/leaderboard service in the backend? If not, Task 6 is a stub with TODOs. Check `services/main/app/services/` and `routers/` for competition-related code.

3. **SmartScore change notifications:** The `watchlist_smartscore_changes` toggle exists but SmartScore is computed daily. Need to detect day-over-day changes > some threshold. Deferred to Phase C.5 unless product clarifies the threshold.

4. **Major news alerts:** `watchlist_major_news` toggle exists. Requires a news ingestion pipeline hook. If news is fetched via external API (FMP?), the trigger point is wherever new articles are ingested. If not yet built, defer.

5. **Portfolio insights / achievements:** `sim_portfolio_insights` and `sim_portfolio_achievements` toggles exist. These likely require AI-generated content or badge triggers. The badge system (NAN-311) already has server-side checks — hook `dispatch()` into the badge award path for achievements. Insights may need AI summarization — scope TBD.

6. **Rate limiting:** Phase C does NOT add rate limiting (that's Phase D). But the portfolio summary + learning reminder crons are inherently once-per-day, so they're self-limiting. Watchlist auto-alerts need the dedup log to prevent spam.
