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

@@ -64,6 +64,7 @@ import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx' import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx' import PlayerAccount from './routes/player/PlayerAccount.jsx'
import PlayerNotifications from './routes/player/PlayerNotifications.jsx' import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
import PlayerInbox from './routes/player/PlayerInbox.jsx'
import Unsubscribe from './routes/player/Unsubscribe.jsx' import Unsubscribe from './routes/player/Unsubscribe.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx' import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
@@ -209,6 +210,13 @@ export default function App() {
<Route path="sends" element={<EngagementSendLog />} /> <Route path="sends" element={<EngagementSendLog />} />
</Route> </Route>
<Route path="account" element={<AccountAdmin />} /> <Route path="account" element={<AccountAdmin />} />
{/* Staff have an inbox and channel preferences like anyone else —
`/auth/me/notifications` is behind requireAuth only — but
`RequirePlayer` sends them out of the player portal, so the two
screens are mounted here as well. Same components, same API,
two paths; `lib/notificationPaths.js` is the one mapping. */}
<Route path="notifications" element={<PlayerInbox />} />
<Route path="notifications/settings" element={<PlayerNotifications />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside {/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth RequireAuth + AdminLayout. A module cannot supply its own auth
wrapper — only an optional { roles }, which core applies as the wrapper — only an optional { roles }, which core applies as the
@@ -252,7 +260,14 @@ export default function App() {
<Route path="/player" element={<PlayerIndex />} /> <Route path="/player" element={<PlayerIndex />} />
<Route path="/account" element={<PlayerAccount />} /> <Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} /> <Route path="/account/appeals" element={<PlayerAppeals />} />
<Route path="/account/notifications" element={<PlayerNotifications />} /> {/* The inbox took `/account/notifications` in engagement Phase 7
and the preferences screen moved under it. Content and
settings are different kinds of thing, and the plain word
belongs to the one a person means when they say it — which is
also what the bell in the header opens. The server's routes
split at the same place. */}
<Route path="/account/notifications" element={<PlayerInbox />} />
<Route path="/account/notifications/settings" element={<PlayerNotifications />} />
{/* Installed modules' player-portal pages, at /player/<id>/…. This {/* Installed modules' player-portal pages, at /player/<id>/…. This
group's own routes are absolute (its layout route has no path), group's own routes are absolute (its layout route has no path),
so the prefix is written here rather than inherited — the one so the prefix is written here rather than inherited — the one

View File

@@ -230,6 +230,25 @@ export const api = {
// field, so clearing the last subscription must not become an absent key. // field, so clearing the last subscription must not become an absent key.
setNotificationSubscriptions: (streams) => setNotificationSubscriptions: (streams) =>
req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }), req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }),
// Per-channel preferences (ENGAGEMENT.md Phase 3). A SPARSE update: only the
// (id, channel) pairs sent are written, so a screen managing one channel need
// not know what the others hold. Shipped with no surface at all until Phase 7.
notificationChannelPrefs: () => req('/auth/me/notifications/channels'),
setNotificationChannelPrefs: (prefs) =>
req('/auth/me/notifications/channels', { method: 'PUT', body: { prefs } }),
// The in-app inbox (ENGAGEMENT.md Phase 7). `before` is a keyset cursor — the
// id of the last item on the previous page — not an offset: the list gains
// rows at the top while it is being read.
notifications: ({ limit, before, unread } = {}) => {
const qs = new URLSearchParams()
if (limit) qs.set('limit', String(limit))
if (before) qs.set('before', String(before))
if (unread) qs.set('unread', 'true')
return req(`/auth/me/notifications${withQs(qs.toString())}`)
},
notificationsUnreadCount: () => req('/auth/me/notifications/unread-count'),
markNotificationRead: (id) => req(`/auth/me/notifications/${id}/read`, { method: 'POST' }),
markAllNotificationsRead: () => req('/auth/me/notifications/read-all', { method: 'POST' }),
teamNotificationPrefs: () => req('/auth/me/notifications/teams'), teamNotificationPrefs: () => req('/auth/me/notifications/teams'),
setTeamNotificationPrefs: (teams) => setTeamNotificationPrefs: (teams) =>
req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }), req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }),

View File

@@ -0,0 +1,353 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext.jsx'
import { api } from '../api/client.js'
import { inboxPath } from '../lib/notificationPaths.js'
// The in-app inbox's header surface (ENGAGEMENT.md Phase 7): a bell with an
// unread badge, and a panel with the most recent items.
//
// **The badge is polled, not pushed**, and the reason is that there is nothing
// to push over. The site's two SSE streams are the shard's; neither is
// per-user, and adding a third authenticated stream to carry an integer would
// mean one open connection per signed-in tab for the rest of the deployment's
// life. A minute-granular badge on a page somebody is already looking at is the
// same answer for a fraction of that. The poll pauses while the tab is hidden —
// a background tab has nobody to show a badge to — and refreshes the moment it
// comes back, which is also the moment it would be most wrong.
//
// **The panel shows a handful and links out.** Paging belongs on the page; a
// dropdown that scrolls is a list in the wrong place.
//
// Dismissal follows `NavDropdown`'s contract exactly — Escape closes and
// returns focus, an outside `mousedown` closes, navigating closes — because
// this sits beside it in the same header and two menus that dismiss differently
// is a bug nobody files.
const POLL_MS = 60_000
const PANEL_ITEMS = 6
function BellIcon({ size = 17 }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.7 21a2 2 0 01-3.4 0" />
</svg>
)
}
// "3m", "4h", "6d" — a relative stamp, because the only question a reader has
// about an inbox item's time is how fresh it is.
function ago(iso) {
const then = new Date(iso).getTime()
if (!Number.isFinite(then)) return ''
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
if (secs < 60) return 'now'
if (secs < 3600) return `${Math.floor(secs / 60)}m`
if (secs < 86400) return `${Math.floor(secs / 3600)}h`
return `${Math.floor(secs / 86400)}d`
}
export default function NotificationBell() {
const { user } = useAuth()
const [unread, setUnread] = useState(0)
const [items, setItems] = useState([])
const [open, setOpen] = useState(false)
const [error, setError] = useState('')
const wrapRef = useRef(null)
const triggerRef = useRef(null)
const location = useLocation()
const navigate = useNavigate()
// Every read here swallows its failure. A count that could not be fetched is
// a bell with no badge, which is what a bell with nothing to report looks
// like anyway — the alternative is an error banner in the site header for a
// number nobody asked for.
const refreshCount = useCallback(async () => {
if (!user) return
try {
const res = await api.notificationsUnreadCount()
setUnread(res.unread || 0)
} catch {
/* leave the badge as it was */
}
}, [user])
useEffect(() => {
if (!user) return undefined
refreshCount()
const timer = setInterval(() => {
if (document.visibilityState === 'visible') refreshCount()
}, POLL_MS)
const onVisible = () => {
if (document.visibilityState === 'visible') refreshCount()
}
document.addEventListener('visibilitychange', onVisible)
return () => {
clearInterval(timer)
document.removeEventListener('visibilitychange', onVisible)
}
}, [user, refreshCount])
// The panel's items are fetched when it opens, never kept warm: a list nobody
// has asked to see is a request per minute for content nobody is reading.
const load = useCallback(async () => {
setError('')
try {
const res = await api.notifications({ limit: PANEL_ITEMS })
setItems(res.items || [])
setUnread(res.unread || 0)
} catch (err) {
setError(err.message || 'Could not load notifications')
}
}, [])
useEffect(() => setOpen(false), [location.pathname])
useEffect(() => {
if (!open) return undefined
const onKey = (e) => {
if (e.key !== 'Escape') return
setOpen(false)
triggerRef.current?.focus()
}
const onOutside = (e) => {
if (!wrapRef.current?.contains(e.target)) setOpen(false)
}
document.addEventListener('keydown', onKey)
document.addEventListener('mousedown', onOutside)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onOutside)
}
}, [open])
if (!user) return null
const toggle = () => {
const next = !open
setOpen(next)
if (next) load()
}
// Opening an item marks it read and then goes where it points. The mark is
// awaited rather than fired off, so the badge the next screen renders is the
// one this click produced; a failed mark still navigates, because the item's
// link is the thing the user asked for.
const openItem = async (item) => {
setOpen(false)
if (!item.read) {
try {
const res = await api.markNotificationRead(item.id)
setUnread(res.unread ?? Math.max(0, unread - 1))
} catch {
/* the link still works */
}
}
navigate(item.url || inboxPath(user))
}
const markAll = async () => {
try {
await api.markAllNotificationsRead()
setUnread(0)
setItems((list) => list.map((i) => ({ ...i, read: true })))
} catch (err) {
setError(err.message || 'Could not mark them read')
}
}
return (
<div ref={wrapRef} style={{ position: 'relative' }}>
<button
ref={triggerRef}
type="button"
className="pill"
aria-haspopup="true"
aria-expanded={open}
// The count is in the label, not only in the badge: a screen reader gets
// "Notifications, 3 unread" rather than "Notifications" and a number it
// has no way to relate to it.
aria-label={unread ? `Notifications, ${unread} unread` : 'Notifications'}
onClick={toggle}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
position: 'relative',
...(open ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : {}),
}}
>
<BellIcon />
{unread > 0 && (
<span
aria-hidden="true"
className="sans"
style={{
minWidth: 17,
height: 17,
padding: '0 4px',
borderRadius: 9,
background: 'var(--accent)',
color: 'var(--bg-deep)',
fontSize: '0.68rem',
fontWeight: 700,
lineHeight: '17px',
textAlign: 'center',
}}
>
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
{open && (
<div
role="menu"
aria-label="Notifications"
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
right: 0,
width: 320,
maxWidth: 'calc(100vw - 24px)',
padding: 6,
borderRadius: 'var(--radius-card)',
border: '1px solid var(--line)',
background: 'var(--panel-flat)',
boxShadow: 'var(--shadow-card)',
zIndex: 40,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
padding: '4px 8px 8px',
}}
>
<strong className="sans" style={{ fontSize: '0.82rem', color: 'var(--head)' }}>
Notifications
</strong>
{unread > 0 && (
<button
type="button"
onClick={markAll}
className="sans"
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'var(--accent)',
fontSize: '0.78rem',
}}
>
Mark all read
</button>
)}
</div>
{error && (
<p className="sans" style={{ margin: '0 8px 8px', fontSize: '0.8rem', color: '#d98b84' }}>
{error}
</p>
)}
{!error && items.length === 0 && (
<p className="sans dim" style={{ margin: '0 8px 10px', fontSize: '0.82rem' }}>
Nothing here yet.
</p>
)}
{items.map((item) => (
<button
key={item.id}
type="button"
role="menuitem"
onClick={() => openItem(item)}
className="sans"
style={{
display: 'block',
width: '100%',
textAlign: 'left',
padding: '8px 10px',
borderRadius: 'var(--radius-input)',
border: 'none',
cursor: 'pointer',
background: item.read ? 'transparent' : 'var(--panel)',
}}
>
<span
style={{
display: 'block',
fontSize: '0.85rem',
color: item.read ? 'var(--muted)' : 'var(--head)',
fontWeight: item.read ? 400 : 600,
}}
>
{item.title}
</span>
{item.body && (
<span
className="dim"
style={{
fontSize: '0.78rem',
marginTop: 2,
// The body is stored and rendered as TEXT, never as markup —
// `white-space: pre-line` is what keeps the template's own
// line breaks without ever interpreting anything.
whiteSpace: 'pre-line',
// Two lines, then an ellipsis. `-webkit-box` is the only
// clamp with real support; it is also why there is no second
// `display: block` above it.
display: '-webkit-box',
overflow: 'hidden',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{item.body}
</span>
)}
<span className="dim" style={{ display: 'block', fontSize: '0.72rem', marginTop: 3 }}>
{ago(item.createdAt)}
</span>
</button>
))}
<Link
to={inboxPath(user)}
role="menuitem"
onClick={() => setOpen(false)}
className="sans"
style={{
display: 'block',
marginTop: 4,
padding: '8px 10px',
borderTop: '1px solid var(--line-soft)',
fontSize: '0.8rem',
color: 'var(--accent)',
textDecoration: 'none',
}}
>
See all notifications
</Link>
</div>
)}
</div>
)
}

View File

