# Notifications and categories

> The inbox every app shows, push to phones and browsers, the daily digests, and the category each game is sorted into.

An inbox every app shows (`notifications` rows), and a push to the player's devices that says something landed in it. `src/lib/notifications.ts` (inbox, devices, preferences), `src/lib/push.ts` (the senders), `src/lib/digest.ts` (the daily ones).

| Kind | When | Tapping it opens | `data` |
|---|---|---|---|
| `announcement` | A game's author sends one in its [channel](https://docs.moonspell.app/moonspell/channels.md): every other member, at once | The game's channel | `{gameId, messageId}` |
| `changes` | Once a day at most: new versions landed, by someone else, on games the player made or helped build (a change they asked for, or a draft of their own) | "What changed" (`GET /api/app/changes?since=`) | `{since}` |
| `new_games` | Once a day at most, for players with a device: games whose first version landed | The games feed | `{since}` |

The daily two are sent by an hourly cron (`vercel.ts` → `GET /api/cron/notifications`, `CRON_SECRET`) to players who are due and for whom it is between 9:00 and 20:59 (their device's time zone, `users.timezone`; UTC when unknown). A player can turn each kind off; an off kind makes no row and no push.

## API

| Route | What |
|---|---|
| `GET /api/app/notifications` | `{notifications: [{id, kind, title, body, data, read, createdAt}], unread, prefs: {announcements, changes, newGames}}`, newest first |
| `POST /api/app/notifications/read` | `{ids: […]}`, or no body for all → `{unread}` |
| `PUT /api/app/notifications/prefs` | Any of `{announcements, changes, newGames}` (booleans) → `{prefs}` |
| `POST /api/app/devices` | Registers this device (the platform is `X-Crescent-Client`). iOS `{token: <APNs hex>, environment: "production" \| "sandbox", timezone}`; Android `{token: <FCM>, timezone}`; web `{token: <subscription endpoint>, keys: {p256dh, auth}, timezone}`. `timezone` is an IANA name. Call it on every launch once notifications are allowed. |
| `DELETE /api/app/devices` | `{token}`: notifications were turned off on this device |
| `GET /api/app/changes[?since=<ISO>]` | `{since, total, games: [{game: PublicGame, yours, changes: [{buildId, version, by, summary, features, createdAt}]}]}`: at most a week back, a day back without `since` |
| `GET /api/app/config` | now also `push: {vapidPublicKey}` (null when Web Push is off) |

## What a push carries

Flat strings: `kind`, `id` (the inbox row) and the row's `data` fields.

- **iOS** (APNs): at the top level of the payload, next to `aps` (`aps.alert.title/body`, `thread-id` = the kind).
- **Android** (FCM): in `data`; the title and body are in `notification`, on the Android channel named after the kind.
- **Web** (Web Push): the payload is `{title, body, data}` as JSON; the service worker (`public/sw.js`) shows it and opens the app on click.

## Keys

Each sender is on only when its keys are set (`pushConfigured()`); without any, the inbox still fills.

| Platform | Env | Where it comes from |
|---|---|---|
| iOS | `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_PRIVATE_KEY` (the `.p8`'s text), `APNS_TOPIC` (defaults to the app's bundle id) | An APNs auth key from the Apple developer account; the App ID needs the Push Notifications capability and the app the `aps-environment` entitlement |
| Android | `FCM_SERVICE_ACCOUNT` (the service account JSON) | The Firebase project of the Android app; the key is a service account key from its Service accounts page |
| Web | `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | `npx web-push generate-vapid-keys` |

## Categories

Every game lands in one category (`src/lib/categories.ts`: Arcade, Runner, Puzzle, Racing, Action, Shooter, Platformer, Adventure, Strategy, Builder, Board & cards, Sports). The categorizer (`src/lib/categorizer.ts`, Claude Haiku 5.5 with a fixed answer format) reads the game's title, first request, latest notes and design document and picks one when the game's first public version lands (`sortGame` in the run workflow); a game keeps its category after that. `npm run categories:backfill -- [--all] [--game <id>] [--dry-run]` sorts older games.

Every game the API hands out carries `category: {id, name} | null`, and `GET /api/app/feed` adds `categories: [{id, name, games}]`, the ones that have games, in the list's order. The apps filter the feed on the device.
