feat(engagement): the in-app channel, core and web (engagement Phase 7)
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

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>
This commit is contained in:
2026-08-31 02:07:10 -05:00
parent 5168446c53
commit 24a3cd85b3
34 changed files with 3153 additions and 112 deletions

View File

@@ -1,8 +1,15 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { api } from '../../api/client.js'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { inboxPath } from '../../lib/notificationPaths.js'
// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6).
// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6; the
// per-channel matrix is ENGAGEMENT.md Phase 3, surfaced in Phase 7).
//
// **It moved to `/account/notifications/settings` in Phase 7**, because the
// inbox took the plain path. See `PlayerInbox.jsx`.
//
// **This screen did not exist before phase 6, and that was the phase's first
// finding.** §6.3 says the per-Team mute list is "surfaced under the existing
@@ -18,6 +25,14 @@ import { api } from '../../api/client.js'
// thing to be told about, then which Teams, then whether any of it should reach a
// mailbox.
// The three modes a per-channel preference can take, labelled for a person. The
// set a given channel actually offers comes from its `supportsDigest` flag.
const MODES = [
{ value: 'off', label: 'Off' },
{ value: 'instant', label: 'As it happens' },
{ value: 'digest', label: 'Daily digest' },
]
const EMAIL_MODES = [
{ value: 'off', label: 'No email' },
{ value: 'digest', label: 'Daily digest' },
@@ -48,48 +63,125 @@ function Note({ msg, error }) {
)
}
// ── What to be told about ──────────────────────────────────────────────────
// ── What to be told about, and how ─────────────────────────────────────────
//
// **This replaced the push-only checkbox list, and it is a strict superset of
// it.** `GET /auth/me/notifications/channels` returns every subscribable id —
// every push stream and every event trigger, one namespace (§7.2) — with the
// EFFECTIVE mode on each channel that applies. A trigger with nothing
// registered to push it simply has no push cell; core does not have to explain
// which kind of id a row is, and neither does a reader.
//
// The old whole-set endpoints are untouched and are now this surface's push
// projection: the shipped Android app keeps its wire shape, and a `push` entry
// written here is mirrored back into `notification_subscriptions` server-side.
//
// The update is SPARSE: only the cells that changed are sent. That is what lets
// this screen manage three channels without a whole-set PUT that could clobber
// a preference a newer client set.
function Streams({ streams, subscribed, onSave, busy, msg, error }) {
const [set, setSet] = useState(() => new Set(subscribed))
useEffect(() => { setSet(new Set(subscribed)) }, [subscribed])
function Channels({ channels, items, onSave, busy, msg, error }) {
const [edits, setEdits] = useState({})
useEffect(() => setEdits({}), [items])
const toggle = (id) => {
const next = new Set(set)
if (next.has(id)) next.delete(id)
else next.add(id)
setSet(next)
const key = (id, channel) => `${id}|${channel}`
const modeOf = (item, channel) => edits[key(item.id, channel)] ?? item.modes[channel]
const set = (id, channel, mode) => setEdits((e) => ({ ...e, [key(id, channel)]: mode }))
// A channel that supports digest offers three modes; one that does not offers
// two. Read off the registry rather than hardcoded, so a channel added later
// shows the right options without touching this file.
const modesFor = (c) => (c.supportsDigest ? MODES : MODES.filter((m) => m.value !== 'digest'))
const changed = Object.entries(edits).filter(([k, mode]) => {
const [id, channel] = k.split('|')
const item = items.find((i) => i.id === id)
return item && item.modes[channel] !== mode
})
const save = () =>
onSave(
changed.map(([k, mode]) => {
const [id, channel] = k.split('|')
return { id, channel, mode }
}),
)
if (items.length === 0) {
return (
<Section title="What to notify me about">
<p className="sans dim" style={{ fontSize: '0.9rem', margin: 0 }}>
There is nothing to configure yet.
</p>
</Section>
)
}
const team = streams.filter((s) => isTeamStream(s.id))
const rest = streams.filter((s) => !isTeamStream(s.id))
const team = items.filter((i) => isTeamStream(i.id))
const rest = items.filter((i) => !isTeamStream(i.id))
const row = (s) => (
<label key={s.id} className="sans" style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: '0.92rem' }}>
<input type="checkbox" checked={set.has(s.id)} onChange={() => toggle(s.id)} style={{ marginTop: 3 }} />
<span>
<span style={{ color: 'var(--ink)' }}>{s.label}</span>
{s.description && <span className="dim" style={{ display: 'block', fontSize: '0.82rem' }}>{s.description}</span>}
</span>
</label>
)
const rows = (list) =>
list.map((item) => (
<tr key={item.id} style={{ borderTop: '1px solid var(--line-soft)' }}>
<td className="sans" style={{ padding: '10px', color: 'var(--ink)' }}>
{item.label}
{item.description && (
<span className="dim" style={{ display: 'block', fontSize: '0.8rem' }}>{item.description}</span>
)}
</td>
{channels.map((c) => (
<td key={c.id} style={{ padding: '10px' }}>
{item.channels.includes(c.id) ? (
<select
className="input"
aria-label={`${item.label} — ${c.label}`}
value={modeOf(item, c.id)}
onChange={(e) => set(item.id, c.id, e.target.value)}
style={{ fontSize: '0.86rem' }}
>
{modesFor(c).map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
) : (
// Not "off" — a dash. Nothing is registered to push this id, so
// there is no preference to hold, and an `off` select would invite
// somebody to switch on a channel that has no sender behind it.
<span className="dim" style={{ fontSize: '0.86rem' }}>—</span>
)}
</td>
))}
</tr>
))
return (
<Section
title="What to notify me about"
hint="Applies to every device you have signed in on. Notifications are delivered to the app; the website itself does not pop anything up."
hint="Applies to every device you have signed in on. On the site means an item in your notification inbox; push wakes the app, which then fetches the content."
>
<div style={{ display: 'grid', gap: 12 }}>{rest.map(row)}</div>
{team.length > 0 && (
<>
<h3 className="sans dim" style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '20px 0 10px' }}>
Teams
</h3>
<div style={{ display: 'grid', gap: 12 }}>{team.map(row)}</div>
</>
)}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
<th style={{ padding: '8px 10px' }}>Notification</th>
{channels.map((c) => (
<th key={c.id} style={{ padding: '8px 10px' }} title={c.description || undefined}>{c.label}</th>
))}
</tr>
</thead>
<tbody>
{rows(rest)}
{team.length > 0 && (
<tr>
<td colSpan={channels.length + 1} className="sans dim" style={{ padding: '18px 10px 6px', fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Teams — set site-wide here, then per team below
</td>
</tr>
)}
{rows(team)}
</tbody>
</table>
</div>
<div style={{ marginTop: 18 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave([...set])}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy || changed.length === 0} onClick={save}>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
@@ -176,27 +268,29 @@ function Teams({ teams, onSave, busy, msg, error }) {
// ── Page ───────────────────────────────────────────────────────────────────
export default function PlayerNotifications() {
const { user } = useAuth()
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [streams, setStreams] = useState([])
const [subscribed, setSubscribed] = useState([])
const [channels, setChannels] = useState([])
const [items, setItems] = useState([])
const [teams, setTeams] = useState([])
const [saving, setSaving] = useState({ streams: false, teams: false })
const [notes, setNotes] = useState({ streams: '', teams: '', streamsError: '', teamsError: '' })
const [saving, setSaving] = useState({ channels: false, teams: false })
const [notes, setNotes] = useState({ channels: '', teams: '', channelsError: '', teamsError: '' })
const load = useCallback(async () => {
setLoading(true)
try {
// Three reads in parallel: the catalog is boot-fixed, the subscriptions and
// the Team list are this user's. None depends on another.
const [cat, subs, prefs] = await Promise.all([
api.notificationStreams(),
api.notificationSubscriptions(),
// Two reads in parallel, where there used to be three: the per-channel
// surface already carries the catalog and this user's effective modes, so
// the streams+subscriptions pair it replaced is one request fewer as well
// as one concept fewer.
const [prefs, teamPrefs] = await Promise.all([
api.notificationChannelPrefs(),
api.teamNotificationPrefs(),
])
setStreams(cat.streams || [])
setSubscribed(subs.streams || [])
setTeams(prefs.teams || [])
setChannels(prefs.channels || [])
setItems(prefs.items || [])
setTeams(teamPrefs.teams || [])
setError('')
} catch {
setError('Could not load your notification settings.')
@@ -207,17 +301,23 @@ export default function PlayerNotifications() {
useEffect(() => { load() }, [load])
const saveStreams = useCallback(async (ids) => {
setSaving((s) => ({ ...s, streams: true }))
setNotes((n) => ({ ...n, streams: '', streamsError: '' }))
const saveChannels = useCallback(async (prefs) => {
if (prefs.length === 0) return
setSaving((s) => ({ ...s, channels: true }))
setNotes((n) => ({ ...n, channels: '', channelsError: '' }))
try {
const { streams: stored } = await api.setNotificationSubscriptions(ids)
setSubscribed(stored || [])
setNotes((n) => ({ ...n, streams: 'Saved.' }))
// The endpoint echoes the FULL stored state back, not just what was sent —
// so an entry it dropped (an unknown id, a channel that does not apply, a
// mode that channel will not take) is visible here as a cell that did not
// move, rather than as a screen that claims a save it did not make.
const stored = await api.setNotificationChannelPrefs(prefs)
setChannels(stored.channels || [])
setItems(stored.items || [])
setNotes((n) => ({ ...n, channels: 'Saved.' }))
} catch {
setNotes((n) => ({ ...n, streamsError: 'Could not save that.' }))
setNotes((n) => ({ ...n, channelsError: 'Could not save that.' }))
} finally {
setSaving((s) => ({ ...s, streams: false }))
setSaving((s) => ({ ...s, channels: false }))
}
}, [])
@@ -245,16 +345,17 @@ export default function PlayerNotifications() {
return (
<div>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
Choose what you are told about, and how. Nothing here is on by default except team
notifications to the app, which you can mute per team below.
Choose what you are told about, and how. Email and push are off until you switch them on;
items on the site go to your <Link to={inboxPath(user)}>notification inbox</Link>,
which you can turn off here per notification.
</p>
<Streams
streams={streams}
subscribed={subscribed}
onSave={saveStreams}
busy={saving.streams}
msg={notes.streams}
error={notes.streamsError}
<Channels
channels={channels}
items={items}
onSave={saveChannels}
busy={saving.channels}
msg={notes.channels}
error={notes.channelsError}
/>
<Teams
teams={teams}