@@ -5,6 +5,7 @@ import BrandLogo from './BrandLogo.jsx'
import { useAuth } from '../contexts/AuthContext.jsx' import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx' import { useSite } from '../contexts/SiteContext.jsx'
import NavDropdown from './NavDropdown.jsx' import NavDropdown from './NavDropdown.jsx'
import NotificationBell from './NotificationBell.jsx'
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js' import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
import { parseJsonSetting } from '../lib/settingsJson.js' import { parseJsonSetting } from '../lib/settingsJson.js'
import { withModuleNav } from '../modules/nav.js' import { withModuleNav } from '../modules/nav.js'
@@ -107,6 +108,10 @@ export default function SiteHeader() {
</NavLink> </NavLink>
), ),
)} )}
{/* Renders nothing when signed out, so the header keeps its shape for
a visitor. It is here rather than only in the portal because an
inbox item is worth seeing from the page you are already on. */}
{!loading && <NotificationBell />}
{!loading && ( {!loading && (
<NavLink <NavLink
to={account.to} to={account.to}

View File

@@ -0,0 +1,21 @@
// Where a given account's notification screens live.
//
// **Staff and players reach the same two screens at different paths, and that is
// this file's whole reason to exist.** `/auth/me/notifications` is role-agnostic
// — behind `requireAuth` only, like every other `/auth/me` route — but the WEB
// has two logged-in shells: `RequirePlayer` sends anyone who is not a player to
// the admin area, where staff manage their own account under `/admin/account`.
// So a bell that always pointed at `/account/notifications` would, for every
// staff member, point at a page that redirects.
//
// Discovered in the Phase 7 rig: signed in as an admin, the inbox was simply
// unreachable on the web. Two routes, one pair of components, one mapping here.
export const isStaff = (user) => !!(user && user.role && user.role !== 'player')
/** The inbox — what the bell opens. */
export const inboxPath = (user) => (isStaff(user) ? '/admin/notifications' : '/account/notifications')
/** The per-channel preferences screen. */
export const notificationSettingsPath = (user) =>
isStaff(user) ? '/admin/notifications/settings' : '/account/notifications/settings'

View File

@@ -96,7 +96,7 @@ export default function TeamNotifyToggle({ externalId, moduleId }) {
{/* The one link off this control, because "mute" is a blunt answer to a {/* The one link off this control, because "mute" is a blunt answer to a
question the account screen asks properly — which streams, and whether question the account screen asks properly — which streams, and whether
email is on at all. */} email is on at all. */}
<Link to="/account/notifications" className="dim">All notification settings</Link> <Link to="/account/notifications/settings" className="dim">All notification settings</Link>
</div> </div>
) )
} }

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom' import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx' import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx' import BrandLogo from '../../components/BrandLogo.jsx'
import NotificationBell from '../../components/NotificationBell.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js' import { applyNavOverrides } from '../../lib/navOverrides.js'
@@ -43,6 +44,7 @@ const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon> const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon> const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon> const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon> const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon> const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
const IconModules = () => <Icon><path d="M12 3l8 4.5-8 4.5-8-4.5z" /><path d="M4 12l8 4.5 8-4.5" /><path d="M4 16.5L12 21l8-4.5" /></Icon> const IconModules = () => <Icon><path d="M12 3l8 4.5-8 4.5-8-4.5z" /><path d="M4 12l8 4.5 8-4.5" /><path d="M4 16.5L12 21l8-4.5" /></Icon>
@@ -129,6 +131,11 @@ export const NAV = [
}, },
{ {
items: [ items: [
// No `end`: `allowedPathsFor` turns an `end` row into an EXACT match, so
// marking this one exact would leave `/admin/notifications/settings`
// outside the allowlist and bounce a staff member off their own
// preferences screen. The row covering its sub-routes is the point.
{ to: '/admin/notifications', label: 'Notifications', icon: IconBell },
{ to: '/admin/account', label: 'Account', icon: IconUser }, { to: '/admin/account', label: 'Account', icon: IconUser },
], ],
}, },
@@ -177,6 +184,8 @@ const TITLES = {
'/admin/users': 'Users', '/admin/users': 'Users',
'/admin/invites': 'Invites', '/admin/invites': 'Invites',
'/admin/account': 'Account Security', '/admin/account': 'Account Security',
'/admin/notifications': 'Notifications',
'/admin/notifications/settings': 'Notification settings',
'/admin/engagement/rules': 'Engagement Rules', '/admin/engagement/rules': 'Engagement Rules',
'/admin/engagement/audiences': 'Engagement Audiences', '/admin/engagement/audiences': 'Engagement Audiences',
'/admin/engagement/templates': 'Message Templates', '/admin/engagement/templates': 'Message Templates',
@@ -443,6 +452,11 @@ export default function AdminLayout() {
{title} {title}
</h1> </h1>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: '0.84rem', color: 'var(--muted)' }}> <div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: '0.84rem', color: 'var(--muted)' }}>
{/* Staff have an inbox like anyone else — `/auth/me/notifications`
is role-agnostic — and `RequirePlayer` keeps them out of the
player portal, so without this the one place they spend their
time is the one place the bell is missing. */}
<NotificationBell />
<a href="/" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)', textDecoration: 'none' }}> <a href="/" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
View site View site
</a> </a>

View File

@@ -0,0 +1,264 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { api } from '../../api/client.js'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { notificationSettingsPath, inboxPath } from '../../lib/notificationPaths.js'
// The in-app inbox (ENGAGEMENT.md Phase 7), at `/account/notifications`.
//
// **It took that path from the preferences screen, which moved to
// `/account/notifications/settings`.** The two are different kinds of thing —
// one is content addressed to this person, the other is how they would like to
// be reached — and the word "notifications" belongs to the first: it is what a
// person means when they say it, and what the bell in the header opens. The
// server's routes make the same split at the same place.
//
// Everything a row can carry is TEXT. `body` is stored as the text part of the
// in-app template's blocks and rendered with `white-space: pre-line`, never as
// markup; `url` is site-relative by the time it is stored, checked against the
// same character class `pageUrlTemplate` uses. So there is no sanitizing to do
// here — there is nothing on this screen that could be markup.
const PAGE = 30
function ago(iso) {
const then = new Date(iso).getTime()
if (!Number.isFinite(then)) return ''
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
if (secs < 60) return 'just now'
if (secs < 3600) return `${Math.floor(secs / 60)} min ago`
if (secs < 86400) return `${Math.floor(secs / 3600)} h ago`
if (secs < 30 * 86400) return `${Math.floor(secs / 86400)} d ago`
return new Date(iso).toLocaleDateString()
}
function Item({ item, onOpen, onMark }) {
const body = (
<>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
<strong
className="sans"
style={{
fontSize: '0.95rem',
color: item.read ? 'var(--muted)' : 'var(--head)',
fontWeight: item.read ? 500 : 700,
}}
>
{item.title}
</strong>
<span className="sans dim" style={{ fontSize: '0.76rem' }}>{ago(item.createdAt)}</span>
</div>
{item.body && (
<p
className="sans dim"
style={{ margin: '6px 0 0', fontSize: '0.86rem', whiteSpace: 'pre-line' }}
>
{item.body}
</p>
)}
</>
)
return (
<li
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 12,
padding: '14px 16px',
borderRadius: 'var(--radius-card)',
border: '1px solid var(--line-soft)',
// The one visual difference between read and unread, plus the weight
// above. A dot alone is easy to miss on a long list.
background: item.read ? 'transparent' : 'var(--panel)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
{item.url ? (
<button
type="button"
onClick={() => onOpen(item)}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
}}
>
{body}
</button>
) : (
body
)}
</div>
{!item.read && (
<button
type="button"
onClick={() => onMark(item)}
className="sans"
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'var(--accent)',
fontSize: '0.78rem',
whiteSpace: 'nowrap',
}}
>
Mark read
</button>
)}
</li>
)
}
export default function PlayerInbox() {
const [items, setItems] = useState([])
const [unread, setUnread] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [unreadOnly, setUnreadOnly] = useState(false)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const navigate = useNavigate()
const { user } = useAuth()
const load = useCallback(async (only) => {
setLoading(true)
setError('')
try {
const res = await api.notifications({ limit: PAGE, unread: only })
setItems(res.items || [])
setHasMore(!!res.hasMore)
setUnread(res.unread || 0)
} catch (err) {
setError(err.message || 'Could not load your notifications')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load(unreadOnly) }, [load, unreadOnly])
// The cursor is the last item's id, not a page number: the list gains rows at
// the top while it is being read, and an offset under those conditions repeats
// or skips items.
const more = async () => {
if (!items.length) return
setBusy(true)
try {
const res = await api.notifications({
limit: PAGE,
before: items[items.length - 1].id,
unread: unreadOnly,
})
setItems((list) => [...list, ...(res.items || [])])
setHasMore(!!res.hasMore)
} catch (err) {
setError(err.message || 'Could not load more')
} finally {
setBusy(false)
}
}
const mark = async (item) => {
try {
const res = await api.markNotificationRead(item.id)
setUnread(res.unread ?? Math.max(0, unread - 1))
// Filtered to unread, a marked item leaves the list; unfiltered it stays
// and goes quiet. Either way the list matches what it says it is showing.
setItems((list) =>
unreadOnly
? list.filter((i) => i.id !== item.id)
: list.map((i) => (i.id === item.id ? { ...i, read: true } : i)),
)
} catch (err) {
setError(err.message || 'Could not mark it read')
}
}
const open = async (item) => {
if (!item.read) await mark(item)
if (item.url) navigate(item.url)
}
const markAll = async () => {
setBusy(true)
try {
await api.markAllNotificationsRead()
setUnread(0)
setItems((list) => (unreadOnly ? [] : list.map((i) => ({ ...i, read: true }))))
} catch (err) {
setError(err.message || 'Could not mark them read')
} finally {
setBusy(false)
}
}
if (loading) return <Loading label="Loading your notifications…" />
if (error && !items.length) return <ErrorState message={error} />
return (
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
marginBottom: 18,
}}
>
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
{unread > 0 ? `${unread} unread` : 'Everything is read.'}{' '}
<Link to={notificationSettingsPath(user)} className="dim">
Notification settings
</Link>
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="pill"
onClick={() => setUnreadOnly((v) => !v)}
style={unreadOnly ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : {}}
>
{unreadOnly ? 'Showing unread' : 'Show unread only'}
</button>
<button type="button" className="pill" onClick={markAll} disabled={busy || unread === 0}>
Mark all read
</button>
</div>
</div>
{error && (
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
)}
{items.length === 0 ? (
<p className="sans dim" style={{ fontSize: '0.9rem' }}>
{unreadOnly
? 'Nothing unread.'
: 'Nothing here yet. Anything the shard or your guilds want to tell you will show up on this page.'}
</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map((item) => (
<Item key={item.id} item={item} onOpen={open} onMark={mark} />
))}
</ul>
)}
{hasMore && (
<button type="button" className="pill" onClick={more} disabled={busy} style={{ marginTop: 16 }}>
{busy ? 'Loading…' : 'Load older'}
</button>
)}
</div>
)
}

View File

