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>
103 lines
4.3 KiB
JavaScript
103 lines
4.3 KiB
JavaScript
import { useCallback, useEffect, useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { api } from '../api/client.js'
|
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
|
|
|
// Core's per-Team notification control, rendered into a THIRD slot a module
|
|
// declares (TEAMS.md §6.3, phase 6).
|
|
//
|
|
// **Why this is a slot at all, and why it is the third one.** Teams have no core
|
|
// page — the module that owns the vocabulary owns the page — so a control that
|
|
// acts on one Team has nowhere of core's to live. The feed and the forum go below
|
|
// the module's roster; this goes above it, because muting a guild is an action ON
|
|
// the page rather than more content in it, and that is exactly the placement
|
|
// decision a module cannot make if core stacks everything into one fill.
|
|
//
|
|
// **It renders nothing for a viewer who is not in the Team**, including anonymous
|
|
// ones, and that is a privacy property rather than a tidiness one: whether a
|
|
// notification preference EXISTS for a Team answers "is this person in it", and
|
|
// the guild page is public. The server decides — the preference list only contains
|
|
// Teams the caller may be notified about — and this file never infers membership
|
|
// from anything it can see on the page.
|
|
//
|
|
// **Muting is per-Team and covers all four streams.** The per-stream on/off lives
|
|
// on the account screen, where the catalog does; the thing that could not be
|
|
// expressed before phase 6 is "I am in five Teams and want notifications from
|
|
// one", and that is the only question this control asks.
|
|
|
|
export default function TeamNotifyToggle({ externalId, moduleId }) {
|
|
const { user } = useAuth()
|
|
const [state, setState] = useState({ loading: true, team: null, pref: null })
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
const load = useCallback(async () => {
|
|
// Anonymous viewers never fetch. The endpoint would 401 harmlessly, but a
|
|
// guild page rendering a public roster should not put an authenticated
|
|
// request on the wire for every visitor.
|
|
if (!user) return setState({ loading: false, team: null, pref: null })
|
|
try {
|
|
const team = await api.teamByExternalId(moduleId, externalId)
|
|
const { teams } = await api.teamNotificationPrefs()
|
|
const pref = (teams || []).find((t) => t.teamId === team.id) || null
|
|
setState({ loading: false, team, pref })
|
|
} catch {
|
|
// Same rule as the feed and the forum: this is core's content on a page
|
|
// core does not own, so a failure renders nothing rather than putting an
|
|
// error box on somebody else's surface.
|
|
setState({ loading: false, team: null, pref: null })
|
|
}
|
|
}, [externalId, moduleId, user])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
const { loading, pref } = state
|
|
if (loading || !pref) return null
|
|
|
|
async function toggle() {
|
|
setBusy(true)
|
|
// Optimistic, and reconciled from the server's echo rather than assumed: a
|
|
// PUT that silently dropped the entry (a Team left in another tab) must not
|
|
// leave the control claiming a state the server does not hold.
|
|
const next = { ...pref, muted: !pref.muted }
|
|
setState((s) => ({ ...s, pref: next }))
|
|
try {
|
|
const { teams } = await api.setTeamNotificationPrefs([
|
|
{ teamId: pref.teamId, muted: next.muted, emailMode: pref.emailMode },
|
|
])
|
|
const echoed = (teams || []).find((t) => t.teamId === pref.teamId)
|
|
if (echoed) setState((s) => ({ ...s, pref: echoed }))
|
|
} catch {
|
|
setState((s) => ({ ...s, pref }))
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="sans"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
flexWrap: 'wrap',
|
|
margin: '10px 0 0',
|
|
fontSize: '0.84rem',
|
|
}}
|
|
>
|
|
<button type="button" onClick={toggle} disabled={busy} className="btn btn-sq">
|
|
{pref.muted ? 'Unmute notifications' : 'Mute notifications'}
|
|
</button>
|
|
<span className="dim">
|
|
{pref.muted
|
|
? 'You get no notifications about this team.'
|
|
: 'You get notifications about this team.'}
|
|
</span>
|
|
{/* The one link off this control, because "mute" is a blunt answer to a
|
|
question the account screen asks properly — which streams, and whether
|
|
email is on at all. */}
|
|
<Link to="/account/notifications/settings" className="dim">All notification settings</Link>
|
|
</div>
|
|
)
|
|
}
|