Files
website/server/src/router/v1/auth/notifications.routes.js
wtclaude 24a3cd85b3
All checks were successful
PR Checks / client-build (pull_request) Successful in 37s
PR Checks / server-tests (pull_request) Successful in 3m27s
PR Checks / bot-tests (pull_request) Successful in 8m36s
feat(engagement): the in-app channel, core and web (engagement Phase 7)
ENGAGEMENT.md Phase 7. `user_notifications`, the in-app DeliveryChannel, the
four inbox routes, and the web surface — plus the two pieces earlier phases
assigned here that Phase 7's own acceptance line omits.

Four decisions settled by the org lead before any code:

1. `inapp` defaults to `instant` — the only channel that does. Push wakes a
   device somebody is holding and email leaves the building, so both are asked
   for; an inbox item is a row on a page the user chose to open. Left `off` the
   channel ships dead.
2. The phase takes push's `deliver` (§2603) and the web per-channel preferences
   screen (Phase 3's as-built), neither of which its own bullets mention.
3. The inbox takes `/auth/me/notifications` and `/account/notifications`; the
   preferences screen moves to `…/settings`. The plain word belongs to the
   content, which is what the bell opens.
4. `ctx.inbox.push` honours the user's in-app preference when `triggerId` names
   a registered trigger, and writes when it does not.

Server
- `user_notifications` + `model/userNotifications/`. The dedupe UNIQUE is scoped
  to the USER, narrower than the outbox's `(rule, user, channel)`: an inbox has
  no channel dimension, so two rows for one event would be one item shown twice.
- `engagement/inappChannel.js` — renders by block ROLE (first heading → title,
  first button → url, the rest → body) and inserts. `pushChannel.js` — a
  content-free `{stream, ref}` tickle whose ref deep-links the inbox row.
- `engine.liveChannels` orders `inapp` first (`CHANNEL_ORDER`) so that ref
  resolves on the first sweep. An ordering, not a dependency.
- `templates.renderInappByKey` + `resolveTemplate` extracted from `renderByKey`,
  so both channels take the same fallback chain.
- `inapp.event` seed → seedVersion 2: it named `body`/`url`, which nothing
  supplies. Renamed to the structural vocabulary the projection fills in.
- `utils/userNotificationsPrune.js` — nightly, READ items only, horizon in
  `settings.user_notifications_retain_days` (default 90).
- `GET /auth/me/notifications`, `…/unread-count`, `POST …/:id/read`,
  `POST …/read-all`. Swagger + route manifest + four component schemas.

Web
- `NotificationBell` in all three headers, polling its badge once a minute and
  pausing while the tab is hidden. `PlayerInbox` at `/account/notifications`.
- The preferences screen becomes a channel matrix over
  `/auth/me/notifications/channels` — a strict superset of the push-only stream
  list it replaces. The two legacy endpoints are untouched, so the shipped
  Android app keeps its wire shape.
- Staff get the same two screens at `/admin/notifications…`: `RequirePlayer`
  keeps them out of `/account`, so without this the inbox was unreachable for
  every non-player account. `lib/notificationPaths.js` is the one mapping.

Verified: 28 new server tests (5 of them against a real MariaDB, for the three
index/statement properties that are a server contract rather than a reading of
this code) + 3 client. Server suite green, client 327 green. A live rig walked
the whole path: two rules on one event produced three outbox rows and exactly
one inbox item, the tickle carried `ref: notification:2`, and the retention
sweep dropped an aged read row while keeping an equally aged unread one.

Docs: RunicGateway/docs#TBD, RunicGateway/runicgateway.com#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 02:07:10 -05:00

242 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Push-notification self-service under /auth/me ──────────────────────────
//
// Device registration + per-user stream subscriptions for the app's opt-in push
// (docs/android/PLAN.md §11). Mounted at /me by auth/index.js alongside
// me.routes.js, behind requireAuth ONLY (role-agnostic — every authenticated
// role manages its own devices/subscriptions), and noindex. The app calls these
// and never touches /admin.
const express = require('express')
const { body, param, query } = require('express-validator')
const notif = require('./notifications.controller')
const { requireAuth } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
const { EMAIL_MODES } = require('../../../model/teams/teamNotify.model')
const { MODES } = require('../../../engagement/channels')
const notifRouter = express.Router()
notifRouter.use(noindex, requireAuth)
// ── Devices ────────────────────────────────────────────────────────────────
notifRouter.post(
'/devices',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Register a push device (endpoint) for the current user'
// #swagger.description = 'Registers a UnifiedPush/ntfy endpoint (or an FCM token) so the backend can deliver opt-in push tickles. The endpoint must be an allowed HTTPS relay URL — private/loopback hosts and non-allowed origins are rejected 400. Idempotent per (user, endpoint).'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RegisterDeviceRequest" } } } } */
/* #swagger.responses[201] = { description: 'Device registered', content: { "application/json": { schema: { $ref: "#/components/schemas/PushDevice" } } } } */
/* #swagger.responses[400] = { description: 'Validation error or disallowed endpoint', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('endpoint').isString().trim().isURL({ protocols: ['https'], require_protocol: true }).isLength({ max: 512 }),
body('transport').optional().isIn(['unifiedpush', 'fcm']),
body('platform').optional({ values: 'falsy' }).isString().isLength({ max: 40 }),
validate,
notif.registerDevice,
)
notifRouter.get(
'/devices',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'List the current user’s registered push devices'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Registered devices', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/PushDevice" } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
notif.listDevices,
)
notifRouter.delete(
'/devices/:id',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Unregister a push device'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Device id (must belong to the caller).' }
/* #swagger.responses[200] = { description: 'Unregistered', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
/* #swagger.responses[404] = { description: 'No such device for this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }),
validate,
notif.removeDevice,
)
// ── Streams catalog + subscriptions ─────────────────────────────────────────
notifRouter.get(
'/notifications/streams',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'List subscribable notification streams (catalog)'
// #swagger.description = 'The catalog of push streams. `personal`/`requiresLinkedAccount` streams are delivered only to the owning user and need a linked game account.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Stream catalog', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationStreams" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
notif.getStreams,
)
notifRouter.get(
'/notifications/subscriptions',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Get the current user’s notification subscriptions'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Subscribed stream ids', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationSubscriptions" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
notif.getSubscriptions,
)
notifRouter.put(
'/notifications/subscriptions',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Replace the current user’s notification subscriptions'
// #swagger.description = 'Sets the full opted-in stream set (applied to all the user’s devices). Unknown stream ids are ignored; the stored set is echoed back.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationSubscriptions" } } } } */
/* #swagger.responses[200] = { description: 'Updated subscriptions', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationSubscriptions" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('streams').isArray(),
body('streams.*').isString().isLength({ max: 64 }),
validate,
notif.putSubscriptions,
)
// ── Per-channel preferences (ENGAGEMENT.md §4.5, phase 3) ──────────────────
//
// The channel dimension `notification_subscriptions` lacks. The two endpoints
// above are unchanged and become the push projection of these — the shipped app
// keeps its wire shape, and a newer client manages email and in-app through here.
//
// The PUT is SPARSE, deliberately unlike the two whole-set PUTs either side of
// it: only the pairs named are written. `off` is a mode rather than an omission,
// so there is no "clearing the last entry" case and no empty-array DTO gotcha.
notifRouter.get(
'/notifications/channels',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Get the current user’s per-channel notification preferences'
// #swagger.description = 'The delivery channels (email, push, in-app) with their defaults, plus one item per subscribable id — every push stream and every event trigger, one namespace — carrying the effective mode on each channel that applies to it. A trigger-only id has no push toggle. Modes not stored are reported as the channel’s default, so a client never has to know which it is looking at.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Per-channel preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationChannelPrefs" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
notif.getChannelPrefs,
)
notifRouter.put(
'/notifications/channels',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Update the current user’s per-channel notification preferences'
// #swagger.description = 'A SPARSE update: only the (id, channel) pairs in `prefs` are written and every other pair is left untouched, so setting `email` does not disturb `push`. Entries naming an unknown id, a channel that does not apply to that id, or a mode that channel does not accept are ignored. A `push` entry is mirrored into /notifications/subscriptions. The full stored state is echoed back.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationChannelPrefsUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Updated preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationChannelPrefs" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('prefs').isArray(),
body('prefs.*.id').isString().isLength({ min: 1, max: 64 }),
body('prefs.*.channel').isString().isLength({ min: 1, max: 32 }),
body('prefs.*.mode').isIn(MODES),
validate,
notif.putChannelPrefs,
)
// ── The inbox (ENGAGEMENT.md §4.5 G17, phase 7) ────────────────────────────
//
// The in-app channel's read side, and the only routes in this file that carry
// CONTENT rather than a preference. They share the `/notifications` prefix
// because a person calls both by that name; the bare path is the inbox and the
// named sub-paths above are the settings for it.
//
// **Route order matters here and is not incidental.** `/notifications/streams`,
// `/notifications/subscriptions`, `/notifications/channels` and
// `/notifications/teams` are all declared ABOVE, and none of the routes below
// introduces a GET `/notifications/:something` that could shadow them. The one
// parameterised path is a POST, and its `:id` is digits-only.
notifRouter.get(
'/notifications',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'One page of the caller’s notification inbox'
// #swagger.description = 'The in-app channel’s items for the signed-in user, newest first. Paged with a keyset cursor (`before`), not an offset, because the list gains rows at the top while it is being read. `unread` counts the whole inbox, not the page. There is no way to name another user: the caller is the only account these routes can read.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, description: 'Page size (capped at 100).' }
// #swagger.parameters['before'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Return items with an id lower than this — the cursor from the previous page.' }
// #swagger.parameters['unread'] = { in: 'query', required: false, schema: { type: 'boolean' }, description: 'Only items that have not been read.' }
/* #swagger.responses[200] = { description: 'A page of the inbox', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationInbox" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
query('limit').optional().isInt({ min: 1, max: 100 }),
query('before').optional().isInt({ min: 1 }),
query('unread').optional().isIn(['true', 'false', '1', '0']),
validate,
notif.getInbox,
)
notifRouter.get(
'/notifications/unread-count',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'How many inbox items the caller has not read'
// #swagger.description = 'The badge. Its own route because it is polled — asking “is there anything new” should not make the server assemble a page of bodies to answer with one integer.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The unread count', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationUnreadCount" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
notif.getUnreadCount,
)
notifRouter.post(
'/notifications/read-all',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Mark the caller’s whole inbox read'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Marked read', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationReadResult" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
notif.markAllRead,
)
notifRouter.post(
'/notifications/:id/read',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Mark one inbox item read'
// #swagger.description = 'Idempotent: a second call does not move the timestamp. 404 both when no such item exists and when it belongs to another account — the same answer on purpose, so this cannot be used to ask whether an id is anybody’s.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Notification id (must belong to the caller).' }
/* #swagger.responses[200] = { description: 'Marked read', content: { "application/json": { schema: { $ref: "#/components/schemas/NotificationReadResult" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such item for this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }),
validate,
notif.markRead,
)
// ── Per-Team preferences (TEAMS.md §6.3, phase 6) ──────────────────────────
//
// The granularity per-stream opt-in cannot express: "I am in five Teams and want
// notifications from one". Opt-OUT for push (no row means notified) and opt-IN
// for email, so a user who never opens this screen is in the state the schema
// documents rather than in one this router has to describe.
notifRouter.get(
'/notifications/teams',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Get the current user’s per-Team notification preferences'
// #swagger.description = 'One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they have a stored preference for. Defaults are applied server-side: `muted` false, `emailMode` "off".'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Per-Team preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
notif.getTeamPrefs,
)
notifRouter.put(
'/notifications/teams',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Replace the current user’s per-Team notification preferences'
// #swagger.description = 'Replaces the whole set. The `teams` array is required even when empty. Entries naming a Team the caller has no access to are ignored; the stored set is echoed back.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */
/* #swagger.responses[200] = { description: 'Updated preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('teams').isArray(),
body('teams.*.teamId').isInt({ min: 1 }),
body('teams.*.muted').optional().isBoolean(),
body('teams.*.emailMode').optional().isIn(EMAIL_MODES),
validate,
notif.putTeamPrefs,
)
module.exports = notifRouter