@@ -1,8 +1,15 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../components/PageState.jsx' import { Loading, ErrorState } from '../../components/PageState.jsx'
import { api } from '../../api/client.js' 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 // **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 // 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 // thing to be told about, then which Teams, then whether any of it should reach a
// mailbox. // 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 = [ const EMAIL_MODES = [
{ value: 'off', label: 'No email' }, { value: 'off', label: 'No email' },
{ value: 'digest', label: 'Daily digest' }, { 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 }) { function Channels({ channels, items, onSave, busy, msg, error }) {
const [set, setSet] = useState(() => new Set(subscribed)) const [edits, setEdits] = useState({})
useEffect(() => { setSet(new Set(subscribed)) }, [subscribed]) useEffect(() => setEdits({}), [items])
const toggle = (id) => { const key = (id, channel) => `${id}|${channel}`
const next = new Set(set) const modeOf = (item, channel) => edits[key(item.id, channel)] ?? item.modes[channel]
if (next.has(id)) next.delete(id) const set = (id, channel, mode) => setEdits((e) => ({ ...e, [key(id, channel)]: mode }))
else next.add(id)
setSet(next) // 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 team = items.filter((i) => isTeamStream(i.id))
const rest = streams.filter((s) => !isTeamStream(s.id)) const rest = items.filter((i) => !isTeamStream(i.id))
const row = (s) => ( const rows = (list) =>
<label key={s.id} className="sans" style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: '0.92rem' }}> list.map((item) => (
<input type="checkbox" checked={set.has(s.id)} onChange={() => toggle(s.id)} style={{ marginTop: 3 }} /> <tr key={item.id} style={{ borderTop: '1px solid var(--line-soft)' }}>
<span> <td className="sans" style={{ padding: '10px', color: 'var(--ink)' }}>
<span style={{ color: 'var(--ink)' }}>{s.label}</span> {item.label}
{s.description && <span className="dim" style={{ display: 'block', fontSize: '0.82rem' }}>{s.description}</span>} {item.description && (
</span> <span className="dim" style={{ display: 'block', fontSize: '0.8rem' }}>{item.description}</span>
</label> )}
) </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 ( return (
<Section <Section
title="What to notify me about" 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> <div style={{ overflowX: 'auto' }}>
{team.length > 0 && ( <table style={{ width: '100%', borderCollapse: 'collapse' }}>
<> <thead>
<h3 className="sans dim" style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '20px 0 10px' }}> <tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Teams <th style={{ padding: '8px 10px' }}>Notification</th>
</h3> {channels.map((c) => (
<div style={{ display: 'grid', gap: 12 }}>{team.map(row)}</div> <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 }}> <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'} {busy ? 'Saving…' : 'Save'}
</button> </button>
</div> </div>
@@ -176,27 +268,29 @@ function Teams({ teams, onSave, busy, msg, error }) {
// ── Page ─────────────────────────────────────────────────────────────────── // ── Page ───────────────────────────────────────────────────────────────────
export default function PlayerNotifications() { export default function PlayerNotifications() {
const { user } = useAuth()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [streams, setStreams] = useState([]) const [channels, setChannels] = useState([])
const [subscribed, setSubscribed] = useState([]) const [items, setItems] = useState([])
const [teams, setTeams] = useState([]) const [teams, setTeams] = useState([])
const [saving, setSaving] = useState({ streams: false, teams: false }) const [saving, setSaving] = useState({ channels: false, teams: false })
const [notes, setNotes] = useState({ streams: '', teams: '', streamsError: '', teamsError: '' }) const [notes, setNotes] = useState({ channels: '', teams: '', channelsError: '', teamsError: '' })
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
// Three reads in parallel: the catalog is boot-fixed, the subscriptions and // Two reads in parallel, where there used to be three: the per-channel
// the Team list are this user's. None depends on another. // surface already carries the catalog and this user's effective modes, so
const [cat, subs, prefs] = await Promise.all([ // the streams+subscriptions pair it replaced is one request fewer as well
api.notificationStreams(), // as one concept fewer.
api.notificationSubscriptions(), const [prefs, teamPrefs] = await Promise.all([
api.notificationChannelPrefs(),
api.teamNotificationPrefs(), api.teamNotificationPrefs(),
]) ])
setStreams(cat.streams || []) setChannels(prefs.channels || [])
setSubscribed(subs.streams || []) setItems(prefs.items || [])
setTeams(prefs.teams || []) setTeams(teamPrefs.teams || [])
setError('') setError('')
} catch { } catch {
setError('Could not load your notification settings.') setError('Could not load your notification settings.')
@@ -207,17 +301,23 @@ export default function PlayerNotifications() {
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
const saveStreams = useCallback(async (ids) => { const saveChannels = useCallback(async (prefs) => {
setSaving((s) => ({ ...s, streams: true })) if (prefs.length === 0) return
setNotes((n) => ({ ...n, streams: '', streamsError: '' })) setSaving((s) => ({ ...s, channels: true }))
setNotes((n) => ({ ...n, channels: '', channelsError: '' }))
try { try {
const { streams: stored } = await api.setNotificationSubscriptions(ids) // The endpoint echoes the FULL stored state back, not just what was sent —
setSubscribed(stored || []) // so an entry it dropped (an unknown id, a channel that does not apply, a
setNotes((n) => ({ ...n, streams: 'Saved.' })) // 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 { } catch {
setNotes((n) => ({ ...n, streamsError: 'Could not save that.' })) setNotes((n) => ({ ...n, channelsError: 'Could not save that.' }))
} finally { } finally {
setSaving((s) => ({ ...s, streams: false })) setSaving((s) => ({ ...s, channels: false }))
} }
}, []) }, [])
@@ -245,16 +345,17 @@ export default function PlayerNotifications() {
return ( return (
<div> <div>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}> <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 Choose what you are told about, and how. Email and push are off until you switch them on;
notifications to the app, which you can mute per team below. items on the site go to your <Link to={inboxPath(user)}>notification inbox</Link>,
which you can turn off here per notification.
</p> </p>
<Streams <Channels
streams={streams} channels={channels}
subscribed={subscribed} items={items}
onSave={saveStreams} onSave={saveChannels}
busy={saving.streams} busy={saving.channels}
msg={notes.streams} msg={notes.channels}
error={notes.streamsError} error={notes.channelsError}
/> />
<Teams <Teams
teams={teams} teams={teams}

View File

@@ -2,6 +2,7 @@ import { useMemo } from 'react'
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom' import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx' import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx' import BrandLogo from '../../components/BrandLogo.jsx'
import NotificationBell from '../../components/NotificationBell.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js' import { applyNavOverrides } from '../../lib/navOverrides.js'
@@ -36,6 +37,9 @@ function Icon({ children, size = 16 }) {
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon> const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon> const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon> const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
// The settings row's own icon: a bell would make the two rows read as the same
// destination twice, which is exactly the confusion the split was meant to end.
const IconBellGear = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h11" /><circle cx="18" cy="18" r="3" /><path d="M18 14v1M18 21v1M14 18h1M21 18h1" /></Icon>
// Exported because Admin -> Navigation edits this list. It stays declared here; // Exported because Admin -> Navigation edits this list. It stays declared here;
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row // the editor may only relabel, reorder and hide what it finds (§7). No CORE row
@@ -48,7 +52,8 @@ const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-
// with `order: 0`. // with `order: 0`.
export const NAV = [ export const NAV = [
{ to: '/account/appeals', label: 'Appeals', icon: IconShield }, { to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account/notifications', label: 'Notifications', icon: IconBell }, { to: '/account/notifications', label: 'Notifications', end: true, icon: IconBell },
{ to: '/account/notifications/settings', label: 'Notification settings', icon: IconBellGear },
{ to: '/account', label: 'Account', end: true, icon: IconGear }, { to: '/account', label: 'Account', end: true, icon: IconGear },
] ]
@@ -59,6 +64,7 @@ const TITLES = {
'/account': 'Account', '/account': 'Account',
'/account/appeals': 'Appeals', '/account/appeals': 'Appeals',
'/account/notifications': 'Notifications', '/account/notifications': 'Notifications',
'/account/notifications/settings': 'Notification settings',
} }
function moduleTitle(baseNav, pathname) { function moduleTitle(baseNav, pathname) {
@@ -186,9 +192,12 @@ export default function PlayerPortalLayout() {
<h1 className="display" style={{ margin: 0, fontSize: '1.5rem', color: 'var(--head)' }}> <h1 className="display" style={{ margin: 0, fontSize: '1.5rem', color: 'var(--head)' }}>
{title} {title}
</h1> </h1>
<a href="/" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.84rem', fontFamily: 'var(--sans)' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
Site <NotificationBell />
</a> <a href="/" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.84rem', fontFamily: 'var(--sans)' }}>
Site
</a>
</div>
</header> </header>
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: 900, width: '100%' }}> <div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: 900, width: '100%' }}>

View File

@@ -54,14 +54,14 @@ export default function Unsubscribe() {
<p className="sans dim" style={{ fontSize: '0.9rem' }}> <p className="sans dim" style={{ fontSize: '0.9rem' }}>
This muted the team rather than switching off your account&rsquo;s email, so your other This muted the team rather than switching off your account&rsquo;s email, so your other
teams are unaffected. You can turn it back on any time under{' '} teams are unaffected. You can turn it back on any time under{' '}
<Link to="/account/notifications">notification settings</Link>. <Link to="/account/notifications/settings">notification settings</Link>.
</p> </p>
</> </>
)} )}
{state === 'failed' && ( {state === 'failed' && (
<p className="sans" style={{ color: 'var(--ink)' }}> <p className="sans" style={{ color: 'var(--ink)' }}>
We could not reach the site to record that. Please try the link again, or change the We could not reach the site to record that. Please try the link again, or change the
setting yourself under <Link to="/account/notifications">notification settings</Link>. setting yourself under <Link to="/account/notifications/settings">notification settings</Link>.
</p> </p>
)} )}
</PublicLayout> </PublicLayout>

View File

@@ -0,0 +1,42 @@
// ── Where each account's notification screens live ─────────────────────────
//
// ENGAGEMENT.md Phase 7. Three assertions for a nine-line module, because the
// defect they pin was invisible to every other check: `/auth/me/notifications`
// is role-agnostic (behind `requireAuth` only, like the rest of `/auth/me`), so
// the server, the tests and the API all agreed a staff member had an inbox —
// and on the web they could not reach it, because `RequirePlayer` sends anyone
// who is not a player back out of `/account`. The bell pointed at a redirect.
//
// Found in the Phase 7 rig, signed in as an admin. What stops it coming back is
// this file plus the two admin routes it maps onto.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { isStaff, inboxPath, notificationSettingsPath } from '../src/lib/notificationPaths.js'
test('a player gets the portal paths', () => {
const user = { role: 'player' }
assert.equal(isStaff(user), false)
assert.equal(inboxPath(user), '/account/notifications')
assert.equal(notificationSettingsPath(user), '/account/notifications/settings')
})
test('every non-player role gets the admin paths, not just admin', () => {
for (const role of ['admin', 'editor', 'moderator']) {
const user = { role }
assert.equal(isStaff(user), true, role)
assert.equal(inboxPath(user), '/admin/notifications', role)
assert.equal(notificationSettingsPath(user), '/admin/notifications/settings', role)
}
})
// The bell renders nothing when signed out, so these are never asked for a null
// user in practice — but a default that guessed "staff" would send a signed-out
// visitor at the admin area the moment that changed.
test('no user, or a user with no role, falls back to the player paths', () => {
for (const user of [null, undefined, {}, { role: '' }]) {
assert.equal(isStaff(user), false)
assert.equal(inboxPath(user), '/account/notifications')
}
})

View File

@@ -1952,3 +1952,45 @@ CREATE TABLE IF NOT EXISTS engagement_digest_state (
INSERT IGNORE INTO engagement_digest_state (user_id, channel, scope_key, last_digest_at) INSERT IGNORE INTO engagement_digest_state (user_id, channel, scope_key, last_digest_at)
SELECT user_id, 'email', CONCAT('team:', team_id), last_digest_at SELECT user_id, 'email', CONCAT('team:', team_id), last_digest_at
FROM team_notification_prefs; FROM team_notification_prefs;
-- ── The in-app channel (ENGAGEMENT.md §4.5 G17 — Phase 7) ──────────────────
-- The inbox. Core, game-agnostic, and the first sink core owns that CARRIES its
-- content: a push tickle deliberately holds none and an email leaves the
-- building, so this is the one place a message both belongs to this deployment
-- and can be read without a mailbox.
--
-- `dedupe_key` is the acceptance criterion, expressed as an index rather than as
-- a check the writer has to remember: a replayed event, a retried outbox row and
-- a module calling `ctx.inbox.push` twice all reduce to the same INSERT IGNORE.
-- It is scoped to the USER (not to the rule and channel the outbox scopes by),
-- because one event may legitimately be two outbox rows for one person — a rule
-- spanning channels — and two inbox rows for it is one item shown twice.
-- Multiple NULLs are permitted by a UNIQUE index, which is what "this item does
-- not dedupe" means.
--
-- `url` is stored RELATIVE only, validated with the character class
-- `pageUrlTemplate` and the engine's `url` variables already use: it ends up in
-- an href on a page a signed-in user is looking at, and `//evil.test/x` passes
-- every "is it rooted" check anyone writes by hand.
CREATE TABLE IF NOT EXISTS user_notifications (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
trigger_id VARCHAR(96) NOT NULL,
title VARCHAR(300) NOT NULL,
body TEXT NULL, -- rendered by the inapp template, sanitized on write
url VARCHAR(500) NULL, -- relative only, validated like pageUrlTemplate
dedupe_key VARCHAR(190) NULL,
read_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_un_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uq_un_dedupe (user_id, dedupe_key),
-- Both of the two questions this table is asked: "what is in my inbox" (the
-- list, newest first) and "how many are unread" (the badge, on every page
-- load). A single index answers both because `read_at` is IS NULL in one and
-- unconstrained in the other, and `created_at` orders what is left.
INDEX idx_un_unread (user_id, read_at, created_at),
-- What the prune sweep queries. Without it the sweep is a table scan of every
-- notification this deployment has ever written.
INDEX idx_un_prune (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -1614,6 +1614,28 @@
"validate" "validate"
] ]
}, },
{
"method": "GET",
"path": "/api/v1/auth/me/notifications",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/auth/me/notifications/:id/read",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/auth/me/notifications/channels", "path": "/api/v1/auth/me/notifications/channels",
@@ -1634,6 +1656,15 @@
"validate" "validate"
] ]
}, },
{
"method": "POST",
"path": "/api/v1/auth/me/notifications/read-all",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/auth/me/notifications/streams", "path": "/api/v1/auth/me/notifications/streams",
@@ -1683,6 +1714,15 @@
"validate" "validate"
] ]
}, },
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/unread-count",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/auth/me/sessions", "path": "/api/v1/auth/me/sessions",

View File

@@ -649,6 +649,14 @@
"method": "DELETE", "method": "DELETE",
"path": "/api/v1/auth/me/devices/:id" "path": "/api/v1/auth/me/devices/:id"
}, },
{
"method": "GET",
"path": "/api/v1/auth/me/notifications"
},
{
"method": "POST",
"path": "/api/v1/auth/me/notifications/:id/read"
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/auth/me/notifications/channels" "path": "/api/v1/auth/me/notifications/channels"
@@ -657,6 +665,10 @@
"method": "PUT", "method": "PUT",
"path": "/api/v1/auth/me/notifications/channels" "path": "/api/v1/auth/me/notifications/channels"
}, },
{
"method": "POST",
"path": "/api/v1/auth/me/notifications/read-all"
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/auth/me/notifications/streams" "path": "/api/v1/auth/me/notifications/streams"
@@ -677,6 +689,10 @@
"method": "PUT", "method": "PUT",
"path": "/api/v1/auth/me/notifications/teams" "path": "/api/v1/auth/me/notifications/teams"
}, },
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/unread-count"
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/auth/me/sessions" "path": "/api/v1/auth/me/sessions"

View File

