Notifications
Email (SES)
Transactional email is sent via AWS SES for:
- Challenge issued / revoked / accepted
- Game start and game end (with expanded end-game details)
- Tournament start, end, and removal
Users can disable most email categories in settings.all.notifications; push notifications for challenges and tournaments follow the same preference flags where applicable (challenge/game push still sends regardless of email toggles for challenges).
Language follows the recipient's language field (locales/*/apback.json for email template strings). Game titles in `` interpolation use apgames:names.{uid} from gameslib locale bundles loaded in initi18n (localizedGameName() in lib/gameDisplayName.ts). If the user's language is not yet registered in i18next, notification copy falls back to English while the stored preference is kept unchanged.
Web push
VAPID keys (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY) enable browser push. Each device/browser subscription is stored as its own PUSH record (sk: <userid>#<subscriptionKey>). Auth queries:
save_push— register or refresh this device (upserts onePUSHrecord; setsmayPushon the user)delete_push— unregister this device (pars.endpoint; clearsmayPushwhen no subscriptions remain)set_push({ state: false })— unregister all devices for the userset_push({ state: true })— sets user preference only; still requiressave_pushto register a devicetest_push— send a test notification to all registered devices
When a push fails with HTTP 404 or 410 (stale endpoint), only that subscription record is deleted.
Your turn batching
The yourturn Lambda runs on a schedule (14:00 and 22:00 UTC, prod only) via EventBridge in the crons stack. It scans active games and sends batched "your turn" emails — not on every move.
Implementation: utils/yourturn.ts (deployed as crons/src/functions/yourturn).
Push topics
Push messages use topics such as challenges and game-related channels. See lib/push/sendUserPush.ts.
Realtime yourturn push is sent after each move when it becomes a player's turn. Solo games (numPlayers === 1) are skipped — the player is always on move.
In-app dashboard feed
Per-user notifications stored under NOTIFICATION#<userid> and returned on list_notifications (not on me_profile or me_dashboard). After createNotification() writes an item, the backend enqueues a WebSocket notification verb to that user's connections so clients can refetch the feed. Each item has client status: new (long TTL, ~180 days) or read (short TTL, ~30 days after seen). Users mark items seen via mark_notifications_seen (opening the navbar bell panel) and remove them via dismiss_notification (pars.sk), which deletes the DynamoDB row.
Users control which in-app categories are created via settings.all.inAppNotifications (separate from email/push settings.all.notifications). Toggles live in User Settings on the front end. Missing keys default to enabled (opt-out). createNotification() skips writes when a category is disabled; admin backfill uses putNotificationItem() and is not gated.
| Pref key | body.type values |
|---|---|
challenges |
challengeIssued, challengeDeclined, challengeRevoked |
gameStart |
gameStart |
gameEnd |
gameEnd |
ratingChange |
ratingChange (crons daily batch) |
eventInvitation |
eventInvitation |
completedGameChat |
completedGameChat |
tournamentStart |
tournamentStart (crons starttournaments) |
tournamentEnd |
tournamentEnd (node-backend endTournament) |
body.type |
When created | Front display |
|---|---|---|
challengeIssued |
Direct challenge opened | Game name links to /games/{metaGame}; View opens challenge response modal |
challengeDeclined / challengeRevoked |
Direct challenge response | Game name links to /games/{metaGame} |
gameStart |
Game begins | Game name links to /move/{metaGame}/0/{gameId} |
gameEnd |
Game ends | View links to /move/{metaGame}/0/{gameId}; includes opponentId / opponentName when the game has a human opponent; scores ((string | number)[]) when the game uses the scores flag |
completedGameChat |
Post-game comment on completed game (save_exploration with updateLastChat) |
View links to /move/{metaGame}/1/{gameId}; one active notification per game until dismissed; legacy backfill rows use generic message when body.backfill |
ratingChange |
Daily batch Glicko diff after summarize (crons stack) | Game name links to /ratings/{metaGame}; variant labels in message when applicable |
tournamentStart |
Tournament series starts (starttournaments cron) |
Tournament name links to /tournament/{tournamentId}; variant labels when applicable; dismiss only |
tournamentEnd |
All tournament divisions complete (endTournament) |
Tournament name links to /tournament/{tournamentId}; includes division winner name when available; dismiss only |
Tournament-end email includes division winner, plain-text standings for the recipient’s division, a link to the finished tournament, and a signup nudge for the next series (earliest start window and current signup count). Push uses winner + nudge only (no standings table); tap opens /tournament/{tournamentId}.
Batch ratingChange issuer (crons stack)
Realtime Elo at game end was removed in Phase 4. ratingChange rows are now written by the rating-change-notifications Lambda in the crons stack, scheduled at 6:20 UTC daily (after summarize at 6:00).
Source data: _summary-ratings.json on records.abstractplay.com — diffs conservative Glicko (ratingLow) per variant pool (batchRatingGameLabel keys in highest[]).
Idempotency: Prior run state in _ratings-notification-snapshot.json (same bucket). First deploy run seeds the snapshot only — zero notifications. Re-runs on the same glickoMeta.generatedAt are no-ops.
Per-pool deduplication: One notification per (userId, gameLabel) per run. Multiple rated games in the same pool between runs produce a single notification with the net ratingLow change.
Anti-spam gates (all must pass):
| Gate | Rule |
|---|---|
| Prior snapshot | First run seeds only |
| Human user | Skip pk=BOT IDs |
| Activity | new.n > old.n in snapshot |
| Magnitude | ` |
| Established enough | Skip provisional players with n < minGamesProvisional |
DynamoDB item (NOTIFICATION#userId): body.type = ratingChange, metaGame, variants, gameId empty (batch has no causal game), rounded oldRating / newRating on ratingLow, oldRd / newRd, oldProvisional / newProvisional, and delta.
Implementation: crons rating-change-notifications Lambda (crons/src/functions/rating-change-notifications.ts) and crons/src/lib/ratingChangeNotifications.ts.
| eventInvitation | Organizer saves invite list on moderated event | {organizerName} has invited you to the event with event name linking to /event/{eventId} |
Event invitations apply only to human-moderated organized events (ORGEVENT) updated through event_update_invites. Each save notifies newly added invitees and any existing invitee who does not yet have an active eventInvitation for that event (for example, invited before this feature shipped). Re-saving an unchanged invite list does not duplicate notifications. Automated tournament sign-up does not use this path.
Implementation: lib/notifications.ts, wired from lib/games/playHandlers.ts and auth route handlers.
Admin read-only dump
bin/dump-dashboard.mjs assembles dashboard-shaped data from DynamoDB without Cognito or writes. Pass --include-notifications to add the NOTIFICATION# feed using loadNotificationsForDashboard(..., { refreshExpiry: false }) so TTL is not tightened during inspection.
Completed-game chat backfill (done)
One-time prod backfill (Aug 2026) created completedGameChat notifications for users with unread post-game chat before the Completed Games dashboard section was removed. The backfill script has been deleted; new post-game chat is enqueued from save_exploration only.
Related
- Getting started — VAPID env vars
- Deployment
- Live crons —
yourturnschedule - Architecture