Notifications

Email (SES)

Transactional email is sent via AWS SES for:

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:

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