@@ -17,6 +17,8 @@
const { registerDeliveryChannel } = require('./channels') const { registerDeliveryChannel } = require('./channels')
const emailChannel = require('./emailChannel') const emailChannel = require('./emailChannel')
const pushChannel = require('./pushChannel')
const inappChannel = require('./inappChannel')
const CHANNELS = [ const CHANNELS = [
{ {
@@ -44,6 +46,10 @@ const CHANNELS = [
// there is nothing to roll up. Ten events are ten wakeups or one; either way // there is nothing to roll up. Ten events are ten wakeups or one; either way
// the app pulls the same inbox. // the app pulls the same inbox.
supportsDigest: false, supportsDigest: false,
// Phase 7: the oldest sink is the last to get a `deliver`, because until the
// inbox existed there was nothing for a content-free tickle to point at.
addressFor: pushChannel.addressFor,
deliver: pushChannel.deliver,
}, },
{ {
id: 'email', id: 'email',
@@ -66,13 +72,25 @@ const CHANNELS = [
label: 'On the site', label: 'On the site',
description: 'An item in your notification inbox on the website and in the app.', description: 'An item in your notification inbox on the website and in the app.',
carriesContent: true, carriesContent: true,
// Opt-IN like the other two, and for a reason particular to this channel: the // **Opt-OUT, and the only one of the three that is** — settled by the org
// inbox does not exist until Phase 7. A default of 'instant' would mean every // lead 2026-08-31, which is the Phase 7 decision this comment used to defer.
// user is opted into a surface that has no rows and no screen, and the first //
// thing Phase 7 shipped would be a backlog. Whether the inbox is opt-out once // The argument against a live default was never about in-app: it was that
// it is real is a Phase 7 decision with a live surface to look at. // push wakes a device the user is holding and email leaves the building, so
defaultMode: 'off', // both must be asked for. An inbox item does neither. It is a row on a page
// the user chose to open, on this deployment, costing them one glance — and
// left at 'off' the surface would ship dead, because no rule could reach
// anyone until every user found a toggle for a channel they had never seen
// deliver anything. The backlog Phase 3 worried about cannot happen either:
// the table is empty at cutover, rules default to `enabled = 0`, and every
// rule carries a per-hour ceiling.
defaultMode: 'instant',
// Instant-only, and unlike push the reason is not that batching is
// meaningless — it is that the inbox IS the batch. A digest of inbox items
// is a list of things already sitting in a list.
supportsDigest: false, supportsDigest: false,
addressFor: inappChannel.addressFor,
deliver: inappChannel.deliver,
}, },
] ]

View File

@@ -42,6 +42,22 @@ const log = require('../utils/logger')('engagement')
const HOUR_MS = 60 * 60 * 1000 const HOUR_MS = 60 * 60 * 1000
// Channels one of whose payloads can REFERENCE another's result, earliest first
// (Phase 7). Only one pair qualifies today: a push tickle's `ref` deep-links to
// the inbox row `inapp` writes, and the outbox is swept `ORDER BY due_at, id`,
// so enqueueing in-app first is what makes that ref resolve on the first pass
// rather than on a retry. Everything not named here keeps the operator's own
// order, which is the order the rules screen shows.
//
// It is an ordering, not a dependency: `pushChannel` treats a missing ref as
// null and the app pulls regardless, so a rule that names only push, or a row
// that gets retried out of sequence, is still correct.
const CHANNEL_ORDER = ['inapp']
const channelRank = (id) => {
const i = CHANNEL_ORDER.indexOf(id)
return i === -1 ? CHANNEL_ORDER.length : i
}
/** /**
* Which of a rule's channels are actually deliverable right now? * Which of a rule's channels are actually deliverable right now?
* *
@@ -50,7 +66,10 @@ const HOUR_MS = 60 * 60 * 1000
* failing the rule: the other channels of that rule are still correct, and a * failing the rule: the other channels of that rule are still correct, and a
* dropped one is visible in the log line below. * dropped one is visible in the log line below.
*/ */
const liveChannels = (rule) => (rule.channels || []).filter((c) => channels.has(c)) const liveChannels = (rule) =>
(rule.channels || [])
.filter((c) => channels.has(c))
.sort((a, b) => channelRank(a) - channelRank(b))
/** /**
* The EFFECTIVE mode each candidate holds for (id, channel), given the event's * The EFFECTIVE mode each candidate holds for (id, channel), given the event's
@@ -267,4 +286,13 @@ async function dispatch(event, now = new Date()) {
return summary return summary
} }
module.exports = { dispatch, applyRule, applyCancellations, subscribedTo, effectiveModes, liveChannels, HOUR_MS } module.exports = {
dispatch,
applyRule,
applyCancellations,
subscribedTo,
effectiveModes,
liveChannels,
CHANNEL_ORDER,
HOUR_MS,
}

View File

@@ -0,0 +1,204 @@
// ── The in-app DeliveryChannel: addressFor + deliver ───────────────────────
//
// ENGAGEMENT.md Phase 7. The third channel to get behaviour, and the one whose
// "address" is not an address at all: the destination is the user's own row in
// this deployment's own table. `addressFor` still exists and still answers null,
// because the question it asks — *can this channel reach this user right now* —
// has a real answer here, and it is the same answer email's has: not if the
// account is no longer active. An outbox row can sit through a `delay_seconds`
// grace window, so a user banned between the emit and the send is exactly the
// case this catches.
//
// **What makes it different from email is what it does NOT have to do.** There
// is no transport, no relay to classify a failure for us, no unsubscribe link to
// mint per recipient, and no address to hash — an inbox item is addressed to a
// user id, and `engagement_sends.address_hash` exists to correlate a bounce that
// this channel cannot have. So `deliver` is two steps: render the template into
// the three columns, and insert.
//
// **It never throws**, for the reason `emailChannel` states: the worker reads a
// throw as a transient failure and retries five times, so an unrenderable
// template would become five identical failures in the send log instead of one
// honest terminal row.
//
// **A duplicate `dedupe_key` reports success.** The acceptance line calls it a
// no-op; from the recipient's side it is a delivery — they have the item — and
// recording `failed` for it would put a red row in the send log for the
// mechanism working exactly as designed. The detail says which it was.
const rulesDb = require('../model/engagement/engagementRules.db')
const registries = require('../modules/registries')
const channelRegistry = require('./channels')
const inbox = require('../model/userNotifications/userNotifications.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const templates = require('./templates')
const projection = require('./projection')
const log = require('../utils/logger')('engagement')
// The template a rule renders through when it names none — §4.6.1 property 1's
// implementation for this channel, exactly as `notify.event` is for email.
const DEFAULT_TEMPLATE = 'inapp.event'
/**
* Can this channel reach `userId`?
*
* Returns the shape every `addressFor` returns rather than a boolean, so the
* registry's contract stays one contract. The "address" is the user id as a
* string, which is the honest answer: this channel's destination is an account,
* and there is nothing else to name.
*/
const addressFor = async (userId) => {
const active = await recipients.filterActive([userId])
return active.length ? { address: String(active[0]) } : null
}
/**
* Render one event into an inbox item. Shared with `ctx.inbox.push`'s rule-less
* path only in spirit — that one is handed its title and body by the module and
* renders nothing.
*/
async function renderItem(triggerId, payload, templateKey) {
const values = projection.project(triggerId, payload || {})
const rendered = await templates.renderInappByKey(templateKey, values)
if (!rendered) return null
if (rendered.missing.length) {
// Names only, never values — the rule every log line in this subsystem
// follows. An optional variable a trigger chose not to supply renders as
// nothing by design, so this is debug rather than a warning.
log.debug('template variables had no value', { key: templateKey, missing: rendered.missing })
}
return rendered
}
/**
* Deliver one claimed outbox row.
*
* @returns {Promise<{ok: boolean, retry?: boolean, detail?: string}>}
*/
async function deliver(row) {
try {
if (!(await addressFor(row.user_id))) {
// Terminal. A five-minute backoff does not un-ban an account, and writing
// the item anyway would put content in the inbox of somebody who is no
// longer allowed to open it.
return { ok: false, detail: 'this user can no longer be reached' }
}
const rule = await rulesDb.getById(row.rule_id)
const key = (rule && rule.template_keys && rule.template_keys.inapp) || DEFAULT_TEMPLATE
const rendered = await renderItem(row.trigger_id, row.payload, key)
if (!rendered) {
// Neither a usable row nor a shipped seed: the operator deleted a template
// a rule points at, which the admin surface refuses with a 409, so reaching
// here means it happened out of band. Terminal, and it names the key.
return { ok: false, detail: `no template and no shipped default for "${key}"` }
}
const { inserted } = await inbox.insert({
userId: row.user_id,
triggerId: row.trigger_id,
title: rendered.title,
body: rendered.body,
url: rendered.url,
dedupeKey: row.dedupe_key || null,
})
// `transport` is left absent rather than invented. The column means "which
// implementation of this channel delivered it", and this channel has one
// sink by construction — a value there would be a name nothing else uses.
return inserted
? { ok: true }
: { ok: true, detail: 'already in this inbox (duplicate dedupe key)' }
} catch (err) {
log.error('in-app delivery failed', { outbox: row.id, message: err.message })
return { ok: false, detail: `delivery error: ${err.message}` }
}
}
// ── The rule-less sink: ctx.inbox.push (§5.1) ──────────────────────────────
//
// A module writing the inbox directly, with no trigger declaration to project
// from, no rule to pick a template, and no audience to resolve. It exists for
// the cases a rule cannot express — something that concerns exactly one person
// and needs no operator configuration to be worth telling them about.
//
// **It respects the user's in-app preference where there is one to respect**
// (settled by the org lead 2026-08-31). If `triggerId` names a REGISTERED
// trigger, the user's effective mode for it decides, and 'off' drops the write:
// a toggle somebody switched off on the preferences screen must not be walkable
// around by the module that owns the trigger behind it. If it names nothing
// registered there is no toggle, nothing on any screen to have switched off, and
// the item is written — refusing it would make the sink useless for the one job
// it has while protecting a preference that does not exist.
//
// Scoped preferences are deliberately not consulted: a scope is a property of an
// EVENT (`team:12`), and a caller with no trigger declaration has no scope to
// name. The engine's path, which does, still applies them.
//
// Fire-and-forget, never throws, never rejects — `ctx.teams.activity.push`'s
// posture, for its reason: this is called from inside a game-event handler and a
// storage problem of core's must not become the module's control flow.
// user_notifications.title. Truncated rather than refused: a module that built a
// long title has still said something worth showing.
const MAX_TITLE = 300
// user_notifications.body is TEXT; this is a sanity bound, not the column's.
const MAX_BODY = 4000
/**
* Write one item on a module's behalf.
*
* @param {string} moduleId bound by the loader, never taken from the arguments
* @param {number} userId
* @param {{triggerId: string, title: string, body?: string, url?: string, dedupeKey?: string}} item
* @returns {Promise<{written: boolean, reason?: string}>} for tests; the loader
* discards it, because a module has nothing correct to do with it.
*/
async function pushDirect(moduleId, userId, item = {}) {
try {
const uid = Number(userId)
if (!Number.isInteger(uid) || uid <= 0) return { written: false, reason: 'invalid user id' }
const triggerId = String(item.triggerId || '').trim()
const title = String(item.title || '').trim().slice(0, MAX_TITLE)
if (!triggerId || !title) return { written: false, reason: 'triggerId and title are required' }
// The declaration is consulted for ONE thing — whether a preference for this
// id exists — and not to validate a payload: there is no payload here, only
// the three strings the module composed itself.
if (registries.eventTrigger(triggerId)) {
const stored = await recipients.storedModes([uid], triggerId, 'inapp')
const mode = stored.get(uid) ?? channelRegistry.defaultMode('inapp')
if (mode !== 'instant') return { written: false, reason: 'the user has this switched off' }
}
if (!(await addressFor(uid))) return { written: false, reason: 'this user can no longer be reached' }
// Same relative-only rule the rendered path applies, and for the same reason:
// this string ends up in an href on a page a signed-in user is looking at.
const url = item.url ? templates.relativeUrl(item.url, templates.baseUrl()) : null
if (item.url && !url) {
log.warn('ctx.inbox.push dropped an off-site url', { module: moduleId, trigger: triggerId })
}
const body = item.body ? String(item.body).slice(0, MAX_BODY) : null
const { inserted } = await inbox.insert({
userId: uid,
triggerId,
title,
// A module supplies data, never markup (§4.6.2's security posture). The
// body is stored as the text it claims to be and every surface renders it
// as text, so there is no markup to sanitize and none to be trusted.
body,
url,
dedupeKey: item.dedupeKey ? String(item.dedupeKey).slice(0, 190) : null,
})
return { written: inserted, reason: inserted ? undefined : 'duplicate dedupe key' }
} catch (err) {
log.error('ctx.inbox.push failed', { module: moduleId, message: err.message })
return { written: false, reason: err.message }
}
}
module.exports = { addressFor, deliver, renderItem, pushDirect, DEFAULT_TEMPLATE }

View File

@@ -0,0 +1,91 @@
// ── The push DeliveryChannel: addressFor + deliver ─────────────────────────
//
// ENGAGEMENT.md Phase 7. Push is the channel that has existed longest and had a
// `deliver` last, because until this phase there was nothing for a tickle to
// point AT: `{ stream, ref }` carries no content by design, so a rule firing on
// push before the inbox existed would have woken a phone to pull a screen that
// had nothing on it.
//
// **The tickle invariant is the whole of this file's security posture.** What
// leaves the server is the stream id and an opaque ref, never a title, never a
// body, never the payload — `carriesContent: false` on the registration is the
// declaration and this is the implementation. ntfy is treated as an untrusted
// relay, so a leaked topic must reveal nothing but that *something* happened;
// the app then pulls the real item over the authenticated, ownership-checked
// inbox API. Every claim in that paragraph is one `pushDispatch` already makes,
// which is why delivery here is a call into it rather than a second publisher.
//
// **`ref` points at the inbox row when there is one, and is null otherwise.**
// A rule spanning `inapp` and `push` enqueues both, and `liveChannels` orders
// `inapp` first precisely so the row exists by the time this runs — but that is
// an optimisation, not a guarantee: the two rows are independent, either can be
// retried, and a push-only rule has no inbox row at all. So the ref is a HINT.
// The app's contract (docs/android/PLAN.md §11, Phase 8) is wake-and-pull; a
// client that renders the ref instead of pulling is a client that will show
// nothing the first time a retry reorders these two rows.
const inbox = require('../model/userNotifications/userNotifications.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const pushDispatch = require('../utils/pushDispatch')
const log = require('../utils/logger')('engagement')
/**
* Can this channel reach `userId`?
*
* Active account only, the same re-check `emailChannel` and `inappChannel` make
* for the same reason (a row can sit through a `delay_seconds` window). It does
* NOT check for a registered device: whether any endpoint is subscribed is the
* question `publishToUsers` answers in its own query, and asking it twice would
* mean two different definitions of "reachable" that could disagree.
*/
const addressFor = async (userId) => {
const active = await recipients.filterActive([userId])
return active.length ? { address: String(active[0]) } : null
}
/**
* Deliver one claimed outbox row.
*
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
*/
async function deliver(row) {
try {
if (!(await addressFor(row.user_id))) {
return { ok: false, detail: 'this user can no longer be reached' }
}
// Best effort, and it fails to null rather than to an error: no dedupe key,
// no in-app row for it, or an inapp row this rule never enqueued all mean
// the same thing to the app — wake up and pull.
let ref = null
try {
const item = await inbox.findByDedupe(row.user_id, row.dedupe_key)
if (item) ref = `notification:${item.id}`
} catch (err) {
log.debug('could not resolve a push ref', { outbox: row.id, message: err.message })
}
// The stream id IS the trigger id — §7.2's one namespace, settled in Phase 2.
// A push stream and an event trigger share a name space, so the app's
// existing `{ stream }` switch keeps working for an engagement rule without
// learning a second vocabulary.
await pushDispatch.publishToUsers(row.trigger_id, { ref, userIds: [row.user_id] })
// **Success here means "handed to the relay", and the send log must not
// claim more than that.** `publishToUsers` resolves whether it found a
// subscribed device or none at all, and a tickle is fire-and-forget over
// HTTP to a relay that owes us no receipt. Retrying on "we are not sure"
// would mean five wakeups for one event, which is worse than one uncertain
// log line — so this is the one channel whose 'sent' is weaker than email's,
// and saying so in the detail is how an operator reading G15 finds that out.
return { ok: true, transport: 'unifiedpush', detail: 'tickle published' }
} catch (err) {
// pushDispatch never throws, so reaching here is a programming error rather
// than a relay being down. Terminal for that reason: retrying a bug is five
// identical rows in the send log.
log.error('push delivery failed', { outbox: row.id, message: err.message })
return { ok: false, detail: `delivery error: ${err.message}` }
}
}
module.exports = { addressFor, deliver }

View File

@@ -260,19 +260,33 @@ const SEEDS = [
name: 'On-site notification', name: 'On-site notification',
channel: 'inapp', channel: 'inapp',
protected: false, protected: false,
seedVersion: 1, // **seedVersion 2, and the bump is a correction rather than an improvement.**
// Phase 5a wrote this template before the channel that renders it existed, and
// named its variables `body` and `url` — names NOTHING supplies. A trigger
// declares domain names (`teamName`, `threadTitle`), and `projection.project`
// fills the gaps with the STRUCTURAL ones the generic seeds use: `title`,
// `intro`, `actionUrl`. So every rendering of this template would have found
// `body` and `url` missing and produced a title and nothing else. Renamed to
// the vocabulary `notify.event` uses, which is the same property stated once:
// a new trigger must render with no authoring at all.
seedVersion: 2,
// No subject: an inbox row has a title, and the title is a block. The column // No subject: an inbox row has a title, and the title is a block. The column
// is email's, and leaving it NULL is how a non-email template says so. // is email's, and leaving it NULL is how a non-email template says so.
subject: null, subject: null,
variables: [ variables: [
{ name: 'title', type: 'string', required: true, example: 'Your house is close to collapsing' }, { name: 'title', type: 'string', required: true, example: 'Your house is close to collapsing' },
{ name: 'body', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' }, { name: 'intro', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
{ name: 'url', type: 'string', required: false, example: 'https://example.com/houses' }, { name: 'actionUrl', type: 'string', required: false, example: '/player/uo/houses' },
], ],
// The three blocks map onto the three columns of `user_notifications` by ROLE
// (templates.js `renderInappByKey`): the heading is the item's title, the
// button is its one action, and everything else is the body. There is no
// unsubscribe line — an inbox item has nowhere to send someone that the
// preferences screen it links to from does not already reach.
blocks: [ blocks: [
heading('h', '{{title}}', 'h3'), heading('h', '{{title}}', 'h3'),
text('body', '{{body}}'), text('intro', '{{intro}}'),
button('cta', 'Open', '{{url}}'), button('cta', 'Open', '{{actionUrl}}'),
], ],
}, },
] ]

View File

@@ -123,13 +123,17 @@ function renderTemplate(template, values, resolved) {
} }
/** /**
* Render the template stored under `key`, falling back to its shipped default. * The template `key` should actually render through, or null.
* @returns {Promise<{subject: string, html: string, text: string, missing: string[]}|null>} *
* null when `key` names no usable row AND no seed — which now includes a * Extracted from `renderByKey` in Phase 7 rather than duplicated into the in-app
* duplicated (seedless) template still in draft. * channel: the fallback chain below is a policy about what this deployment sends
* when its own table is in a bad state, and a second channel resolving templates
* by its own rules would be a second answer to that. `renderInappByKey` takes the
* same rows, the same seeds and the same three refusals.
*
* @returns {Promise<{subject: string|null, blocks: object[], text_body: string|null}|null>}
*/ */
async function renderByKey(key, values = {}) { async function resolveTemplate(key) {
const resolved = await ambient()
let template = null let template = null
try { try {
template = await templatesDb.getByKey(key) template = await templatesDb.getByKey(key)
@@ -158,9 +162,113 @@ async function renderByKey(key, values = {}) {
if (unusable === 'unpublished') log.warn('stored template is a draft; using the shipped default', { key }) if (unusable === 'unpublished') log.warn('stored template is a draft; using the shipped default', { key })
template = { subject: seed.subject, blocks: seed.blocks, text_body: null } template = { subject: seed.subject, blocks: seed.blocks, text_body: null }
} }
return template
}
/**
* Render the template stored under `key`, falling back to its shipped default.
* @returns {Promise<{subject: string, html: string, text: string, missing: string[]}|null>}
* null when `key` names no usable row AND no seed — which now includes a
* duplicated (seedless) template still in draft.
*/
async function renderByKey(key, values = {}) {
const resolved = await ambient()
const template = await resolveTemplate(key)
if (!template) return null
return renderTemplate(template, values, resolved) return renderTemplate(template, values, resolved)
} }
// ── The in-app projection (Phase 7) ────────────────────────────────────────
//
// `user_notifications` has three columns — title, body, url — where email has a
// subject and a document, so the in-app channel needs the template rendered into
// those three rather than into a mail. **The mapping is by block ROLE**, and it
// is here rather than in the channel because it is a statement about what the
// block registry means, not about how a row gets written:
//
// - the first `email.heading` → `title` (a heading IS the item's headline)
// - the first `email.button` → `url` (a button IS the item's one action)
// - everything else, as TEXT → `body`
//
// **Text, not the email HTML, and that is the load-bearing choice.** The block
// renderer's HTML is built for mail clients: table rows, inline hex colours, a
// light-only palette declared with `color-scheme`. Dropped into a page that
// follows the viewer's theme it renders as a pale card floating in a dark one.
// `toText` is the same content with none of that, and it is the part the block
// contract already promises every block can produce.
//
// The three refusals a mail can afford and an inbox row cannot are handled here
// too: a title is NOT NULL, so an empty one falls back to the projected `title`
// and then to the trigger id; and a url that is not site-relative is dropped
// rather than stored, because the column's whole contract is that a template
// cannot aim a signed-in user's click off-site.
const HEADING = 'email.heading'
const BUTTON = 'email.button'
// user_notifications.title / .url. Truncated rather than refused: a long title is
// a cosmetic problem and a dropped notification is not.
const MAX_TITLE = 300
const MAX_URL = 500
// The same character class `pageUrlTemplate` and the engine's `url` variables
// use (registries.js, engagementEmit.js). Duplicated as a literal rather than
// imported from `engagementEmit`, which would be a cycle through the engine.
const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/
/**
* Site-relative form of `raw`, or null.
*
* An absolute url on this deployment's own base is accepted and reduced — a
* template that writes `{{siteUrl}}/guilds/4` is saying the same thing as
* `/guilds/4`, and refusing it would make the ambient `siteUrl` variable a trap
* in the one channel where the link never leaves the site.
*/
function relativeUrl(raw, base) {
const value = String(raw || '').trim()
if (!value) return null
const stripped = base && value.startsWith(`${base}/`) ? value.slice(base.length) : value
if (!RELATIVE_URL.test(stripped)) return null
return stripped.slice(0, MAX_URL)
}
/**
* Render one template into an inbox item.
*
* @returns {Promise<{title: string, body: string|null, url: string|null, missing: string[]}|null>}
* null when `key` names no usable row and no seed — the caller reports a
* terminal failure, exactly as the email channel does.
*/
async function renderInappByKey(key, values = {}) {
const resolved = await ambient()
const template = await resolveTemplate(key)
if (!template) return null
const merged = { ...values, ...resolved.values }
const missing = new Set()
const ctx = emailBlocks.buildContext({
values: merged,
theme: resolved.theme,
baseUrl: resolved.baseUrl,
missing,
})
const blocks = Array.isArray(template.blocks) ? template.blocks : []
const visible = blocks.filter((b) => b && b.visible !== false)
const heading = visible.find((b) => b.type === HEADING)
const button = visible.find((b) => b.type === BUTTON)
// Only the FIRST of each is consumed; a second heading or button is ordinary
// body content, which is what an operator who added one meant.
const rest = visible.filter((b) => b !== heading && b !== button)
const headingText = heading ? ctx.t((heading.props || {}).text || '').trim() : ''
const title = (headingText || String(merged.title || '').trim() || key).slice(0, MAX_TITLE)
const url = button ? relativeUrl(ctx.t((button.props || {}).url || ''), resolved.baseUrl) : null
const body = emailBlocks.renderBlocks(rest, ctx).text.trim()
return { title, body: body || null, url, missing: [...missing] }
}
/** /**
* Ensure every shipped template exists, and bring un-customized rows up to the * Ensure every shipped template exists, and bring un-customized rows up to the
* current seed. Idempotent: a second run reports nine skips and writes nothing. * current seed. Idempotent: a second run reports nine skips and writes nothing.
@@ -215,4 +323,16 @@ async function seedTemplates() {
const KEY_RE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/ const KEY_RE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
const MAX_KEY = 96 const MAX_KEY = 96
module.exports = { ambient, variablesFor, renderTemplate, renderByKey, seedTemplates, baseUrl, KEY_RE, MAX_KEY } module.exports = {
ambient,
variablesFor,
renderTemplate,
resolveTemplate,
renderByKey,
renderInappByKey,
relativeUrl,
seedTemplates,
baseUrl,
KEY_RE,
MAX_KEY,
}

View File

@@ -0,0 +1,189 @@
// ── The in-app inbox: SQL ──────────────────────────────────────────────────
//
// ENGAGEMENT.md §4.5 (G17), Phase 7. `user_notifications` is a small table with
// one unusual property worth stating up front: **every read here is scoped by
// `user_id`, and none of them takes an id alone.**
//
// That is not belt-and-braces over the route's own auth check. A notification is
// the only content core stores that is addressed to exactly one person, so
// "mark 41 read" is a request whose whole meaning is which account is asking.
// Passing the caller down to the WHERE clause makes the ownership check part of
// the statement that does the work, rather than a separate question asked
// earlier and trusted afterwards — an `UPDATE … WHERE id = ? AND user_id = ?`
// that matches nothing is a 404, and there is no ordering in which it is not.
// Phase 7's acceptance line asks for that assertion at the ROUTE; this is what
// makes the route's answer true rather than merely tested.
const { query } = require('../../utils/db')
// The page size a client gets when it asks for none, and the largest it may ask
// for. An inbox is read newest-first and nobody scrolls to row 500; the cap is
// what stops `?limit=100000` from being a way to make the server assemble the
// whole table.
const DEFAULT_LIMIT = 30
const MAX_LIMIT = 100
const num = (n) => (Number.isFinite(Number(n)) ? Number(n) : 0)
/** Shape one row for the API. `read` as a boolean beside the stamp: a client
* renders the flag and shows the stamp, and neither has to parse the other. */
const toItem = (row) => ({
id: num(row.id),
triggerId: row.trigger_id,
title: row.title,
body: row.body || null,
url: row.url || null,
read: row.read_at != null,
readAt: row.read_at || null,
createdAt: row.created_at,
})
/**
* Write one item, ignoring a duplicate `dedupe_key`.
*
* @returns {Promise<{inserted: boolean, id: number|null}>}
*
* `INSERT IGNORE` rather than a SELECT-then-INSERT, because the two callers race
* by construction: the outbox worker can be mid-retry while a module calls
* `ctx.inbox.push` for the same event. IGNORE also swallows an FK failure on a
* deleted user, which is the right outcome for the same reason — a row addressed
* to an account that no longer exists is not a failure anybody can act on.
*
* `inserted: false` is the dedupe path and the caller reports success: the user
* has the item, which is what "delivered" means. Distinguishing them at all is
* for the send log, which is entitled to say the second one was a duplicate.
*/
const insert = async ({ userId, triggerId, title, body = null, url = null, dedupeKey = null }) => {
const res = await query(
`INSERT IGNORE INTO user_notifications (user_id, trigger_id, title, body, url, dedupe_key)
VALUES (?, ?, ?, ?, ?, ?)`,
[Number(userId), String(triggerId), String(title), body, url, dedupeKey],
)
const inserted = num(res && res.affectedRows) > 0
return { inserted, id: inserted ? num(res.insertId) : null }
}
/**
* One page of a user's inbox, newest first.
*
* @param {number} userId
* @param {{limit?: number, before?: number, unreadOnly?: boolean}} [opts]
* `before` is a keyset cursor (an id), not an offset. An inbox gains rows
* at the top while it is being paged; OFFSET under those conditions skips
* or repeats items, and the id is already the ordering key.
*/
const list = async (userId, { limit, before, unreadOnly } = {}) => {
const take = Math.min(Math.max(Number(limit) || DEFAULT_LIMIT, 1), MAX_LIMIT)
const params = [Number(userId)]
let where = 'user_id = ?'
if (unreadOnly) where += ' AND read_at IS NULL'
if (Number(before) > 0) {
where += ' AND id < ?'
params.push(Number(before))
}
// take + 1 so the caller can say whether there is another page without a
// second COUNT over the same predicate.
const rows = await query(
`SELECT id, trigger_id, title, body, url, read_at, created_at
FROM user_notifications
WHERE ${where}
ORDER BY id DESC
LIMIT ?`,
[...params, take + 1],
)
const hasMore = rows.length > take
return { items: rows.slice(0, take).map(toItem), hasMore }
}
/**
* The item written for one (user, dedupe key), or null.
*
* The push channel's `ref` lookup and nothing else. A NULL dedupe key is not a
* wildcard — it means "this item does not dedupe", and matching on it would
* return an arbitrary earlier notification.
*/
const findByDedupe = async (userId, dedupeKey) => {
if (!dedupeKey) return null
const rows = await query(
`SELECT id, trigger_id, title, body, url, read_at, created_at
FROM user_notifications WHERE user_id = ? AND dedupe_key = ? LIMIT 1`,
[Number(userId), String(dedupeKey)],
)
return rows.length ? toItem(rows[0]) : null
}
/** How many of this user's items are unread. The badge. */
const unreadCount = async (userId) => {
const rows = await query(
'SELECT COUNT(*) AS n FROM user_notifications WHERE user_id = ? AND read_at IS NULL',
[Number(userId)],
)
return num(rows[0] && rows[0].n)
}
/**
* Mark one item read. Idempotent, and scoped to its owner.
*
* `read_at IS NULL` in the predicate is what makes a second call a no-op rather
* than a re-stamp: the acceptance line says mark-read is idempotent, and a
* timestamp that moves every time somebody re-opens the page is not.
*
* @returns {Promise<boolean>} whether the row exists FOR THIS USER — false is a
* 404 whether the id belongs to nobody or to somebody else, which is
* also the only answer that does not report other people's row ids.
*/
const markRead = async (userId, id) => {
await query(
'UPDATE user_notifications SET read_at = NOW() WHERE id = ? AND user_id = ? AND read_at IS NULL',
[Number(id), Number(userId)],
)
const rows = await query('SELECT id FROM user_notifications WHERE id = ? AND user_id = ?', [
Number(id),
Number(userId),
])
return rows.length > 0
}
/** Mark everything read. @returns {Promise<number>} how many changed. */
const markAllRead = async (userId) => {
const res = await query(
'UPDATE user_notifications SET read_at = NOW() WHERE user_id = ? AND read_at IS NULL',
[Number(userId)],
)
return num(res && res.affectedRows)
}
/**
* Drop items older than `days`.
*
* **Read rows only.** An unread item is one the user has not seen, and an inbox
* that quietly deletes those is worse than one that grows: the whole point of
* the badge is that something is waiting. Age alone would also delete the
* evidence for "I was never told", which is the complaint this table answers.
* A never-read backlog is bounded in practice by the per-rule hourly ceiling.
*
* `LIMIT` per call so one sweep after a long outage is a bounded statement
* rather than a delete of a million rows holding locks; the sweep runs again.
*/
const pruneRead = async (days, limit = 1000) => {
const res = await query(
`DELETE FROM user_notifications
WHERE read_at IS NOT NULL AND created_at < (NOW() - INTERVAL ? DAY)
LIMIT ?`,
[Number(days), Number(limit)],
)
return num(res && res.affectedRows)
}
module.exports = {
insert,
list,
findByDedupe,
unreadCount,
markRead,
markAllRead,
pruneRead,
toItem,
DEFAULT_LIMIT,
MAX_LIMIT,
}

View File

@@ -122,6 +122,7 @@ function buildCtx(id, moduleRoot) {
const teams = require('../model/teams/teamSync.model') const teams = require('../model/teams/teamSync.model')
const teamActivity = require('../model/teams/teamActivity.model') const teamActivity = require('../model/teams/teamActivity.model')
const engagementEmit = require('../utils/engagementEmit') const engagementEmit = require('../utils/engagementEmit')
const inappChannel = require('../engagement/inappChannel')
const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit') const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
/* eslint-enable global-require */ /* eslint-enable global-require */
@@ -229,17 +230,26 @@ function buildCtx(id, moduleRoot) {
}, },
}, },
// The in-app sink (§5.1) — a module writing the inbox directly, without a // The in-app sink (§5.1) — a module writing the inbox directly, without a
// rule. It is PRESENT AND THROWS until Phase 7 builds the channel and the // rule. Live from Phase 7; it threw until the `user_notifications` table
// `user_notifications` table behind it. // behind it existed.
// //
// Present-and-throwing rather than absent is the shape 1.6.0 settled on for // Fire-and-forget and returns undefined, like `events.emit` above and
// exactly this situation (`ctx.teams.activity.push` before its phase landed): // `teams.activity.push` before it, and for the same reason: a module calls
// the version number states a whole surface, so a member of 1.7.0 that is // this from inside a game-event handler, and there is nothing it could
// missing would make the version a lie, and one that silently accepted data // correctly do with a storage failure of core's. The decision the sink makes
// into a table that does not exist would be the worst of the three. // that a module might want to know about — the user has this switched off —
// is deliberately not reported either, because a module that could see it
// would be a module that could enumerate people's preferences one write at a
// time.
//
// `id` is bound here and never taken from the arguments, exactly as `emit`
// and `teams.activity.push` bind theirs.
inbox: { inbox: {
push: () => { push: (userId, item) => {
throw new Error('ctx.inbox.push is not available until the in-app channel lands (ENGAGEMENT.md Phase 7)') inappChannel.pushDirect(id, userId, item).then(
(result) => { void result },
(err) => { log.error('ctx.inbox.push failed', { module: id, message: err.message }) },
)
}, },
}, },
// One function, for one caller: the `admin.users.detail` slot router needs // One function, for one caller: the `admin.users.detail` slot router needs

View File

@@ -14,12 +14,15 @@
// `api.registerAudiences([...])`, `ctx.events.emit(triggerId, envelope)` and // `api.registerAudiences([...])`, `ctx.events.emit(triggerId, envelope)` and
// `ctx.inbox.push(userId, item)`. module-uo's `coreApi: "^1.3.0"` still resolves. // `ctx.inbox.push(userId, item)`. module-uo's `coreApi: "^1.3.0"` still resolves.
// //
// **As in 1.6.0, the number covers the whole surface and the members arrive by // **As in 1.6.0, the number covered the whole surface and the members arrived by
// phase.** `ctx.inbox.push` is present and THROWS until Phase 7 builds the // phase, and all of them have now arrived.** `ctx.inbox.push` threw until Phase 7
// in-app channel and the table behind it — the same choice, for the same reason: // built the in-app channel and the table behind it — the same choice, for the
// a member of 1.7.0 that were absent would make the version a lie, and one that // same reason: a member of 1.7.0 that were absent would have made the version a
// silently accepted data into a table that does not exist would be worse than // lie, and one that silently accepted data into a table that did not exist would
// either. Everything else in 1.7.0 is live. // have been worse than either. **Filling it in is NOT a bump**: the signature is
// the one 1.7.0 declared, and a module written against it needs no change. What a
// module WILL see differently is the throw becoming a write, which is the whole
// point of the phase.
// //
// One thing here is not a member and is still part of the contract: a trigger id // One thing here is not a member and is still part of the contract: a trigger id
// and a notification-stream id share ONE namespace (ENGAGEMENT.md §7.2, settled // and a notification-stream id share ONE namespace (ENGAGEMENT.md §7.2, settled

View File

@@ -9,6 +9,7 @@ const channelPrefs = require('../../../model/notificationChannelPrefs/notificati
const registries = require('../../../modules/registries') const registries = require('../../../modules/registries')
const teamPrefs = require('../../../model/teams/teamNotify.model') const teamPrefs = require('../../../model/teams/teamNotify.model')
const { isAllowedEndpoint } = require('../../../utils/pushDispatch') const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
const inbox = require('../../../model/userNotifications/userNotifications.db')
const log = require('../../../utils/logger')('notifications') const log = require('../../../utils/logger')('notifications')
@@ -146,6 +147,80 @@ async function putTeamPrefs(req, res) {
} }
} }
// ── The inbox (ENGAGEMENT.md §4.5 G17, Phase 7) ────────────────────────────
//
// The in-app channel's read side. Everything above this line is a PREFERENCE —
// which streams, which channels, which Teams — and everything below it is
// CONTENT addressed to the caller. They share a path prefix because a person
// calls both "notifications", and the shapes keep them apart: the preference
// endpoints are whole-set GET/PUT pairs on named sub-paths, the inbox is a
// paged GET on the bare path with POSTs that name a row.
//
// **`req.user.id` is the only user id any of these can name.** There is no route
// parameter for a user and no query string that selects one, so the ownership
// check is not something a caller can be forgetful about — it is the shape of
// the API. The model then repeats it in the WHERE clause of every statement, so
// "read someone else's notification" is a 404 twice over.
// GET /auth/me/notifications — one page of the caller's inbox, newest first.
async function getInbox(req, res) {
try {
const page = await inbox.list(req.user.id, {
limit: req.query.limit,
before: req.query.before,
unreadOnly: req.query.unread === 'true' || req.query.unread === '1',
})
// The unread count rides along on every page, so the bell and the list never
// disagree: a client that renders both from one response cannot show "3
// unread" above a list in which the third was just marked read.
return res.json({ ...page, unread: await inbox.unreadCount(req.user.id) })
} catch (err) {
log.error('getInbox', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /auth/me/notifications/unread-count — the badge, on its own.
//
// Its own route rather than a field of the list, because it is polled: a client
// asking "is there anything new" every minute should not make the server
// assemble thirty rows and their bodies to answer with one integer.
async function getUnreadCount(req, res) {
try {
return res.json({ unread: await inbox.unreadCount(req.user.id) })
} catch (err) {
log.error('getUnreadCount', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /auth/me/notifications/:id/read — mark one item read. Idempotent.
//
// 404 both when the row does not exist and when it belongs to somebody else,
// which is the same answer on purpose: distinguishing them would turn this route
// into a way to ask whether a given id is anybody's.
async function markRead(req, res) {
try {
const found = await inbox.markRead(req.user.id, req.params.id)
if (!found) return res.status(404).json({ message: 'Not Found' })
return res.json({ ok: true, unread: await inbox.unreadCount(req.user.id) })
} catch (err) {
log.error('markRead', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /auth/me/notifications/read-all — mark the whole inbox read.
async function markAllRead(req, res) {
try {
const changed = await inbox.markAllRead(req.user.id)
return res.json({ ok: true, changed, unread: 0 })
} catch (err) {
log.error('markAllRead', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { module.exports = {
registerDevice, registerDevice,
listDevices, listDevices,
@@ -157,4 +232,8 @@ module.exports = {
putChannelPrefs, putChannelPrefs,
getTeamPrefs, getTeamPrefs,
putTeamPrefs, putTeamPrefs,
getInbox,
getUnreadCount,
markRead,
markAllRead,
} }

View File

@@ -7,7 +7,7 @@
// and never touches /admin. // and never touches /admin.
const express = require('express') const express = require('express')
const { body, param } = require('express-validator') const { body, param, query } = require('express-validator')
const notif = require('./notifications.controller') const notif = require('./notifications.controller')
const { requireAuth } = require('../../../auth/session.middleware') const { requireAuth } = require('../../../auth/session.middleware')
@@ -137,6 +137,72 @@ notifRouter.put(
notif.putChannelPrefs, 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 callers notification inbox'
// #swagger.description = 'The in-app channels 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 callers 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 anybodys.'
// #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) ────────────────────────── // ── Per-Team preferences (TEAMS.md §6.3, phase 6) ──────────────────────────
// //
// The granularity per-stream opt-in cannot express: "I am in five Teams and want // The granularity per-stream opt-in cannot express: "I am in five Teams and want

View File

@@ -10,6 +10,7 @@ const http = require('http')
const botScore = require('./middleware/botScore') const botScore = require('./middleware/botScore')
const announceWorker = require('./utils/announceWorker') const announceWorker = require('./utils/announceWorker')
const teamActivityPrune = require('./utils/teamActivityPrune') const teamActivityPrune = require('./utils/teamActivityPrune')
const inboxPrune = require('./utils/userNotificationsPrune')
const teamForumUploadSweep = require('./utils/teamForumUploadSweep') const teamForumUploadSweep = require('./utils/teamForumUploadSweep')
const teamDigestWorker = require('./utils/teamDigestWorker') const teamDigestWorker = require('./utils/teamDigestWorker')
const engagementWorker = require('./utils/engagementWorker') const engagementWorker = require('./utils/engagementWorker')
@@ -158,6 +159,7 @@ async function start() {
// is the obvious unbounded-growth failure, so retention starts with the feed // is the obvious unbounded-growth failure, so retention starts with the feed
// rather than after someone notices. No-op on a deployment with no Teams. // rather than after someone notices. No-op on a deployment with no Teams.
teamActivityPrune.start() teamActivityPrune.start()
inboxPrune.start()
teamForumUploadSweep.start() teamForumUploadSweep.start()
teamDigestWorker.start() teamDigestWorker.start()
@@ -183,6 +185,7 @@ function setupShutdown(server, internalServer) {
botScore.stopSweeper() // stop the bot-store cleanup interval botScore.stopSweeper() // stop the bot-store cleanup interval
announceWorker.stop() // stop the news-announcement dispatcher poller announceWorker.stop() // stop the news-announcement dispatcher poller
teamActivityPrune.stop() // stop the Team activity retention timer teamActivityPrune.stop() // stop the Team activity retention timer
inboxPrune.stop() // stop the in-app inbox retention timer
teamForumUploadSweep.stop() // stop the forum upload sweep teamForumUploadSweep.stop() // stop the forum upload sweep
teamDigestWorker.stop() // stop the Team forum digest timer teamDigestWorker.stop() // stop the Team forum digest timer
engagementWorker.stop() // stop the engagement outbox worker engagementWorker.stop() // stop the engagement outbox worker

View File

@@ -0,0 +1,105 @@
// ── Inbox retention worker ─────────────────────────────────────────────────
//
// ENGAGEMENT.md Phase 7. `user_notifications` is written by a rule that can fire
// on every event of its trigger, for every member of its audience, forever —
// nothing in the engine deletes anything, and neither the outbox nor the send
// log is a bound on this table (both hold one row per DELIVERY, and an inbox
// item outlives its delivery by design). The plan specifies no retention at all,
// which is how `team_activity` grew until §4.2 gave it this same worker.
//
// **Read items only, and that is the policy rather than an implementation
// detail.** An unread item is one the user has not seen; deleting it because it
// is old is the inbox quietly answering "nothing waiting" when something is.
// A never-read backlog is bounded in practice by the per-rule hourly ceiling
// (§7.1 Q3), which is the limit an operator actually tunes.
//
// Same in-process shape as `teamActivityPrune` and `announceWorker` — setInterval
// + unref + stop(), wired into server.js start/shutdown beside them, with the
// first run delayed so a table-wide DELETE never lands in front of the first
// request on a crash-looping deployment.
const inbox = require('../model/userNotifications/userNotifications.db')
const settings = require('../model/settings/settings.model')
const log = require('./logger')('engagement')
const INTERVAL_MS = Number(process.env.INBOX_PRUNE_MS) || 24 * 60 * 60 * 1000
const FIRST_RUN_MS = Number(process.env.INBOX_PRUNE_DELAY_MS) || 5 * 60 * 1000
// In `settings`, not in env, for the reason §4.2 gives: an operator tightening a
// busy shard should not need a deploy. The key is namespaced with the table it
// governs rather than with the phase that added it.
const RETAIN_KEY = 'user_notifications_retain_days'
const DEFAULT_RETAIN_DAYS = 90
// A bound per sweep, so one run after a long outage is a series of bounded
// statements rather than a delete of a million rows holding locks. The sweep
// repeats until it clears, and stops early rather than looping forever.
const BATCH = 1000
const MAX_BATCHES = 50
/**
* How many days of read items to keep.
*
* Wrapped in a try like `teamActivity.retentionConfig`, and for its reason: this
* runs on a timer with nobody watching, so a settings table that is briefly
* unavailable must yield the default rather than an exception that kills the
* nightly job. A zero or negative value would delete the whole inbox, so it is
* rejected rather than honoured.
*/
async function retainDays() {
try {
const raw = await settings.get(RETAIN_KEY)
const days = Number(raw)
if (Number.isFinite(days) && days > 0) return Math.floor(days)
} catch (err) {
log.debug('inbox retention setting unreadable; using the default', { message: err.message })
}
return DEFAULT_RETAIN_DAYS
}
/** One prune. Never throws — it runs on a timer with nobody to catch it. */
async function tick() {
try {
const days = await retainDays()
let removed = 0
for (let i = 0; i < MAX_BATCHES; i += 1) {
const n = await inbox.pruneRead(days, BATCH)
removed += n
if (n < BATCH) break
}
if (removed) log.info('inbox pruned', { removed, retainDays: days })
return removed
} catch (err) {
log.error('inbox prune failed', { message: err.message })
return null
}
}
let timer = null
let firstRun = null
function start() {
if (timer || firstRun) return timer
firstRun = setTimeout(() => {
firstRun = null
tick()
timer = setInterval(() => { tick() }, INTERVAL_MS)
if (timer.unref) timer.unref()
}, FIRST_RUN_MS)
if (firstRun.unref) firstRun.unref()
log.info('inbox retention started', { intervalMs: INTERVAL_MS, firstRunMs: FIRST_RUN_MS })
return timer
}
function stop() {
if (firstRun) {
clearTimeout(firstRun)
firstRun = null
}
if (timer) {
clearInterval(timer)
timer = null
}
}
module.exports = { start, stop, tick, retainDays, RETAIN_KEY, DEFAULT_RETAIN_DAYS, INTERVAL_MS, FIRST_RUN_MS }

View File

@@ -10225,6 +10225,101 @@
] ]
} }
}, },
"/api/v1/auth/me/notifications": {
"get": {
"tags": [
"Auth · Me"
],
"summary": "One page of the callers notification inbox",
"description": "The in-app channels 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.",
"parameters": [
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"minimum": {
"type": "number",
"example": 1
},
"maximum": {
"type": "number",
"example": 100
},
"default": {
"type": "number",
"example": 30
}
}
},
"description": "Page size (capped at 100)."
},
{
"name": "before",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Return items with an id lower than this — the cursor from the previous page."
},
{
"name": "unread",
"in": "query",
"required": false,
"schema": {
"type": "boolean"
},
"description": "Only items that have not been read."
}
],
"responses": {
"200": {
"description": "A page of the inbox",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationInbox"
}
}
}
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/auth/me/notifications/channels": { "/api/v1/auth/me/notifications/channels": {
"get": { "get": {
"tags": [ "tags": [
@@ -10333,6 +10428,51 @@
} }
} }
}, },
"/api/v1/auth/me/notifications/read-all": {
"post": {
"tags": [
"Auth · Me"
],
"summary": "Mark the callers whole inbox read",
"description": "",
"responses": {
"200": {
"description": "Marked read",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationReadResult"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/auth/me/notifications/streams": { "/api/v1/auth/me/notifications/streams": {
"get": { "get": {
"tags": [ "tags": [
@@ -10594,6 +10734,120 @@
} }
} }
}, },
"/api/v1/auth/me/notifications/unread-count": {
"get": {
"tags": [
"Auth · Me"
],
"summary": "How many inbox items the caller has not read",
"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.",
"responses": {
"200": {
"description": "The unread count",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationUnreadCount"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/auth/me/notifications/{id}/read": {
"post": {
"tags": [
"Auth · Me"
],
"summary": "Mark one inbox item read",
"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 anybodys.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "Notification id (must belong to the caller)."
}
],
"responses": {
"200": {
"description": "Marked read",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationReadResult"
}
}
}
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "No such item for this user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/auth/me/sessions": { "/api/v1/auth/me/sessions": {
"get": { "get": {
"tags": [ "tags": [
@@ -18728,6 +18982,292 @@
} }
} }
}, },
"NotificationItem": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "One item in the callers in-app inbox."
},
"properties": {
"type": "object",
"properties": {
"id": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 412
}
}
},
"triggerId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "team.forum.post"
}
}
},
"title": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "The Silver Anvil — new forum post"
}
}
},
"body": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "Darrow posted in The Silver Anvil."
}
}
},
"url": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Site-relative path only. An absolute or protocol-relative url is never stored."
},
"example": {
"type": "string",
"example": "/guilds/the-silver-anvil/forum/412"
}
}
},
"read": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": false
}
}
},
"readAt": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "date-time"
},
"nullable": {
"type": "boolean",
"example": true
}
}
},
"createdAt": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "date-time"
}
}
}
}
}
}
},
"NotificationInbox": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "One page of the inbox, newest first. `unread` counts the whole inbox, not the page."
},
"properties": {
"type": "object",
"properties": {
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"$ref": "#/components/schemas/NotificationItem"
}
}
},
"hasMore": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Whether another page exists. Fetch it with `before` set to the last items id."
},
"example": {
"type": "boolean",
"example": false
}
}
},
"unread": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 3
}
}
}
}
}
}
},
"NotificationUnreadCount": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"unread": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 3
}
}
}
}
}
}
},
"NotificationReadResult": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "The result of marking one item, or the whole inbox, read. `unread` is the count after the change, so a client never has to re-poll for the badge."
},
"properties": {
"type": "object",
"properties": {
"ok": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"changed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"description": {
"type": "string",
"example": "Mark-all only: how many items changed."
},
"example": {
"type": "number",
"example": 3
}
}
},
"unread": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 0
}
}
}
}
}
}
},
"TeamNotificationPref": { "TeamNotificationPref": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -716,6 +716,51 @@ const doc = {
}, },
}, },
}, },
NotificationItem: {
type: 'object',
description: 'One item in the callers in-app inbox.',
properties: {
id: { type: 'integer', example: 412 },
triggerId: { type: 'string', example: 'team.forum.post' },
title: { type: 'string', example: 'The Silver Anvil — new forum post' },
body: { type: 'string', nullable: true, example: 'Darrow posted in The Silver Anvil.' },
url: {
type: 'string',
nullable: true,
description: 'Site-relative path only. An absolute or protocol-relative url is never stored.',
example: '/guilds/the-silver-anvil/forum/412',
},
read: { type: 'boolean', example: false },
readAt: { type: 'string', format: 'date-time', nullable: true },
createdAt: { type: 'string', format: 'date-time' },
},
},
NotificationInbox: {
type: 'object',
description: 'One page of the inbox, newest first. `unread` counts the whole inbox, not the page.',
properties: {
items: { type: 'array', items: { $ref: '#/components/schemas/NotificationItem' } },
hasMore: {
type: 'boolean',
description: 'Whether another page exists. Fetch it with `before` set to the last items id.',
example: false,
},
unread: { type: 'integer', example: 3 },
},
},
NotificationUnreadCount: {
type: 'object',
properties: { unread: { type: 'integer', example: 3 } },
},
NotificationReadResult: {
type: 'object',
description: 'The result of marking one item, or the whole inbox, read. `unread` is the count after the change, so a client never has to re-poll for the badge.',
properties: {
ok: { type: 'boolean', example: true },
changed: { type: 'integer', description: 'Mark-all only: how many items changed.', example: 3 },
unread: { type: 'integer', example: 0 },
},
},
TeamNotificationPref: { TeamNotificationPref: {
type: 'object', type: 'object',
description: "One Team's notification preference for the current user. Absent fields take the stored defaults: push is opt-OUT (not muted) and email is opt-IN (`off`).", description: "One Team's notification preference for the current user. Absent fields take the stored defaults: push is opt-OUT (not muted) and email is opt-IN (`off`).",

View File

@@ -475,11 +475,21 @@ test('two sweepers racing one due row: exactly one claim wins', async () => {
// ── The send log ─────────────────────────────────────────────────────────── // ── The send log ───────────────────────────────────────────────────────────
test('a row whose channel has no deliver() finishes failed, and the send log says why', async () => { test('a row whose channel has no deliver() finishes failed, and the send log says why', async () => {
// `inapp`, because as of Phase 6 `email` DOES deliver. The inbox arrives in // **A channel registered for this test, because as of Phase 7 all three of
// Phase 7, and until then recording 'sent' would be a lie in the one table // core's deliver.** It used to name `inapp` (and `email` before that), which
// whose purpose is answering "did they get it". // meant the assertion moved every time a phase gave a channel behaviour. The
addRule({ channels: ['inapp'] }) // property under test was never about a particular channel: it is that the
optIn(10, 'uo.house.idoc_warning', 'inapp') // worker does not record 'sent' for a sink it cannot reach, because that would
// be a lie in the one table whose purpose is answering "did they get it".
channels.registerDeliveryChannel({
id: 'nosink',
label: 'No sink',
carriesContent: true,
defaultMode: 'off',
supportsDigest: false,
})
addRule({ channels: ['nosink'] })
optIn(10, 'uo.house.idoc_warning', 'nosink')
await engine.dispatch(event(), T0) await engine.dispatch(event(), T0)
await worker.tick(later(1000)) await worker.tick(later(1000))

View File

@@ -0,0 +1,365 @@
// ── The in-app channel (ENGAGEMENT.md Phase 7) ─────────────────────────────
//
// The phase's five acceptance criteria, plus the things building it showed were
// worth pinning:
//
// • one event delivered to `inapp` produces exactly one row
// • a duplicate `dedupeKey` is a no-op
// • mark-read is idempotent
// • a user cannot read another user's row — asserted AT THE ROUTE, which is
// what the acceptance line asks for, not only in the model
// • `url` is relative-only, by the same character class `pageUrlTemplate` uses
//
// • the block→column role mapping, which is the whole of how a template with a
// subject and a document becomes a row with three fields
// • `ctx.inbox.push` honours a preference where one exists and writes where
// none does
// • `liveChannels` puts `inapp` before `push`, which is what makes the tickle's
// deep-link ref resolve on the first pass
//
// Point the DB at a closed port before requiring anything: the registries reach
// utils/discordAnnounce, which builds the pool at require time.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const channels = require('../src/engagement/channels')
const engine = require('../src/engagement/engine')
const inappChannel = require('../src/engagement/inappChannel')
const pushChannel = require('../src/engagement/pushChannel')
const templates = require('../src/engagement/templates')
const templateSeeds = require('../src/engagement/templateSeeds')
const templatesDb = require('../src/model/engagement/engagementTemplates.db')
const settings = require('../src/model/settings/settings.model')
const inbox = require('../src/model/userNotifications/userNotifications.db')
const recipients = require('../src/model/engagement/engagementRecipients.db')
const rulesDb = require('../src/model/engagement/engagementRules.db')
const pushDispatch = require('../src/utils/pushDispatch')
const notifCtrl = require('../src/router/v1/auth/notifications.controller')
const db = require('../src/utils/db')
require('../src/engagement')
registries.registerCore()
after(() => db.close())
const saved = new Map()
function patch(mod, name, fn) {
if (!saved.has(mod)) saved.set(mod, new Map())
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
mod[name] = fn
}
function restore() {
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
saved.clear()
}
const TRIGGER = 'team.forum.post'
let world
beforeEach(() => {
world = { rows: [], tickles: [], storedModes: new Map() }
// A stand-in for `user_notifications`, keyed the way the UNIQUE index is.
patch(inbox, 'insert', async (item) => {
const clash =
item.dedupeKey &&
world.rows.some((r) => r.userId === item.userId && r.dedupeKey === item.dedupeKey)
if (clash) return { inserted: false, id: null }
const row = { id: world.rows.length + 1, ...item }
world.rows.push(row)
return { inserted: true, id: row.id }
})
patch(inbox, 'findByDedupe', async (userId, key) => {
if (!key) return null
const row = world.rows.find((r) => r.userId === Number(userId) && r.dedupeKey === key)
return row ? { id: row.id, title: row.title } : null
})
patch(recipients, 'filterActive', async (ids) => ids)
patch(recipients, 'storedModes', async () => world.storedModes)
patch(pushDispatch, 'publishToUsers', async (streamId, opts) => {
world.tickles.push({ streamId, ...opts })
})
patch(rulesDb, 'getById', async () => ({
id: 1,
trigger_id: TRIGGER,
template_keys: { inapp: 'inapp.event' },
}))
// **Stubbed at the MODULE BOUNDARY, not on `templates` itself**, and the
// distinction cost four minutes a run to find: `renderInappByKey` calls its
// own file-local `ambient()` and `resolveTemplate()`, so patching
// `templates.ambient` replaces an export nothing in that path reads, every
// call reaches the dead port, and each one waits out the driver's 30-second
// connect timeout while still passing. These two are real cross-module calls,
// so replacing them is what actually keeps the render off the database.
//
// A null row is also the path a fresh deployment takes: `resolveTemplate`
// falls through to the shipped seed.
patch(templatesDb, 'getByKey', async () => null)
patch(settings, 'getInstanceName', async () => 'Test Shard')
patch(settings, 'getShellBrand', async () => ({ logo: null, theme: null }))
})
afterEach(restore)
const outboxRow = (over = {}) => ({
id: 1,
rule_id: 1,
trigger_id: TRIGGER,
user_id: 11,
channel: 'inapp',
subject_key: 'The Silver Hand',
scope_key: 'team:1',
dedupe_key: 'post:7',
payload: { teamName: 'The Silver Hand', authorName: 'Ten', threadTitle: 'Raid', postUrl: '/g/1?thread=7' },
...over,
})
// ── The registration ───────────────────────────────────────────────────────
// The Phase 7 decision `coreChannels.js` deferred in as many words. Opt-OUT for
// in-app alone: it wakes no device and leaves no building.
test('inapp is the one channel that defaults to instant', () => {
assert.equal(channels.defaultMode('inapp'), 'instant')
assert.equal(channels.defaultMode('push'), 'off')
assert.equal(channels.defaultMode('email'), 'off')
})
test('inapp and push both have a deliver now, and push still carries no content', () => {
assert.equal(typeof channels.get('inapp').deliver, 'function')
assert.equal(typeof channels.get('push').deliver, 'function')
assert.equal(channels.get('push').carriesContent, false)
})
// ── deliver ────────────────────────────────────────────────────────────────
test('one event delivered to inapp produces exactly one row', async () => {
const result = await inappChannel.deliver(outboxRow())
assert.equal(result.ok, true)
assert.equal(world.rows.length, 1)
assert.equal(world.rows[0].userId, 11)
assert.equal(world.rows[0].triggerId, TRIGGER)
})
// The acceptance line calls it a no-op; from the recipient's side it is a
// delivery, so it reports ok with the reason in the detail rather than putting a
// red row in the send log for the mechanism working.
test('a duplicate dedupeKey is a no-op that still reports success', async () => {
await inappChannel.deliver(outboxRow())
const again = await inappChannel.deliver(outboxRow({ id: 2 }))
assert.equal(again.ok, true)
assert.match(again.detail, /duplicate/i)
assert.equal(world.rows.length, 1)
})
test('a row with no dedupe key is never deduped', async () => {
await inappChannel.deliver(outboxRow({ dedupe_key: null }))
await inappChannel.deliver(outboxRow({ id: 2, dedupe_key: null }))
assert.equal(world.rows.length, 2)
})
test('a user who can no longer be reached is a terminal failure, not a retry', async () => {
patch(recipients, 'filterActive', async () => [])
const result = await inappChannel.deliver(outboxRow())
assert.equal(result.ok, false)
assert.equal(result.retry, undefined)
assert.equal(world.rows.length, 0)
})
// A throw would be read by the worker as a transient failure and retried five
// times — one unrenderable template becoming five identical send-log rows.
test('deliver never throws — a render failure is classified, not propagated', async () => {
patch(templates, 'renderInappByKey', async () => { throw new Error('blocks are broken') })
const result = await inappChannel.deliver(outboxRow())
assert.equal(result.ok, false)
assert.match(result.detail, /blocks are broken/)
})
test('a template that names nothing shipped is terminal and says which key', async () => {
patch(rulesDb, 'getById', async () => ({ id: 1, template_keys: { inapp: 'nope.missing' } }))
const result = await inappChannel.deliver(outboxRow())
assert.equal(result.ok, false)
assert.match(result.detail, /nope\.missing/)
})
// ── The block → column role mapping ────────────────────────────────────────
test('the heading becomes the title, the button becomes the url, the rest becomes the body', async () => {
const rendered = await templates.renderInappByKey('inapp.event', {
title: 'Your house is close to collapsing',
intro: 'The Silver Anvil has entered its final decay stage.',
actionUrl: '/player/uo/houses',
})
assert.equal(rendered.title, 'Your house is close to collapsing')
assert.equal(rendered.url, '/player/uo/houses')
assert.match(rendered.body, /final decay stage/)
// The title and the action are COLUMNS; repeating them in the body would show
// the same words twice on one card.
assert.doesNotMatch(rendered.body, /close to collapsing/)
assert.doesNotMatch(rendered.body, /player\/uo\/houses/)
})
// §4.6.1 property 1, for this channel: a trigger with no bespoke template still
// renders, because `projection.project` supplies the structural names.
test('a trigger that authored nothing still gets a title, from its declaration', async () => {
const rendered = await inappChannel.renderItem(TRIGGER, { teamName: 'The Silver Hand' }, 'inapp.event')
assert.ok(rendered.title.length > 0)
assert.notEqual(rendered.title, 'inapp.event')
})
// The seed Phase 5a wrote named `body` and `url` — names nothing supplies, so
// every rendering of it would have produced a title and nothing else.
test('the shipped inapp seed names only variables the projection actually supplies', () => {
const seed = templateSeeds.seedByKey('inapp.event')
assert.equal(seed.seedVersion, 2)
const names = seed.variables.map((v) => v.name).sort()
assert.deepEqual(names, ['actionUrl', 'intro', 'title'])
})
// ── url: relative only ─────────────────────────────────────────────────────
test('url is relative-only, and a protocol-relative one is dropped rather than stored', () => {
const base = 'https://shard.test'
assert.equal(templates.relativeUrl('/guilds/4', base), '/guilds/4')
assert.equal(templates.relativeUrl('https://shard.test/guilds/4', base), '/guilds/4')
assert.equal(templates.relativeUrl('//evil.test/x', base), null)
assert.equal(templates.relativeUrl('https://evil.test/x', base), null)
assert.equal(templates.relativeUrl('javascript:alert(1)', base), null)
assert.equal(templates.relativeUrl('', base), null)
})
// ── ctx.inbox.push ─────────────────────────────────────────────────────────
test('ctx.inbox.push writes an item for a trigger nothing has registered', async () => {
const res = await inappChannel.pushDirect('uo', 11, {
triggerId: 'uo.unregistered.thing',
title: 'Something happened',
body: 'A thing occurred.',
url: '/player/uo/houses',
})
assert.equal(res.written, true)
assert.equal(world.rows[0].url, '/player/uo/houses')
})
// The decision: a toggle somebody switched off must not be walkable around by
// the module that owns the trigger behind it.
test('ctx.inbox.push honours the users preference when the trigger IS registered', async () => {
world.storedModes = new Map([[11, 'off']])
const res = await inappChannel.pushDirect('uo', 11, { triggerId: TRIGGER, title: 'Hi' })
assert.equal(res.written, false)
assert.equal(world.rows.length, 0)
})
test('ctx.inbox.push writes for a registered trigger the user has left at the default', async () => {
world.storedModes = new Map()
const res = await inappChannel.pushDirect('uo', 11, { triggerId: TRIGGER, title: 'Hi' })
assert.equal(res.written, true)
})
test('ctx.inbox.push drops an off-site url rather than storing it', async () => {
await inappChannel.pushDirect('uo', 11, {
triggerId: 'x.y',
title: 'Hi',
url: 'https://evil.test/steal',
})
assert.equal(world.rows[0].url, null)
})
test('ctx.inbox.push refuses an item with no title, and never throws', async () => {
const res = await inappChannel.pushDirect('uo', 11, { triggerId: 'x.y' })
assert.equal(res.written, false)
patch(inbox, 'insert', async () => { throw new Error('table is gone') })
const boom = await inappChannel.pushDirect('uo', 11, { triggerId: 'x.y', title: 'Hi' })
assert.equal(boom.written, false)
})
// ── push: the tickle, its ref, and what must never ride on it ──────────────
test('the push tickle carries the stream and a ref, and no content whatsoever', async () => {
await inappChannel.deliver(outboxRow())
const result = await pushChannel.deliver(outboxRow({ id: 2, channel: 'push' }))
assert.equal(result.ok, true)
const tickle = world.tickles[0]
assert.equal(tickle.streamId, TRIGGER)
assert.equal(tickle.ref, 'notification:1')
assert.deepEqual(Object.keys(tickle).sort(), ['ref', 'streamId', 'userIds'])
assert.deepEqual(tickle.userIds, [11])
})
test('a push row with no inbox row behind it still publishes, with a null ref', async () => {
const result = await pushChannel.deliver(outboxRow({ channel: 'push', dedupe_key: null }))
assert.equal(result.ok, true)
assert.equal(world.tickles[0].ref, null)
})
// The ordering is what makes the ref resolve on the first pass: the outbox is
// swept `ORDER BY due_at, id`, so the in-app row has to be enqueued first.
test('liveChannels enqueues inapp before push, whatever order the rule names them in', () => {
assert.deepEqual(engine.liveChannels({ channels: ['push', 'inapp'] }), ['inapp', 'push'])
assert.deepEqual(engine.liveChannels({ channels: ['email', 'push'] }), ['email', 'push'])
assert.deepEqual(engine.liveChannels({ channels: ['push', 'nope'] }), ['push'])
})
// ── The routes: ownership, asserted where the acceptance line asks for it ──
function res() {
const out = { code: 200, body: null }
return {
out,
status(c) { out.code = c; return this },
json(b) { out.body = b; return this },
}
}
test('a user cannot mark another users notification read — 404 at the route', async () => {
// The model is NOT stubbed to "found": it is the real ownership predicate the
// route depends on, so the stub answers the way the SQL would.
patch(inbox, 'markRead', async (userId, id) => Number(userId) === 11 && Number(id) === 5)
patch(inbox, 'unreadCount', async () => 0)
const mine = res()
await notifCtrl.markRead({ user: { id: 11 }, params: { id: 5 } }, mine)
assert.equal(mine.out.code, 200)
const theirs = res()
await notifCtrl.markRead({ user: { id: 12 }, params: { id: 5 } }, theirs)
assert.equal(theirs.out.code, 404)
// The same answer whether the row is nobody's or somebody else's: telling them
// apart would make this a way to ask whether an id exists.
const missing = res()
await notifCtrl.markRead({ user: { id: 11 }, params: { id: 999 } }, missing)
assert.equal(missing.out.code, 404)
})
test('mark-read is idempotent', async () => {
let stamps = 0
patch(inbox, 'markRead', async () => { stamps += 1; return true })
patch(inbox, 'unreadCount', async () => 0)
await notifCtrl.markRead({ user: { id: 11 }, params: { id: 5 } }, res())
await notifCtrl.markRead({ user: { id: 11 }, params: { id: 5 } }, res())
assert.equal(stamps, 2) // the route is happy to be called twice…
// …and the statement behind it only stamps an unread row, which is the half
// that makes the second call a no-op. Pinned in the SQL test.
})
// There is no route parameter and no query string that names a user, so the
// listing cannot be pointed at another account even by a caller who tries.
test('the inbox list reads the caller and nothing else', async () => {
let askedFor = null
patch(inbox, 'list', async (userId, opts) => {
askedFor = { userId, opts }
return { items: [], hasMore: false }
})
patch(inbox, 'unreadCount', async () => 2)
const r = res()
await notifCtrl.getInbox(
{ user: { id: 11 }, query: { limit: '10', before: '99', unread: 'true', userId: '12' } },
r,
)
assert.equal(askedFor.userId, 11)
assert.equal(askedFor.opts.unreadOnly, true)
assert.equal(r.out.body.unread, 2)
})

View File

@@ -157,11 +157,18 @@ test('unknown stream ids are still dropped, and are not mirrored either', async
// ── Acceptance: defaults ─────────────────────────────────────────────────── // ── Acceptance: defaults ───────────────────────────────────────────────────
test("a fresh user's modes are the channel defaults, and all three are off", async () => { test("a fresh user's modes are the channel defaults — push and email off, in-app on", async () => {
const surface = await prefs.getForUser(USER, PLAYER) const surface = await prefs.getForUser(USER, PLAYER)
const news = item(surface, 'news.post') const news = item(surface, 'news.post')
assert.deepEqual(news.modes, { push: 'off', email: 'off', inapp: 'off' }) // **`inapp` is 'instant' from Phase 7**, and it is the only one that is.
// Settled by the org lead 2026-08-31: the argument for opt-IN was that push
// wakes a device somebody is holding and email leaves the building, and an
// inbox item does neither — it is a row on a page the user chose to open. Left
// 'off' the surface ships dead, because no rule could reach anybody until
// every user found a toggle for a channel they had never seen deliver
// anything.
assert.deepEqual(news.modes, { push: 'off', email: 'off', inapp: 'instant' })
assert.equal(prefRows.size, 0, 'reading preferences must not write rows') assert.equal(prefRows.size, 0, 'reading preferences must not write rows')
// The acceptance line in ENGAGEMENT.md originally said push defaults // The acceptance line in ENGAGEMENT.md originally said push defaults

View File

@@ -0,0 +1,203 @@
// ── The inbox's raw SQL, against a real MariaDB ────────────────────────────
//
// ENGAGEMENT.md Phase 7. `engagementInapp.test.js` stubs the table and exercises
// everything the channel DECIDES. It cannot prove the three statements whose
// correctness is a server contract rather than a reading of this code:
//
// • **`UNIQUE (user_id, dedupe_key)` must admit many NULLs.** The whole
// "this item does not dedupe" case rests on it, and a unique index that
// rejected a second NULL would mean the second un-keyed notification any
// user ever received was silently dropped. It is standard SQL and it is also
// exactly the kind of assumption Phase 4a's `foundRows` defect was.
// • **`INSERT IGNORE` on a duplicate reports `affectedRows = 0`** — the value
// `insert()` returns `inserted: false` from, and therefore the value that
// decides whether the send log says "delivered" or "duplicate".
// • **`read_at IS NULL` in the mark-read predicate is what makes it
// idempotent**: the timestamp must not move on a second call.
//
// Plus the prune's one policy: it deletes read rows and leaves unread ones,
// however old.
//
// **It SKIPS when there is no database**, exactly as `engagementEngineSql`
// does and for its reason: CI runs the suite with the pool pointed at a dead
// port, and a file that failed there would make every PR red for a reason
// unrelated to itself. Run it against this machine's container with:
//
// DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=... DB_PASSWORD=... \
// node --test test/userNotificationsSql.test.js
//
// It creates a throwaway database named after the process and drops it again, so
// it can never touch a real schema.
const { test, before, after } = require('node:test')
const assert = require('node:assert/strict')
const mariadb = require('mariadb')
// Verbatim from schema.sql, minus the FK to `users` — the point of this file is
// the index semantics, and a foreign key would mean seeding an accounts table
// that has nothing to do with any of them.
const SCHEMA = `
CREATE TABLE user_notifications (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
trigger_id VARCHAR(96) NOT NULL,
title VARCHAR(300) NOT NULL,
body TEXT NULL,
url VARCHAR(500) NULL,
dedupe_key VARCHAR(190) NULL,
read_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_un_dedupe (user_id, dedupe_key),
INDEX idx_un_unread (user_id, read_at, created_at),
INDEX idx_un_prune (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`
// The statements under test, verbatim from `userNotifications.db.js`. Duplicated
// rather than required for `engagementEngineSql`'s reason: requiring the model
// would drag in `utils/db`'s pool, which the harness has pointed at a dead port.
const INSERT = `
INSERT IGNORE INTO user_notifications (user_id, trigger_id, title, body, url, dedupe_key)
VALUES (?, ?, ?, ?, ?, ?)`
const MARK_READ = `
UPDATE user_notifications SET read_at = NOW() WHERE id = ? AND user_id = ? AND read_at IS NULL`
const PRUNE = `
DELETE FROM user_notifications
WHERE read_at IS NOT NULL AND created_at < (NOW() - INTERVAL ? DAY)
LIMIT ?`
const DB = `rg_inbox_test_${process.pid}`
let pool = null
let available = false
const opts = () => ({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
})
before(async () => {
const admin = mariadb.createPool({
...opts(),
connectionLimit: 1,
connectTimeout: 2000,
initializationTimeout: 2000,
})
try {
await admin.query(`CREATE DATABASE ${DB}`)
available = true
} catch {
available = false
} finally {
await admin.end().catch(() => {})
}
if (!available) return
pool = mariadb.createPool({
...opts(),
database: DB,
connectionLimit: 3,
multipleStatements: true,
bigIntAsNumber: true,
insertIdAsNumber: true,
})
await pool.query(SCHEMA)
})
after(async () => {
if (pool) {
await pool.query(`DROP DATABASE IF EXISTS ${DB}`).catch(() => {})
await pool.end().catch(() => {})
}
})
// Checked INSIDE each test, never as a `{ skip }` option — the trap
// `engagementEngineSql` documents and this file fell into anyway: the option is
// evaluated when the file is READ, which is before `before()` has had a chance
// to find out whether there is a database, so every test skips unconditionally.
// It looks exactly like a passing suite.
const SKIP = 'no database reachable - set DB_HOST/DB_PORT/DB_USER/DB_PASSWORD to run'
const needDb = (t) => {
if (available) return false
t.skip(SKIP)
return true
}
const write = (userId, key, over = {}) =>
pool.query(INSERT, [userId, over.trigger || 't.x', over.title || 'Hi', null, null, key])
test('a duplicate (user, dedupe key) is ignored and reports affectedRows 0', async (t) => {
if (needDb(t)) return
const first = await write(901, 'evt:1')
assert.equal(first.affectedRows, 1)
const second = await write(901, 'evt:1')
assert.equal(second.affectedRows, 0)
// Scoped to the USER, not global: one event legitimately reaches fifty people,
// and a global unique key would admit the first and drop forty-nine — the
// defect Phase 4a found in §4.2a's outbox index, in a second place.
const other = await write(902, 'evt:1')
assert.equal(other.affectedRows, 1)
})
test('a NULL dedupe key never collides, however many there are', async (t) => {
if (needDb(t)) return
for (let i = 0; i < 3; i += 1) {
const res = await write(903, null)
assert.equal(res.affectedRows, 1)
}
const rows = await pool.query('SELECT COUNT(*) AS n FROM user_notifications WHERE user_id = 903')
assert.equal(Number(rows[0].n), 3)
})
test('mark-read stamps once and a second call moves nothing', async (t) => {
if (needDb(t)) return
const ins = await write(904, 'evt:read')
const id = ins.insertId
const first = await pool.query(MARK_READ, [id, 904])
assert.equal(first.affectedRows, 1)
const [after1] = await pool.query('SELECT read_at FROM user_notifications WHERE id = ?', [id])
// A second later, so a re-stamp would be visible rather than equal by accident.
await pool.query('UPDATE user_notifications SET read_at = read_at - INTERVAL 1 SECOND WHERE id = ?', [id])
const [before2] = await pool.query('SELECT read_at FROM user_notifications WHERE id = ?', [id])
const second = await pool.query(MARK_READ, [id, 904])
assert.equal(second.affectedRows, 0)
const [after2] = await pool.query('SELECT read_at FROM user_notifications WHERE id = ?', [id])
assert.deepEqual(after2.read_at, before2.read_at)
assert.notDeepEqual(after1.read_at, before2.read_at) // the shift really happened
})
test('mark-read scoped to the owner matches nothing for anyone else', async (t) => {
if (needDb(t)) return
const ins = await write(905, 'evt:owner')
const wrong = await pool.query(MARK_READ, [ins.insertId, 906])
assert.equal(wrong.affectedRows, 0)
const [row] = await pool.query('SELECT read_at FROM user_notifications WHERE id = ?', [ins.insertId])
assert.equal(row.read_at, null)
})
test('the prune drops old READ rows and keeps unread ones however old', async (t) => {
if (needDb(t)) return
const old = await write(907, 'evt:old')
const oldUnread = await write(907, 'evt:old-unread')
const recent = await write(907, 'evt:recent')
await pool.query(
'UPDATE user_notifications SET created_at = NOW() - INTERVAL 200 DAY, read_at = NOW() WHERE id = ?',
[old.insertId],
)
await pool.query('UPDATE user_notifications SET created_at = NOW() - INTERVAL 200 DAY WHERE id = ?', [
oldUnread.insertId,
])
await pool.query('UPDATE user_notifications SET read_at = NOW() WHERE id = ?', [recent.insertId])
const res = await pool.query(PRUNE, [90, 1000])
assert.equal(res.affectedRows, 1)
const rows = await pool.query('SELECT id FROM user_notifications WHERE user_id = 907 ORDER BY id')
assert.deepEqual(rows.map((r) => Number(r.id)), [oldUnread.insertId, recent.insertId])
})