feat(teams): phase 6 — notifications, and the email sink the web never had #156
@@ -57,6 +57,8 @@ import ResetPassword from './routes/player/ResetPassword.jsx'
|
||||
import AcceptInvite from './routes/player/AcceptInvite.jsx'
|
||||
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
|
||||
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
||||
import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
|
||||
import Unsubscribe from './routes/player/Unsubscribe.jsx'
|
||||
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
||||
|
||||
export default function App() {
|
||||
@@ -204,6 +206,10 @@ export default function App() {
|
||||
<Route path="/account/forgot" element={<ForgotPassword />} />
|
||||
<Route path="/account/reset/:token" element={<ResetPassword />} />
|
||||
<Route path="/invite/:token" element={<AcceptInvite />} />
|
||||
{/* PUBLIC, and grouped with the other tokened landings above rather
|
||||
than with the portal below: the person following an unsubscribe
|
||||
link is reading their mail, not signed in (TEAMS.md §6.4). */}
|
||||
<Route path="/unsubscribe/:token" element={<Unsubscribe />} />
|
||||
<Route
|
||||
element={
|
||||
<RequirePlayer>
|
||||
@@ -218,6 +224,7 @@ export default function App() {
|
||||
<Route path="/player" element={<PlayerIndex />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
<Route path="/account/notifications" element={<PlayerNotifications />} />
|
||||
{/* Installed modules' player-portal pages, at /player/<id>/…. This
|
||||
group's own routes are absolute (its layout route has no path),
|
||||
so the prefix is written here rather than inherited — the one
|
||||
|
||||
@@ -187,6 +187,26 @@ export const api = {
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }),
|
||||
teamGrantRevoke: (slug, userId) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- notifications (TEAMS.md Part 6) -----
|
||||
//
|
||||
// Under /auth/me rather than /player: these are role-agnostic self-service, the
|
||||
// same rule that put the forum under /player rather than behind a staff gate.
|
||||
// The streams catalog and the per-stream subscriptions were built for the app
|
||||
// and had no web consumer at all until phase 6 gave them one.
|
||||
notificationStreams: () => req('/auth/me/notifications/streams'),
|
||||
notificationSubscriptions: () => req('/auth/me/notifications/subscriptions'),
|
||||
// `streams` is always sent, empty array included — the endpoint requires the
|
||||
// field, so clearing the last subscription must not become an absent key.
|
||||
setNotificationSubscriptions: (streams) =>
|
||||
req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }),
|
||||
teamNotificationPrefs: () => req('/auth/me/notifications/teams'),
|
||||
setTeamNotificationPrefs: (teams) =>
|
||||
req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }),
|
||||
// Unauthenticated, and the one write in the public tier: the caller is reading
|
||||
// their mail, not signed in. Always resolves 200 whatever the token was.
|
||||
unsubscribeTeam: (token) =>
|
||||
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
|
||||
wikiTags: () => req('/public/wiki/tags'),
|
||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
||||
|
||||
@@ -6,6 +6,7 @@ import { publishSharedDependencies } from './modules/shared.js'
|
||||
import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js'
|
||||
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
|
||||
import TeamForumPanel from './modules/TeamForumPanel.jsx'
|
||||
import TeamNotifyToggle from './modules/TeamNotifyToggle.jsx'
|
||||
import './styles/theme.css'
|
||||
|
||||
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
||||
@@ -84,6 +85,12 @@ fillModuleSlot('uo.guild.detail', TeamActivityFeed)
|
||||
// exactly as before.
|
||||
fillModuleSlot('uo.guild.forum', TeamForumPanel)
|
||||
|
||||
// And the notification control, in a third place the module declares ABOVE its
|
||||
// roster. A third slot rather than a corner of the feed for the same reason there
|
||||
// were two: this is an action on the page and the other two are content in it,
|
||||
// and only the module can say where each belongs on a page it owns.
|
||||
fillModuleSlot('uo.guild.header', TeamNotifyToggle)
|
||||
|
||||
// Render on DOMContentLoaded rather than immediately, and that is the one line
|
||||
// of core's boot the module system changes.
|
||||
//
|
||||
|
||||
102
client/src/modules/TeamNotifyToggle.jsx
Normal file
102
client/src/modules/TeamNotifyToggle.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
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" className="dim">All notification settings</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
268
client/src/routes/player/PlayerNotifications.jsx
Normal file
268
client/src/routes/player/PlayerNotifications.jsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6).
|
||||
//
|
||||
// **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
|
||||
// notification settings screen" — there was no such screen on the web. The stream
|
||||
// catalog and the per-stream subscriptions have been built and shipped since M7,
|
||||
// with the Android app as their only consumer; a browser could not see them at
|
||||
// all. That is tolerable for push, which needs the app anyway. It is not tolerable
|
||||
// for email, whose whole reason for existing (§6.4) is the web-only user who runs
|
||||
// neither the app nor Discord — so the sink and the screen to configure it had to
|
||||
// arrive together.
|
||||
//
|
||||
// Three blocks, in the order a user actually reasons about them: what kinds of
|
||||
// thing to be told about, then which Teams, then whether any of it should reach a
|
||||
// mailbox.
|
||||
|
||||
const EMAIL_MODES = [
|
||||
{ value: 'off', label: 'No email' },
|
||||
{ value: 'digest', label: 'Daily digest' },
|
||||
{ value: 'immediate', label: 'Every post' },
|
||||
]
|
||||
|
||||
// Streams whose scoping lives in this page's second block rather than in the
|
||||
// first. Shown as a group so a user does not toggle `team.forum.post` off site-
|
||||
// wide when what they meant was "not this one guild".
|
||||
const isTeamStream = (id) => String(id).startsWith('team.')
|
||||
|
||||
function Section({ title, hint, children }) {
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 26, marginTop: 26 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: 'var(--head)' }}>{title}</h2>
|
||||
{hint && <p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.86rem' }}>{hint}</p>}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Note({ msg, error }) {
|
||||
if (!msg && !error) return null
|
||||
return (
|
||||
<p className="sans" style={{ margin: '10px 0 0', color: error ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}>
|
||||
{error || msg}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// ── What to be told about ──────────────────────────────────────────────────
|
||||
|
||||
function Streams({ streams, subscribed, onSave, busy, msg, error }) {
|
||||
const [set, setSet] = useState(() => new Set(subscribed))
|
||||
useEffect(() => { setSet(new Set(subscribed)) }, [subscribed])
|
||||
|
||||
const toggle = (id) => {
|
||||
const next = new Set(set)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
setSet(next)
|
||||
}
|
||||
|
||||
const team = streams.filter((s) => isTeamStream(s.id))
|
||||
const rest = streams.filter((s) => !isTeamStream(s.id))
|
||||
|
||||
const row = (s) => (
|
||||
<label key={s.id} className="sans" style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: '0.92rem' }}>
|
||||
<input type="checkbox" checked={set.has(s.id)} onChange={() => toggle(s.id)} style={{ marginTop: 3 }} />
|
||||
<span>
|
||||
<span style={{ color: 'var(--ink)' }}>{s.label}</span>
|
||||
{s.description && <span className="dim" style={{ display: 'block', fontSize: '0.82rem' }}>{s.description}</span>}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="What to notify me about"
|
||||
hint="Applies to every device you have signed in on. Notifications are delivered to the app; the website itself does not pop anything up."
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 12 }}>{rest.map(row)}</div>
|
||||
{team.length > 0 && (
|
||||
<>
|
||||
<h3 className="sans dim" style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '20px 0 10px' }}>
|
||||
Teams
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gap: 12 }}>{team.map(row)}</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave([...set])}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
<Note msg={msg} error={error} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Which Teams, and whether by email ──────────────────────────────────────
|
||||
|
||||
function Teams({ teams, onSave, busy, msg, error }) {
|
||||
const [rows, setRows] = useState(teams)
|
||||
useEffect(() => { setRows(teams) }, [teams])
|
||||
|
||||
const patch = (teamId, change) =>
|
||||
setRows((rs) => rs.map((r) => (r.teamId === teamId ? { ...r, ...change } : r)))
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Section title="Teams">
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem', margin: 0 }}>
|
||||
You are not in a team, and nobody has given you access to a team forum. There is nothing to
|
||||
configure here yet.
|
||||
</p>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Teams"
|
||||
hint="Muting a team silences all four team notifications for it, without changing anything for your other teams. Email is off until you turn it on."
|
||||
>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
<th style={{ padding: '8px 10px' }}>Team</th>
|
||||
<th style={{ padding: '8px 10px' }}>Notifications</th>
|
||||
<th style={{ padding: '8px 10px' }}>Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((t) => (
|
||||
<tr key={t.teamId} style={{ borderTop: '1px solid var(--line-soft)' }}>
|
||||
<td className="sans" style={{ padding: '10px', color: 'var(--ink)' }}>
|
||||
{t.name}
|
||||
{/* An archived Team is still listed when a preference exists for
|
||||
it, so a mute does not silently vanish when a guild disbands
|
||||
and reappear if it re-forms under the same name. */}
|
||||
{t.archived && <span className="dim" style={{ fontSize: '0.78rem' }}> · archived</span>}
|
||||
</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<label className="sans" style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: '0.88rem' }}>
|
||||
<input type="checkbox" checked={!t.muted} onChange={() => patch(t.teamId, { muted: !t.muted })} />
|
||||
<span className="dim">{t.muted ? 'Muted' : 'On'}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<select
|
||||
className="input"
|
||||
value={t.emailMode}
|
||||
onChange={(e) => patch(t.teamId, { emailMode: e.target.value })}
|
||||
style={{ fontSize: '0.88rem' }}
|
||||
>
|
||||
{EMAIL_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave(rows)}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
<Note msg={msg} error={error} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PlayerNotifications() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [streams, setStreams] = useState([])
|
||||
const [subscribed, setSubscribed] = useState([])
|
||||
const [teams, setTeams] = useState([])
|
||||
const [saving, setSaving] = useState({ streams: false, teams: false })
|
||||
const [notes, setNotes] = useState({ streams: '', teams: '', streamsError: '', teamsError: '' })
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Three reads in parallel: the catalog is boot-fixed, the subscriptions and
|
||||
// the Team list are this user's. None depends on another.
|
||||
const [cat, subs, prefs] = await Promise.all([
|
||||
api.notificationStreams(),
|
||||
api.notificationSubscriptions(),
|
||||
api.teamNotificationPrefs(),
|
||||
])
|
||||
setStreams(cat.streams || [])
|
||||
setSubscribed(subs.streams || [])
|
||||
setTeams(prefs.teams || [])
|
||||
setError('')
|
||||
} catch {
|
||||
setError('Could not load your notification settings.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const saveStreams = useCallback(async (ids) => {
|
||||
setSaving((s) => ({ ...s, streams: true }))
|
||||
setNotes((n) => ({ ...n, streams: '', streamsError: '' }))
|
||||
try {
|
||||
const { streams: stored } = await api.setNotificationSubscriptions(ids)
|
||||
setSubscribed(stored || [])
|
||||
setNotes((n) => ({ ...n, streams: 'Saved.' }))
|
||||
} catch {
|
||||
setNotes((n) => ({ ...n, streamsError: 'Could not save that.' }))
|
||||
} finally {
|
||||
setSaving((s) => ({ ...s, streams: false }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const saveTeams = useCallback(async (rows) => {
|
||||
setSaving((s) => ({ ...s, teams: true }))
|
||||
setNotes((n) => ({ ...n, teams: '', teamsError: '' }))
|
||||
try {
|
||||
// The whole set, every time, and the array is sent even when empty — the
|
||||
// endpoint requires the field (docs/android/PLAN.md §11).
|
||||
const { teams: stored } = await api.setTeamNotificationPrefs(
|
||||
rows.map((t) => ({ teamId: t.teamId, muted: t.muted, emailMode: t.emailMode })),
|
||||
)
|
||||
setTeams(stored || [])
|
||||
setNotes((n) => ({ ...n, teams: 'Saved.' }))
|
||||
} catch {
|
||||
setNotes((n) => ({ ...n, teamsError: 'Could not save that.' }))
|
||||
} finally {
|
||||
setSaving((s) => ({ ...s, teams: false }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
||||
Choose what you are told about, and how. Nothing here is on by default except team
|
||||
notifications to the app, which you can mute per team below.
|
||||
</p>
|
||||
<Streams
|
||||
streams={streams}
|
||||
subscribed={subscribed}
|
||||
onSave={saveStreams}
|
||||
busy={saving.streams}
|
||||
msg={notes.streams}
|
||||
error={notes.streamsError}
|
||||
/>
|
||||
<Teams
|
||||
teams={teams}
|
||||
onSave={saveTeams}
|
||||
busy={saving.teams}
|
||||
msg={notes.teams}
|
||||
error={notes.teamsError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ 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 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>
|
||||
|
||||
// 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
|
||||
@@ -47,6 +48,7 @@ const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-1
|
||||
// with `order: 0`.
|
||||
export const NAV = [
|
||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||
{ to: '/account/notifications', label: 'Notifications', icon: IconBell },
|
||||
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
||||
]
|
||||
|
||||
@@ -56,6 +58,7 @@ export const NAV = [
|
||||
const TITLES = {
|
||||
'/account': 'Account',
|
||||
'/account/appeals': 'Appeals',
|
||||
'/account/notifications': 'Notifications',
|
||||
}
|
||||
|
||||
function moduleTitle(baseNav, pathname) {
|
||||
|
||||
69
client/src/routes/player/Unsubscribe.jsx
Normal file
69
client/src/routes/player/Unsubscribe.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The landing page for the unsubscribe link in a Team notification email
|
||||
// (TEAMS.md §6.4).
|
||||
//
|
||||
// **Public, and it must be**: the person reading it is in their mail client, not
|
||||
// signed in, and an unsubscribe that first demands a login is one most people do
|
||||
// not complete. The token in the path is what stands in for the session.
|
||||
//
|
||||
// **The page POSTs; the link the user clicked was a GET.** A GET must not mutate —
|
||||
// mail clients and security scanners follow links in messages, and one that did
|
||||
// would silently mute Teams nobody asked to leave. So the link lands here, this
|
||||
// runs one POST, and the API route that shares the path answers GET with a
|
||||
// redirect to exactly this page.
|
||||
//
|
||||
// **It says the same thing whatever the token was.** A page that distinguished a
|
||||
// valid token from a forged one would be an oracle for which (user, Team) pairs
|
||||
// exist, on a surface with no session behind it. The server always answers 200 and
|
||||
// this always says the same sentence.
|
||||
|
||||
export default function Unsubscribe() {
|
||||
const { token } = useParams()
|
||||
const [state, setState] = useState('working')
|
||||
// React 18 StrictMode mounts an effect twice in development. The POST is
|
||||
// idempotent (it sets a boolean), so a second call is harmless — but it is
|
||||
// still a second request for no reason, and the guard keeps the network panel
|
||||
// honest for anyone debugging this page.
|
||||
const fired = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (fired.current) return
|
||||
fired.current = true
|
||||
api.unsubscribeTeam(token)
|
||||
.then(() => setState('done'))
|
||||
// A network failure is the ONE case worth distinguishing, because it is the
|
||||
// one where trying again helps. A rejected token is not: the server does not
|
||||
// tell us, deliberately.
|
||||
.catch(() => setState('failed'))
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website" shell="narrow">
|
||||
<PageHeader eyebrow="Notifications" title="Unsubscribe" />
|
||||
{state === 'working' && <p className="sans dim">One moment…</p>}
|
||||
{state === 'done' && (
|
||||
<>
|
||||
<p className="sans" style={{ color: 'var(--ink)' }}>
|
||||
You will not receive further notification emails about this team.
|
||||
</p>
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem' }}>
|
||||
This muted the team rather than switching off your account’s email, so your other
|
||||
teams are unaffected. You can turn it back on any time under{' '}
|
||||
<Link to="/account/notifications">notification settings</Link>.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{state === 'failed' && (
|
||||
<p className="sans" style={{ color: 'var(--ink)' }}>
|
||||
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>.
|
||||
</p>
|
||||
)}
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
87
client/test/teamNotify.test.js
Normal file
87
client/test/teamNotify.test.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { test, beforeEach, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { api } from '../src/api/client.js'
|
||||
|
||||
// The client half of Team notifications (docs/website/TEAMS.md Part 6, phase 6).
|
||||
//
|
||||
// There is no DOM in this runner, so what is asserted here is the WIRE — which is
|
||||
// where this feature's client-side mistakes actually live. Two of them have
|
||||
// already been made once in this repo and are recorded rather than re-derived:
|
||||
//
|
||||
// 1. **A PUT-the-whole-set body must always carry its array**, empty included.
|
||||
// `docs/android/PLAN.md` §11: a DTO field with a default is dropped by
|
||||
// kotlinx when it equals that default, so "clear the last entry" arrives as a
|
||||
// body with no array at all and 400s. The web client has no such
|
||||
// serialisation quirk, but it shares the endpoint's contract, and a test that
|
||||
// pins the shape here is what keeps the two clients honest about the same
|
||||
// rule.
|
||||
// 2. **The unsubscribe call is a POST**, not the GET the link in the mail was.
|
||||
// A GET that mutated would be triggered by every mail-client link scanner.
|
||||
|
||||
let calls
|
||||
const realFetch = global.fetch
|
||||
|
||||
function reply(body = {}) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: async () => JSON.stringify(body),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = []
|
||||
global.fetch = async (url, opts = {}) => {
|
||||
calls.push({ url, opts })
|
||||
return reply({ teams: [], streams: [], ok: true })
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => { global.fetch = realFetch })
|
||||
|
||||
const body = (i = 0) => JSON.parse(calls[i].opts.body)
|
||||
|
||||
test('the per-Team preference endpoints sit under /auth/me, not /player', async () => {
|
||||
await api.teamNotificationPrefs()
|
||||
// Role-agnostic self-service, the same rule that put the Team forum under
|
||||
// /player rather than behind a staff gate: staff are a superset of players and
|
||||
// manage their own notifications like anyone else.
|
||||
assert.match(calls[0].url, /\/auth\/me\/notifications\/teams$/)
|
||||
assert.equal(calls[0].opts.method ?? 'GET', 'GET')
|
||||
})
|
||||
|
||||
test('saving preferences PUTs the whole set under a `teams` key', async () => {
|
||||
await api.setTeamNotificationPrefs([{ teamId: 3, muted: true, emailMode: 'digest' }])
|
||||
assert.equal(calls[0].opts.method, 'PUT')
|
||||
assert.deepEqual(body(), { teams: [{ teamId: 3, muted: true, emailMode: 'digest' }] })
|
||||
})
|
||||
|
||||
test('clearing every preference still sends the array, never an absent key', async () => {
|
||||
await api.setTeamNotificationPrefs([])
|
||||
assert.deepEqual(body(), { teams: [] })
|
||||
assert.equal('teams' in body(), true)
|
||||
})
|
||||
|
||||
test('the same rule holds for the stream subscriptions beside them', async () => {
|
||||
await api.setNotificationSubscriptions([])
|
||||
assert.deepEqual(body(), { streams: [] })
|
||||
})
|
||||
|
||||
test('unsubscribe is a POST to the public tier, with the token encoded into the path', async () => {
|
||||
await api.unsubscribeTeam('1.7.3.abcDEF')
|
||||
assert.equal(calls[0].opts.method, 'POST')
|
||||
assert.match(calls[0].url, /\/public\/teams\/unsubscribe\/1\.7\.3\.abcDEF$/)
|
||||
})
|
||||
|
||||
test('a token with url-unsafe characters is encoded rather than pasted in', async () => {
|
||||
await api.unsubscribeTeam('a/b c')
|
||||
assert.match(calls[0].url, /unsubscribe\/a%2Fb%20c$/)
|
||||
})
|
||||
|
||||
test('the streams catalog and subscriptions are separate reads', async () => {
|
||||
await api.notificationStreams()
|
||||
await api.notificationSubscriptions()
|
||||
assert.match(calls[0].url, /\/notifications\/streams$/)
|
||||
assert.match(calls[1].url, /\/notifications\/subscriptions$/)
|
||||
})
|
||||
@@ -1266,6 +1266,45 @@ CREATE TABLE IF NOT EXISTS team_activity (
|
||||
INDEX idx_team_activity_feed (team_id, occurred_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Per-Team notification preference (TEAMS.md §6.3/§6.4, phase 6). OPT-OUT, not
|
||||
-- opt-in: a user in a single Team must never have to configure anything, so the
|
||||
-- absence of a row is the default and every column here is a deviation from it.
|
||||
--
|
||||
-- Team scoping lives HERE and in the recipient computation, never in a stream id.
|
||||
-- The push catalog is a static registration validated at boot against a namespaced
|
||||
-- pattern; it cannot express one stream per Team, and stream ids are stored in
|
||||
-- notification_subscriptions rows that would then need garbage-collecting every
|
||||
-- time a Team archived. Four fixed streams plus this table is the same feature
|
||||
-- with nothing to collect.
|
||||
--
|
||||
-- `last_digest_at` is the digest's ONLY state. There is no queue of pending items:
|
||||
-- the worker asks what arrived after this timestamp and re-runs the access
|
||||
-- resolver, so a deployment that was down for a day sends one correct digest
|
||||
-- rather than replaying a backlog, and a user who lost forum access between the
|
||||
-- post and the send is not emailed content they can no longer read.
|
||||
CREATE TABLE IF NOT EXISTS team_notification_prefs (
|
||||
user_id INT NOT NULL,
|
||||
team_id INT NOT NULL,
|
||||
muted TINYINT(1) NOT NULL DEFAULT 0,
|
||||
-- 'off', and NOT the design-of-record's 'digest'. Digest-by-default would mean
|
||||
-- every member of every Team starts receiving daily mail the moment an operator
|
||||
-- connects Gmail, which is a decision about other people's inboxes made on their
|
||||
-- behalf. Email is therefore the one sink here that is opt-IN; the mute is still
|
||||
-- opt-out, because a mute silences something the user already asked for.
|
||||
--
|
||||
-- It also keeps this column honest as a deviation-from-default: a row written to
|
||||
-- set `muted` alone leaves email exactly where it was.
|
||||
email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off',
|
||||
last_digest_at DATETIME NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, team_id),
|
||||
CONSTRAINT fk_tnp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tnp_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
-- The digest worker's driving query is "rows in digest mode, oldest send first",
|
||||
-- which is a scan of this index rather than of every preference ever written.
|
||||
INDEX idx_tnp_digest (email_mode, last_digest_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||
|
||||
@@ -1387,6 +1387,26 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/teams",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/auth/me/notifications/teams",
|
||||
"handlers": 6,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/sessions",
|
||||
@@ -1944,6 +1964,18 @@
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/unsubscribe/:token",
|
||||
"handlers": 1,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/teams/unsubscribe/:token",
|
||||
"handlers": 1,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/version",
|
||||
|
||||
@@ -549,6 +549,14 @@
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/auth/me/notifications/subscriptions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/teams"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/auth/me/notifications/teams"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/sessions"
|
||||
@@ -789,6 +797,14 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/by-external/:moduleId/:externalId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/teams/unsubscribe/:token"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/teams/unsubscribe/:token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/version"
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
//
|
||||
// What is left of config/notificationStreams.js once the shard-derived catalog
|
||||
// moved to config/shardStreams.js (MODULE_SYSTEM.md §1.8: push INFRASTRUCTURE is
|
||||
// core, the CATALOG is content). Exactly one stream is core's: `news.post` is
|
||||
// produced by the website's own posts path, not by any game feed.
|
||||
// core, the CATALOG is content). `news.post` is produced by the website's own
|
||||
// posts path, not by any game feed, and the four `team.*` streams by core's own
|
||||
// Team sync and forum.
|
||||
//
|
||||
// Registered through modules/registries.js like any module's, and read back
|
||||
// through it — nothing imports this file to get "the catalog", because the
|
||||
// catalog is core's plus every module's.
|
||||
//
|
||||
// Phase 6 added the four Team streams below. They are core's for the same reason
|
||||
// the Team tables are: a module supplies who is in a Team, but who may be told
|
||||
// about it is the access resolver's answer, and that is core's (TEAMS.md Part 6).
|
||||
//
|
||||
// The payload that ever leaves the server is a CONTENT-FREE tickle
|
||||
// ({ stream, ref }); the app wakes and PULLS the real, ownership-checked content
|
||||
// over the authenticated API (docs/android/PLAN.md §11).
|
||||
@@ -21,6 +26,55 @@ const STREAMS = [
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
// ── Teams (TEAMS.md §6.2, phase 6) ───────────────────────────────────────
|
||||
//
|
||||
// FOUR streams, and not one per Team. The catalog is a static registration
|
||||
// validated at boot; it has no way to express an unbounded runtime-created set,
|
||||
// and a stream id per Team would leave rows in notification_subscriptions to
|
||||
// collect every time a Team archived. Which Team an event came from lives in
|
||||
// the RECIPIENT SET (utils/teamNotify.js) and in the `ref`, never in the id.
|
||||
//
|
||||
// `requiresLinkedAccount: false` on all four is deliberate and reads oddly.
|
||||
// These are game-sourced events, so the instinct is to demand a linked game
|
||||
// account — but a forum-granted user with no game identity at all is exactly
|
||||
// the population §2.5 path 3 exists for, and they are a legitimate recipient of
|
||||
// `team.forum.post`. The flag would refuse them a toggle they have every right
|
||||
// to. What enforces who gets what is the recipient computation, which asks the
|
||||
// access resolver; the stream flag is not a second, weaker copy of that rule.
|
||||
//
|
||||
// `personal: false` for the same reason it is false on news.post: these are not
|
||||
// owner-keyed events about one account's own property. `publishToUsers` is a
|
||||
// third fan-out shape alongside "everyone subscribed" and "this one owner", and
|
||||
// the catalog has no flag for it because the flag would say nothing a caller
|
||||
// does not already know by choosing the function.
|
||||
{
|
||||
id: 'team.member.joined',
|
||||
label: 'Team — new member',
|
||||
description: 'Someone joined a Team you belong to.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'team.leadership.changed',
|
||||
label: 'Team — leadership change',
|
||||
description: 'Leadership changed in a Team you belong to.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'team.forum.post',
|
||||
label: 'Team — new forum post',
|
||||
description: 'A new thread or reply in a Team forum you can read.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'team.announcement',
|
||||
label: 'Team — announcements',
|
||||
description: 'A leader posted an announcement in a Team you can read.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { STREAMS }
|
||||
|
||||
@@ -48,4 +48,44 @@ const endpointsForUserStream = (userId, streamId) =>
|
||||
[userId, streamId],
|
||||
)
|
||||
|
||||
module.exports = { upsert, getByUserEndpoint, listByUser, remove, endpointsForStream, endpointsForUserStream }
|
||||
// `Number.isInteger` alone is not enough: `Number(null)` is 0 and 0 is an
|
||||
// integer, so a null slipping into a caller's list would become user id 0 and
|
||||
// ride into an IN clause. No row has id 0, so it is harmless today — which is
|
||||
// exactly why it would never be noticed.
|
||||
const isUserId = (n) => Number.isInteger(n) && n > 0
|
||||
|
||||
// Endpoints of a COMPUTED SET of users' devices, each still gated on that user's
|
||||
// own subscription (TEAMS.md §6.2's third fan-out shape).
|
||||
//
|
||||
// The set is the whole Team-scoping mechanism: the four `team.*` streams are
|
||||
// global, and which Team an event belongs to is expressed by who is in `userIds`
|
||||
// rather than by a stream id per Team. The caller has already resolved access and
|
||||
// subtracted mutes; this function's only remaining job is to honour each
|
||||
// recipient's own opt-in, which is why the JOIN is here and not left to the
|
||||
// caller — a fan-out that skipped it would deliver to a user who had turned the
|
||||
// stream off.
|
||||
//
|
||||
// Returns [] for an empty set rather than building `IN ()`, which is a syntax
|
||||
// error in MariaDB. That case is common, not exceptional: most Team events have
|
||||
// no subscribed recipients on a deployment with no app installed at all.
|
||||
async function endpointsForUsersStream(userIds, streamId) {
|
||||
const ids = [...new Set((userIds || []).map(Number).filter(isUserId))]
|
||||
if (ids.length === 0) return []
|
||||
return query(
|
||||
`SELECT d.endpoint, d.transport
|
||||
FROM push_devices d
|
||||
JOIN notification_subscriptions s ON s.user_id = d.user_id
|
||||
WHERE s.stream_id = ? AND d.user_id IN (${ids.map(() => '?').join(',')})`,
|
||||
[streamId, ...ids],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsert,
|
||||
getByUserEndpoint,
|
||||
listByUser,
|
||||
remove,
|
||||
endpointsForStream,
|
||||
endpointsForUserStream,
|
||||
endpointsForUsersStream,
|
||||
}
|
||||
|
||||
@@ -28,5 +28,13 @@ const remove = async (id, userId) => (await db.remove(id, userId)) > 0
|
||||
// Fan-out helpers: raw { endpoint, transport } rows (not toSafe-shaped).
|
||||
const endpointsForStream = (streamId) => db.endpointsForStream(streamId)
|
||||
const endpointsForUserStream = (userId, streamId) => db.endpointsForUserStream(userId, streamId)
|
||||
const endpointsForUsersStream = (userIds, streamId) => db.endpointsForUsersStream(userIds, streamId)
|
||||
|
||||
module.exports = { register, listForUser, remove, endpointsForStream, endpointsForUserStream }
|
||||
module.exports = {
|
||||
register,
|
||||
listForUser,
|
||||
remove,
|
||||
endpointsForStream,
|
||||
endpointsForUserStream,
|
||||
endpointsForUsersStream,
|
||||
}
|
||||
|
||||
@@ -217,7 +217,17 @@ async function createThread({ team, actor, type, title, body }) {
|
||||
authorUsername: actor.username,
|
||||
bodyHtml: cleaned,
|
||||
})
|
||||
return { ok: true, threadId, postId }
|
||||
// `notify` is what the CONTROLLER needs to fan a notification out, and it is a
|
||||
// separate key rather than more fields on the result because the controller
|
||||
// spreads the result straight into the response body — a notification's excerpt
|
||||
// is not part of the API's answer to "did my post save".
|
||||
//
|
||||
// The notification itself is fired from the controller and not from here, on
|
||||
// this file's own rule (see the header): everything in it takes an
|
||||
// already-resolved access decision and reads no membership table. The fan-out
|
||||
// reads both, so importing it here would make the forum model transitively
|
||||
// depend on exactly what it exists not to touch.
|
||||
return { ok: true, threadId, postId, notify: { threadId, title, type, bodyHtml: cleaned } }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,7 +266,11 @@ async function createPost({ team, threadId, actor, body }) {
|
||||
authorUsername: actor.username,
|
||||
bodyHtml: cleaned,
|
||||
})
|
||||
return { ok: true, threadId, postId }
|
||||
// The thread's OWN title and type, not the reply's — a reply has neither, and
|
||||
// what a recipient needs to know is which conversation moved. `type` is always
|
||||
// 'discussion' here (an announcement takes no replies) and is carried anyway so
|
||||
// the controller has one shape to hand the fan-out from both routes.
|
||||
return { ok: true, threadId, postId, notify: { threadId, title: thread.title, type: thread.type, bodyHtml: cleaned } }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
223
server/src/model/teams/teamNotify.db.js
Normal file
223
server/src/model/teams/teamNotify.db.js
Normal file
@@ -0,0 +1,223 @@
|
||||
// SQL for Team notification recipients and per-Team preferences (TEAMS.md Part 6).
|
||||
//
|
||||
// **The recipient set is the whole of Team scoping.** The four `team.*` streams
|
||||
// are global and carry no Team in their id; who an event reaches is decided here.
|
||||
// That is §6.2's design and it is not an optimisation — the push catalog is a
|
||||
// static registration validated at boot, so a stream per Team is unexpressible,
|
||||
// and stream ids live in `notification_subscriptions` rows that a per-Team id
|
||||
// would leave behind every time a Team archived.
|
||||
//
|
||||
// **One recipient query serves all four streams**, because the two populations in
|
||||
// §6.2's table are the same set written twice: "active members with a user_id,
|
||||
// plus active forum grants" IS "everyone with resolved forum access", by the
|
||||
// definition of teamAccess.forumAccess() (membership OR grant). What differs
|
||||
// between the streams is only who is subtracted — the author of the post that
|
||||
// caused it — and that is a caller's argument, not a second query.
|
||||
//
|
||||
// **Mutes are subtracted in SQL, not in the caller.** A recipient list that came
|
||||
// back complete and was filtered afterwards would be one refactor away from being
|
||||
// used unfiltered; there is no function here that returns an unmuted set.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// The union, as a derived table both recipient functions build on. Written once
|
||||
// so that "who is in a Team for notification purposes" has exactly one definition.
|
||||
//
|
||||
// `status = 'active'` on the membership half and `revoked_at IS NULL` on the
|
||||
// grant half are the same two conditions the access resolver uses; a departed
|
||||
// member and a revoked guest are both people who could still be read a private
|
||||
// forum by a query that forgot one.
|
||||
const RECIPIENT_UNION = `
|
||||
SELECT user_id FROM team_members
|
||||
WHERE team_id = ? AND status = 'active' AND user_id IS NOT NULL
|
||||
UNION
|
||||
SELECT user_id FROM team_forum_grants
|
||||
WHERE team_id = ? AND revoked_at IS NULL`
|
||||
|
||||
// `Number.isInteger` alone is not enough: `Number(null)` is 0 and 0 is an
|
||||
// integer, so a null slipping into a caller's list would become user id 0 and
|
||||
// ride into an IN clause. No row has id 0, so it is harmless today — which is
|
||||
// exactly why it would never be noticed.
|
||||
const isUserId = (n) => Number.isInteger(n) && n > 0
|
||||
|
||||
/**
|
||||
* Every user id that may be notified about `teamId`, mutes already removed.
|
||||
*
|
||||
* `exclude` is the author of the thing that happened. Passed rather than removed
|
||||
* afterwards for the reason in the header, and taken as a list because a caller
|
||||
* with nobody to exclude should not have to invent a sentinel.
|
||||
*/
|
||||
async function recipientIds(teamId, { exclude = [] } = {}) {
|
||||
const skip = [...new Set(exclude.map(Number).filter(isUserId))]
|
||||
const notMe = skip.length ? `AND r.user_id NOT IN (${skip.map(() => '?').join(',')})` : ''
|
||||
const rows = await query(
|
||||
`SELECT DISTINCT r.user_id
|
||||
FROM (${RECIPIENT_UNION}) r
|
||||
LEFT JOIN team_notification_prefs p ON p.user_id = r.user_id AND p.team_id = ?
|
||||
WHERE COALESCE(p.muted, 0) = 0 ${notMe}`,
|
||||
[teamId, teamId, teamId, ...skip],
|
||||
)
|
||||
return rows.map((r) => Number(r.user_id))
|
||||
}
|
||||
|
||||
/**
|
||||
* The same set, narrowed to those reachable by EMAIL and carrying each one's mode.
|
||||
*
|
||||
* A separate query rather than a join onto `recipientIds` because email has two
|
||||
* conditions push does not: an address to send to, and an account still allowed to
|
||||
* have one. A banned or disabled account keeps its forum grant in the ledger —
|
||||
* revoking it is a separate staff decision — but must not keep receiving the
|
||||
* Team's private discussion in its inbox.
|
||||
*
|
||||
* `email_mode` is COALESCEd to the column default rather than read as NULL — and
|
||||
* that default is `'off'`, so this query returns the whole set with most of it
|
||||
* marked as not wanting mail. Filtering to a mode is the CALLER's job, because
|
||||
* `immediate` and `digest` are consumed by two different senders.
|
||||
*/
|
||||
async function emailRecipients(teamId, { exclude = [] } = {}) {
|
||||
const skip = [...new Set(exclude.map(Number).filter(isUserId))]
|
||||
const notMe = skip.length ? `AND u.id NOT IN (${skip.map(() => '?').join(',')})` : ''
|
||||
return query(
|
||||
`SELECT u.id AS user_id, u.username, u.email,
|
||||
COALESCE(p.email_mode, 'off') AS email_mode,
|
||||
p.last_digest_at
|
||||
FROM (${RECIPIENT_UNION}) r
|
||||
JOIN users u ON u.id = r.user_id
|
||||
LEFT JOIN team_notification_prefs p ON p.user_id = u.id AND p.team_id = ?
|
||||
WHERE COALESCE(p.muted, 0) = 0
|
||||
AND u.email IS NOT NULL AND u.email <> ''
|
||||
AND u.status = 'active' ${notMe}
|
||||
GROUP BY u.id, u.username, u.email, p.email_mode, p.last_digest_at`,
|
||||
[teamId, teamId, teamId, ...skip],
|
||||
)
|
||||
}
|
||||
|
||||
// ── Preferences ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One row per Team this user may be notified about, whether or not a preference
|
||||
* has ever been written for it — the account screen has to offer a Team the user
|
||||
* has never touched, and a list built from the prefs table alone would be empty
|
||||
* for exactly the users who have configured nothing.
|
||||
*
|
||||
* Archived Teams appear only when a preference row exists for them, so a mute the
|
||||
* user set does not vanish from the screen the moment a guild disbands, while a
|
||||
* disbanded guild nobody configured does not linger on it forever.
|
||||
*/
|
||||
async function prefsForUser(userId) {
|
||||
return query(
|
||||
`SELECT t.id AS team_id, t.slug, t.name, t.display_name_override, t.status AS team_status,
|
||||
COALESCE(p.muted, 0) AS muted,
|
||||
COALESCE(p.email_mode, 'off') AS email_mode
|
||||
FROM teams t
|
||||
LEFT JOIN team_notification_prefs p ON p.team_id = t.id AND p.user_id = ?
|
||||
WHERE (
|
||||
EXISTS (SELECT 1 FROM team_members m
|
||||
WHERE m.team_id = t.id AND m.user_id = ? AND m.status = 'active')
|
||||
OR EXISTS (SELECT 1 FROM team_forum_grants g
|
||||
WHERE g.team_id = t.id AND g.user_id = ? AND g.revoked_at IS NULL)
|
||||
OR p.user_id IS NOT NULL
|
||||
)
|
||||
ORDER BY t.status, t.name`,
|
||||
[userId, userId, userId],
|
||||
)
|
||||
}
|
||||
|
||||
/** One Team's preference for one user, or undefined. Read by the mute toggle. */
|
||||
async function prefFor(userId, teamId) {
|
||||
const rows = await query(
|
||||
`SELECT team_id, muted, email_mode, last_digest_at
|
||||
FROM team_notification_prefs WHERE user_id = ? AND team_id = ?`,
|
||||
[userId, teamId],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one preference.
|
||||
*
|
||||
* An upsert that touches ONLY the columns it was given: the one-click unsubscribe
|
||||
* writes `muted` and must not reset an `email_mode` the user chose, and the
|
||||
* settings screen writes both. `last_digest_at` is never written here — it is the
|
||||
* worker's column, and a preference change must not look like a delivery.
|
||||
*/
|
||||
async function setPref(userId, teamId, { muted, emailMode }) {
|
||||
const sets = ['updated_at = CURRENT_TIMESTAMP']
|
||||
if (muted != null) sets.push('muted = VALUES(muted)')
|
||||
if (emailMode != null) sets.push('email_mode = VALUES(email_mode)')
|
||||
await query(
|
||||
`INSERT INTO team_notification_prefs (user_id, team_id, muted, email_mode)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE ${sets.join(', ')}`,
|
||||
[userId, teamId, muted ? 1 : 0, emailMode || 'off'],
|
||||
)
|
||||
}
|
||||
|
||||
/** Stamp a digest as delivered. The worker's column, and its only writer. */
|
||||
async function stampDigest(userId, teamId, at) {
|
||||
await query(
|
||||
`INSERT INTO team_notification_prefs (user_id, team_id, last_digest_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE last_digest_at = VALUES(last_digest_at)`,
|
||||
[userId, teamId, at],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Active Teams that have had forum activity since `since` — the digest worker's
|
||||
* driving query.
|
||||
*
|
||||
* Driven from ACTIVITY rather than from the prefs table, which is what makes the
|
||||
* worker's cost proportional to what was WRITTEN rather than to how many people
|
||||
* once opened a settings screen. A Team nobody posted in costs one row of this
|
||||
* query and no recipient computation at all.
|
||||
*/
|
||||
async function teamsWithForumActivitySince(since) {
|
||||
return query(
|
||||
`SELECT DISTINCT t.id, t.slug, t.name, t.display_name_override
|
||||
FROM teams t
|
||||
JOIN team_forum_threads th ON th.team_id = t.id
|
||||
JOIN team_forum_posts po ON po.thread_id = th.id
|
||||
WHERE t.status = 'active'
|
||||
AND po.created_at > ?
|
||||
AND po.status = 'visible'
|
||||
AND th.status = 'visible'`,
|
||||
[since],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The posts one digest covers: visible posts in visible threads, newer than the
|
||||
* recipient's own `since`.
|
||||
*
|
||||
* Re-read at send time rather than accumulated at publish time. A queue of pending
|
||||
* items would have to be garbage-collected, would replay a backlog after an outage,
|
||||
* and — the reason that actually matters — could email a body a moderator hid in
|
||||
* between. This query cannot: a hidden post is simply not in it.
|
||||
*/
|
||||
async function digestPostsSince(teamId, since, limit = 20) {
|
||||
return query(
|
||||
`SELECT po.id, po.thread_id, po.body_html, po.created_at, po.author_username,
|
||||
th.title, th.type
|
||||
FROM team_forum_posts po
|
||||
JOIN team_forum_threads th ON th.id = po.thread_id
|
||||
WHERE th.team_id = ?
|
||||
AND po.created_at > ?
|
||||
AND po.status = 'visible'
|
||||
AND th.status = 'visible'
|
||||
ORDER BY po.created_at
|
||||
LIMIT ?`,
|
||||
[teamId, since, Number(limit)],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
recipientIds,
|
||||
emailRecipients,
|
||||
prefsForUser,
|
||||
prefFor,
|
||||
setPref,
|
||||
stampDigest,
|
||||
teamsWithForumActivitySince,
|
||||
digestPostsSince,
|
||||
}
|
||||
146
server/src/model/teams/teamNotify.model.js
Normal file
146
server/src/model/teams/teamNotify.model.js
Normal file
@@ -0,0 +1,146 @@
|
||||
// Per-Team notification preferences, and the recipient sets built from them
|
||||
// (TEAMS.md §6.2–§6.4, phase 6).
|
||||
//
|
||||
// **The absence of a row is the default, and the two sinks default OPPOSITE ways.**
|
||||
// Push is opt-out: a user in one Team must never have to configure anything to be
|
||||
// tickled about it, and the per-Team mute is how they stop. Email is opt-IN
|
||||
// (`email_mode` defaults to `'off'`, deviating from §6.4 on the org lead's call):
|
||||
// turning on Gmail in the admin panel must not start sending daily mail to every
|
||||
// member of every Team on the deployment.
|
||||
//
|
||||
// Both are read the same way — COALESCE to the column default, never treat a
|
||||
// missing row as "unknown" — so the asymmetry lives in ONE place, the schema, and
|
||||
// not in a condition anybody has to remember.
|
||||
//
|
||||
// **This file never decides who may READ a Team.** It asks the same two tables
|
||||
// teamAccess.forumAccess() asks, in one query, because a fan-out cannot afford a
|
||||
// round trip per recipient — but it asks them for the same answer. If the access
|
||||
// rule ever changes, both must; the SQL in teamNotify.db.js says so at the union
|
||||
// it builds on, and the test that matters is the one asserting a revoked guest
|
||||
// receives nothing.
|
||||
|
||||
const db = require('./teamNotify.db')
|
||||
|
||||
// Stored as an ENUM, restated here because a value arriving from a request body
|
||||
// must be checked against something in JavaScript before it reaches the column —
|
||||
// a bad value would otherwise be a 500 from the driver rather than a 400 from us.
|
||||
const EMAIL_MODES = ['off', 'digest', 'immediate']
|
||||
|
||||
const isEmailMode = (v) => EMAIL_MODES.includes(v)
|
||||
|
||||
function publicPref(row) {
|
||||
return {
|
||||
teamId: Number(row.team_id),
|
||||
slug: row.slug,
|
||||
// The same `display_name_override || name` rule every other Team surface
|
||||
// uses (§2.8.3). A notification screen showing the raw name would show a name
|
||||
// staff have deliberately replaced everywhere else.
|
||||
name: row.display_name_override || row.name,
|
||||
archived: row.team_status === 'archived',
|
||||
muted: Boolean(Number(row.muted)),
|
||||
emailMode: row.email_mode,
|
||||
}
|
||||
}
|
||||
|
||||
/** Every Team this user could be notified about, with its current preference. */
|
||||
async function listPrefs(userId) {
|
||||
return (await db.prefsForUser(userId)).map(publicPref)
|
||||
}
|
||||
|
||||
/** One Team's preference for one user, defaults applied. Never null. */
|
||||
async function prefFor(userId, teamId) {
|
||||
const row = await db.prefFor(userId, teamId)
|
||||
return {
|
||||
teamId: Number(teamId),
|
||||
muted: Boolean(row && Number(row.muted)),
|
||||
emailMode: (row && row.email_mode) || 'off',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace this user's whole set of Team preferences.
|
||||
*
|
||||
* PUT-the-whole-set, matching the existing subscription endpoint, and the
|
||||
* Android gotcha carried forward from `docs/android/PLAN.md` §11 applies to the
|
||||
* ROUTE rather than to this function: the array is required even when empty.
|
||||
*
|
||||
* **A preference may only be written for a Team the caller is actually in.** The
|
||||
* ids are checked against `listPrefs`, not trusted from the body — otherwise any
|
||||
* authenticated user could write a row naming any Team, which is a (small) write
|
||||
* primitive into a table keyed by someone else's private membership. Unknown ids
|
||||
* are dropped rather than 400'd: a Team the user left between loading the screen
|
||||
* and saving it is an ordinary race, not a client bug.
|
||||
*/
|
||||
async function replacePrefs(userId, entries) {
|
||||
const allowed = new Map((await listPrefs(userId)).map((p) => [p.teamId, p]))
|
||||
const written = []
|
||||
for (const entry of entries) {
|
||||
const teamId = Number(entry && entry.teamId)
|
||||
if (!allowed.has(teamId)) continue
|
||||
const emailMode = isEmailMode(entry.emailMode) ? entry.emailMode : 'off'
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.setPref(userId, teamId, { muted: Boolean(entry.muted), emailMode })
|
||||
written.push(teamId)
|
||||
}
|
||||
|
||||
// A Team the caller COULD have named and did not is returned to its defaults.
|
||||
//
|
||||
// Without this, "replace the whole set" was a lie the endpoint told: omitting an
|
||||
// entry left the old preference standing, which made `teams: []` — the body the
|
||||
// route requires precisely so that clearing everything is expressible — clear
|
||||
// nothing at all.
|
||||
//
|
||||
// Reset rather than deleted, and the difference is `last_digest_at`. That column
|
||||
// is the digest worker's state, not a preference; dropping the row with it would
|
||||
// make every visit to the settings screen re-open a day-wide digest window and
|
||||
// mail somebody a summary they already read.
|
||||
for (const teamId of allowed.keys()) {
|
||||
if (written.includes(teamId)) continue
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.setPref(userId, teamId, { muted: false, emailMode: 'off' })
|
||||
}
|
||||
|
||||
return { written, prefs: await listPrefs(userId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute one Team for one user — the one-click unsubscribe's only effect.
|
||||
*
|
||||
* Deliberately narrow. The unsubscribe link is reached without a session, so what
|
||||
* it can do is what an attacker holding a leaked link can do: silence one Team's
|
||||
* notifications for one account, visibly and reversibly on the account screen.
|
||||
* It writes no other column, and there is no "unsubscribe from everything".
|
||||
*/
|
||||
async function mute(userId, teamId) {
|
||||
await db.setPref(userId, teamId, { muted: true })
|
||||
}
|
||||
|
||||
/** Un-mute, for the toggle's other half. */
|
||||
async function unmute(userId, teamId) {
|
||||
await db.setPref(userId, teamId, { muted: false })
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EMAIL_MODES,
|
||||
isEmailMode,
|
||||
listPrefs,
|
||||
prefFor,
|
||||
replacePrefs,
|
||||
mute,
|
||||
unmute,
|
||||
// Recipient sets, passed through so callers depend on the model rather than on
|
||||
// the SQL. The fan-out in utils/teamNotify.js and the digest worker are the only
|
||||
// callers.
|
||||
//
|
||||
// Wrapped rather than re-exported (`recipientIds: db.recipientIds`), which is
|
||||
// the obvious shorter form and is wrong: that captures the function OBJECT at
|
||||
// require time, so the layer below can never be substituted afterwards — which
|
||||
// makes the db layer untestable in isolation and, more to the point, means the
|
||||
// model is not really the seam it claims to be. These resolve `db.x` at call
|
||||
// time, so the boundary is real.
|
||||
recipientIds: (teamId, opts) => db.recipientIds(teamId, opts),
|
||||
emailRecipients: (teamId, opts) => db.emailRecipients(teamId, opts),
|
||||
stampDigest: (userId, teamId, at) => db.stampDigest(userId, teamId, at),
|
||||
teamsWithForumActivitySince: (since) => db.teamsWithForumActivitySince(since),
|
||||
digestPostsSince: (teamId, since, limit) => db.digestPostsSince(teamId, since, limit),
|
||||
}
|
||||
@@ -33,6 +33,7 @@ const teamsDb = require('./teams.db')
|
||||
const teamProvider = require('./teamProvider')
|
||||
const moderation = require('./teamModeration.model')
|
||||
const activity = require('./teamActivity.model')
|
||||
const teamNotify = require('../../utils/teamNotify')
|
||||
const { slugify, uniqueSlug } = require('./teamSlug')
|
||||
const settings = require('../settings/settings.model')
|
||||
const log = require('../../utils/logger')('teams')
|
||||
@@ -191,6 +192,34 @@ async function logRosterActivity(team, { joined, left, promoted, demoted }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The push half of the same roster run (TEAMS.md §6.2, phase 6).
|
||||
*
|
||||
* **At most one tickle per stream per run, not one per member.** A tickle is
|
||||
* content-free — it says "something happened in this Team" and the app pulls the
|
||||
* rest — so five people joining in one sweep is five identical notifications and
|
||||
* one piece of information. The feed above is per-member because it is a record;
|
||||
* this is per-run because it is a nudge.
|
||||
*
|
||||
* **Suppressed on a Team's FIRST roster, exactly as the feed is**, and this is the
|
||||
* half where it matters more: importing a 155-member guild would otherwise wake
|
||||
* every one of their phones. `roster_synced_at IS NULL` is the same condition, read
|
||||
* from the same row before the same stamp moves.
|
||||
*
|
||||
* Never throws — the fan-out swallows its own failures, and this adds the guard
|
||||
* for anything the surrounding read could raise. A roster sync is the source of
|
||||
* truth; a notification about it is not.
|
||||
*/
|
||||
async function notifyRoster(team, { joined, promoted, demoted }) {
|
||||
if (!team.roster_synced_at) return
|
||||
try {
|
||||
if (joined.length > 0) await teamNotify.memberJoined(team)
|
||||
if (promoted.length > 0 || demoted.length > 0) await teamNotify.leadershipChanged(team)
|
||||
} catch (err) {
|
||||
log.warn('roster notification not sent', { teamId: team.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync one Team's roster and leadership. Gates 3 and 4 live here.
|
||||
*
|
||||
@@ -285,8 +314,10 @@ async function syncRoster(team) {
|
||||
}
|
||||
|
||||
await teamsDb.recount(team.id)
|
||||
// Read before `markRosterSynced` moves the stamp this decision turns on.
|
||||
// Both read before `markRosterSynced` moves the stamp their first-roster
|
||||
// suppression turns on.
|
||||
await logRosterActivity(team, { joined, left: left.filter(Boolean), promoted, demoted })
|
||||
await notifyRoster(team, { joined, promoted, demoted })
|
||||
await teamsDb.markRosterSynced(team.id)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -255,6 +255,10 @@ function checkLegShape(entry) {
|
||||
// implementing it means core fails CLOSED when the call cannot be made, so this
|
||||
// is a member to add deliberately rather than by habit.
|
||||
//
|
||||
// `pageUrlTemplate` is the fifth, also OPTIONAL, and is data rather than a method
|
||||
// — see its own comment below. A module that omits it costs its deployment
|
||||
// clickable links in Team notification email and nothing else.
|
||||
//
|
||||
// The copy is explicit rather than a spread: this object is what core calls, so
|
||||
// anything not named here is not part of the contract and must not survive
|
||||
// registration. A method that silently rode along would look implemented from the
|
||||
@@ -274,9 +278,43 @@ function checkTeamProviderShape(entry) {
|
||||
}
|
||||
out.projectRoster = provider.projectRoster
|
||||
}
|
||||
if (provider.pageUrlTemplate !== undefined) {
|
||||
out.pageUrlTemplate = checkPageUrlTemplate(provider.pageUrlTemplate)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// `pageUrlTemplate` is the fifth member and OPTIONAL (TEAMS.md §6.4, phase 6).
|
||||
//
|
||||
// **Why a module has to supply this at all.** Teams are a contract primitive with
|
||||
// no core surface: core owns the tables and the access rules, and the MODULE owns
|
||||
// the page, because core does not own the word for a Team. That is settled and
|
||||
// right — but it leaves core unable to write a link to one, and a notification
|
||||
// email that cannot link to the thread it is about is most of the way to useless.
|
||||
// So the module that owns the page says where it is.
|
||||
//
|
||||
// **A template, not a callback.** Core substitutes `{externalId}` and `{slug}`
|
||||
// into a relative path and does nothing else with it. A function would be a
|
||||
// module hook on the mail path — one more thing that can hang or throw between a
|
||||
// forum reply and the mail about it — to produce a string that never varies.
|
||||
//
|
||||
// Validated hard, because the output goes into an email as a link. Relative only:
|
||||
// a template naming its own host would let a module redirect the site's outbound
|
||||
// mail somewhere else, and there is no reason for one to.
|
||||
// One leading slash, and the second character may not be another. `//evil.test/x`
|
||||
// passes an "is it rooted" check and is a PROTOCOL-RELATIVE url — core prefixing
|
||||
// its own base makes it harmless today, but a template is a string that ends up
|
||||
// in an href sooner or later, and this is a character class rather than a
|
||||
// judgement call about who concatenates it.
|
||||
const PAGE_URL_TEMPLATE = /^\/(?!\/)[A-Za-z0-9\-._~/{}]*$/
|
||||
|
||||
function checkPageUrlTemplate(value) {
|
||||
if (typeof value !== 'string' || !PAGE_URL_TEMPLATE.test(value)) {
|
||||
throw new Error(`registerTeamProvider: pageUrlTemplate must be a relative path, got "${value}"`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerPostHook({ onSaved, onDeleted })` — both optional, at least one
|
||||
* required. A registration with neither is a subscription that can never fire,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const pushDevices = require('../../../model/pushDevices/pushDevices.model')
|
||||
const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model')
|
||||
const registries = require('../../../modules/registries')
|
||||
const teamPrefs = require('../../../model/teams/teamNotify.model')
|
||||
const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
|
||||
|
||||
const log = require('../../../utils/logger')('notifications')
|
||||
@@ -78,6 +79,39 @@ async function putSubscriptions(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /auth/me/notifications/teams — this user's per-Team preferences, one row
|
||||
// per Team they could be notified about whether or not they have ever set one.
|
||||
//
|
||||
// Not gated on `teams_forums_enabled`: two of the four streams (member joined,
|
||||
// leadership changed) have nothing to do with the forum, so a deployment with
|
||||
// forums switched off still has preferences worth showing.
|
||||
async function getTeamPrefs(req, res) {
|
||||
try {
|
||||
return res.json({ teams: await teamPrefs.listPrefs(req.user.id) })
|
||||
} catch (err) {
|
||||
log.error('getTeamPrefs', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /auth/me/notifications/teams — replace the caller's whole preference set.
|
||||
//
|
||||
// PUT-the-whole-set, matching the subscriptions endpoint beside it, and the
|
||||
// `teams` array is REQUIRED even when empty — the Android gotcha in
|
||||
// docs/android/PLAN.md §11: a DTO field with a default is dropped by kotlinx when
|
||||
// it equals that default, so clearing the last entry would arrive as a body with
|
||||
// no array at all and 400. Entries naming a Team the caller is not in are dropped
|
||||
// by the model rather than refused here (an ordinary race, not a client bug).
|
||||
async function putTeamPrefs(req, res) {
|
||||
try {
|
||||
const { prefs } = await teamPrefs.replacePrefs(req.user.id, req.body.teams)
|
||||
return res.json({ teams: prefs })
|
||||
} catch (err) {
|
||||
log.error('putTeamPrefs', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerDevice,
|
||||
listDevices,
|
||||
@@ -85,4 +119,6 @@ module.exports = {
|
||||
getStreams,
|
||||
getSubscriptions,
|
||||
putSubscriptions,
|
||||
getTeamPrefs,
|
||||
putTeamPrefs,
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const notif = require('./notifications.controller')
|
||||
const { requireAuth } = require('../../../auth/session.middleware')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const { EMAIL_MODES } = require('../../../model/teams/teamNotify.model')
|
||||
|
||||
const notifRouter = express.Router()
|
||||
|
||||
@@ -97,4 +98,39 @@ notifRouter.put(
|
||||
notif.putSubscriptions,
|
||||
)
|
||||
|
||||
// ── Per-Team preferences (TEAMS.md §6.3, phase 6) ──────────────────────────
|
||||
//
|
||||
// The granularity per-stream opt-in cannot express: "I am in five Teams and want
|
||||
// notifications from one". Opt-OUT for push (no row means notified) and opt-IN
|
||||
// for email, so a user who never opens this screen is in the state the schema
|
||||
// documents rather than in one this router has to describe.
|
||||
notifRouter.get(
|
||||
'/notifications/teams',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Get the current user’s per-Team notification preferences'
|
||||
// #swagger.description = 'One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they have a stored preference for. Defaults are applied server-side: `muted` false, `emailMode` "off".'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Per-Team preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
notif.getTeamPrefs,
|
||||
)
|
||||
|
||||
notifRouter.put(
|
||||
'/notifications/teams',
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Replace the current user’s per-Team notification preferences'
|
||||
// #swagger.description = 'Replaces the whole set. The `teams` array is required even when empty. Entries naming a Team the caller has no access to are ignored; the stored set is echoed back.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('teams').isArray(),
|
||||
body('teams.*.teamId').isInt({ min: 1 }),
|
||||
body('teams.*.muted').optional().isBoolean(),
|
||||
body('teams.*.emailMode').optional().isIn(EMAIL_MODES),
|
||||
validate,
|
||||
notif.putTeamPrefs,
|
||||
)
|
||||
|
||||
module.exports = notifRouter
|
||||
|
||||
@@ -26,6 +26,7 @@ const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||
const uploads = require('../../../model/teams/teamForumUploads.model')
|
||||
const reports = require('../../../model/reports/contentReports.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const teamNotify = require('../../../utils/teamNotify')
|
||||
|
||||
const log = require('../../../utils/logger')('teams')
|
||||
|
||||
@@ -86,6 +87,34 @@ async function viewerFor(ctx, user) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan a new thread or reply out to the Team (TEAMS.md Part 6, phase 6).
|
||||
*
|
||||
* **Here rather than in the forum model**, because the model takes an
|
||||
* already-resolved access decision and reads no membership table by design, and
|
||||
* the fan-out reads both to compute its recipients. A notification call inside the
|
||||
* model would make it transitively depend on what its own header says it must not.
|
||||
*
|
||||
* **Awaited, and it still cannot fail the request.** `teamNotify.forumPost` catches
|
||||
* everything and returns; awaiting it costs the response the time of one recipient
|
||||
* query plus, in `immediate` mode, the SMTP calls — which is why the alternative
|
||||
* (fire-and-forget) is tempting and wrong here: an un-awaited rejection in an
|
||||
* Express handler is an unhandled rejection, and the tests would have no moment at
|
||||
* which to assert the fan-out happened.
|
||||
*/
|
||||
async function announce(ctx, actor, notify) {
|
||||
if (!notify) return
|
||||
await teamNotify.forumPost({
|
||||
team: ctx.team,
|
||||
threadId: notify.threadId,
|
||||
threadTitle: notify.title,
|
||||
type: notify.type,
|
||||
authorUserId: actor.id,
|
||||
authorName: actor.username,
|
||||
bodyHtml: notify.bodyHtml,
|
||||
})
|
||||
}
|
||||
|
||||
// ── threads ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function listThreads(req, res) {
|
||||
@@ -151,13 +180,14 @@ async function createThread(req, res) {
|
||||
return res.status(403).json({ message: 'Only Team leaders may post announcements' })
|
||||
}
|
||||
|
||||
const result = await forum.createThread({
|
||||
const { notify, ...result } = await forum.createThread({
|
||||
team: ctx.team,
|
||||
actor: req.user,
|
||||
type,
|
||||
title: req.body.title,
|
||||
body: req.body.body,
|
||||
})
|
||||
if (result.ok) await announce(ctx, req.user, notify)
|
||||
return send(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'create thread')
|
||||
@@ -170,12 +200,14 @@ async function createPost(req, res) {
|
||||
const ctx = await resolveForum(req)
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
|
||||
return send(res, await forum.createPost({
|
||||
const { notify, ...result } = await forum.createPost({
|
||||
team: ctx.team,
|
||||
threadId: Number(req.params.id),
|
||||
actor: req.user,
|
||||
body: req.body.body,
|
||||
}))
|
||||
})
|
||||
if (result.ok) await announce(ctx, req.user, notify)
|
||||
return send(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'create post')
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
const teams = require('../../../model/teams/teams.model')
|
||||
const teamActivity = require('../../../model/teams/teamActivity.model')
|
||||
const teamPrefs = require('../../../model/teams/teamNotify.model')
|
||||
const unsubscribeToken = require('../../../utils/unsubscribeToken')
|
||||
|
||||
const log = require('../../../utils/logger')('teams')
|
||||
|
||||
@@ -98,4 +100,52 @@ async function getActivity(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listTeams, getTeam, getTeamByExternalId, getRoster, getActivity }
|
||||
/**
|
||||
* POST /public/teams/unsubscribe/:token — one-click unsubscribe (TEAMS.md §6.4).
|
||||
*
|
||||
* **The one write in this tier, and it is unauthenticated on purpose.** A person
|
||||
* reading their mail is not logged into the site, and an unsubscribe that first
|
||||
* demands a login is an unsubscribe most people do not complete. The token is what
|
||||
* stands in for the session, and the capability it carries is deliberately the
|
||||
* narrowest one that does the job: set `muted` for ONE (user, Team) pair. It reads
|
||||
* nothing, cannot un-mute, and names no other Team.
|
||||
*
|
||||
* **Always 200, whatever the token was.** A response that distinguished a valid
|
||||
* token from a forged one would turn this into an oracle for which (user, Team)
|
||||
* pairs exist, on an endpoint with no session behind it. The page says "you will
|
||||
* not receive further emails about this team" either way, which is true either way.
|
||||
*
|
||||
* Reached two ways with the same effect: a mail client's RFC 8058 one-click POST
|
||||
* (the `List-Unsubscribe-Post` header), and the site's own /unsubscribe page,
|
||||
* which POSTs here after a human clicks the link in the body.
|
||||
*/
|
||||
async function unsubscribe(req, res) {
|
||||
const claim = unsubscribeToken.verify(req.params.token)
|
||||
if (claim) {
|
||||
try {
|
||||
await teamPrefs.mute(claim.userId, claim.teamId)
|
||||
} catch (err) {
|
||||
// Logged, not surfaced. A failed write here is worth an operator's
|
||||
// attention and is not worth telling an anonymous caller about — and a 500
|
||||
// would make a mail client retry a request it should not repeat.
|
||||
log.error('unsubscribe', err)
|
||||
}
|
||||
}
|
||||
return res.json({ ok: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET on the same path — for a mail client that shows the `List-Unsubscribe` URL
|
||||
* as a link and has no one-click support.
|
||||
*
|
||||
* Redirects to the site's own page rather than acting, because a GET must not
|
||||
* mutate: a link prefetcher or a mail client's link scanner would otherwise
|
||||
* silently mute Teams nobody asked to leave. The page it lands on does the POST
|
||||
* once a human is looking at it.
|
||||
*/
|
||||
function unsubscribeLanding(req, res) {
|
||||
const base = (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
return res.redirect(302, `${base}/unsubscribe/${encodeURIComponent(req.params.token)}`)
|
||||
}
|
||||
|
||||
module.exports = { listTeams, getTeam, getTeamByExternalId, getRoster, getActivity, unsubscribe, unsubscribeLanding }
|
||||
|
||||
@@ -90,4 +90,38 @@ teamsRouter.get(
|
||||
ctrl.getActivity,
|
||||
)
|
||||
|
||||
// ── One-click unsubscribe (TEAMS.md §6.4) ──────────────────────────────────
|
||||
//
|
||||
// Declared last, and the shadowing question is worth answering rather than
|
||||
// assuming: these are two segments, so the one-segment '/:slug' cannot take them,
|
||||
// and the two-segment '/:slug/members' and '/:slug/activity' both pin a LITERAL
|
||||
// second segment. Only a token spelled exactly "members" or "activity" could
|
||||
// collide, and a token is `<v>.<uid>.<tid>.<mac>`.
|
||||
//
|
||||
// No `siteMode`, unlike every other route in this file. An unsubscribe has to work
|
||||
// while the site is in maintenance: the mail that carried the link went out before
|
||||
// the site went down, and "we are doing maintenance" is not an answer to "stop
|
||||
// emailing me".
|
||||
teamsRouter.post(
|
||||
'/unsubscribe/:token',
|
||||
// #swagger.tags = ['Public · Teams']
|
||||
// #swagger.summary = 'Unsubscribe from one Team’s notification emails'
|
||||
// #swagger.description = 'Honours the tokened link in a Team notification email, including RFC 8058 one-click. Sets the same per-Team mute the account screen shows. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, Team) pairs exist.'
|
||||
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
|
||||
// #swagger.security = [{}]
|
||||
/* #swagger.responses[200] = { description: 'Acknowledged', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
|
||||
ctrl.unsubscribe,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/unsubscribe/:token',
|
||||
// #swagger.tags = ['Public · Teams']
|
||||
// #swagger.summary = 'Land a human on the unsubscribe page'
|
||||
// #swagger.description = 'For mail clients that render the List-Unsubscribe URL as an ordinary link. Redirects to the site’s own confirmation page and changes nothing — a GET must not mutate, or a link scanner would mute Teams nobody asked to leave.'
|
||||
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
|
||||
// #swagger.security = [{}]
|
||||
/* #swagger.responses[302] = { description: 'Redirect to the site’s unsubscribe page' } */
|
||||
ctrl.unsubscribeLanding,
|
||||
)
|
||||
|
||||
module.exports = teamsRouter
|
||||
|
||||
@@ -11,6 +11,7 @@ const botScore = require('./middleware/botScore')
|
||||
const announceWorker = require('./utils/announceWorker')
|
||||
const teamActivityPrune = require('./utils/teamActivityPrune')
|
||||
const teamForumUploadSweep = require('./utils/teamForumUploadSweep')
|
||||
const teamDigestWorker = require('./utils/teamDigestWorker')
|
||||
const { ensureSchema, close } = require('./utils/db')
|
||||
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
||||
const settings = require('./model/settings/settings.model')
|
||||
@@ -157,6 +158,7 @@ async function start() {
|
||||
// rather than after someone notices. No-op on a deployment with no Teams.
|
||||
teamActivityPrune.start()
|
||||
teamForumUploadSweep.start()
|
||||
teamDigestWorker.start()
|
||||
|
||||
setupShutdown(server, internalServer)
|
||||
}
|
||||
@@ -177,6 +179,7 @@ function setupShutdown(server, internalServer) {
|
||||
announceWorker.stop() // stop the news-announcement dispatcher poller
|
||||
teamActivityPrune.stop() // stop the Team activity retention timer
|
||||
teamForumUploadSweep.stop() // stop the forum upload sweep
|
||||
teamDigestWorker.stop() // stop the Team forum digest timer
|
||||
server.close(() => log.info('http server closed'))
|
||||
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
|
||||
try {
|
||||
|
||||
@@ -187,4 +187,81 @@ async function sendPasswordReset({ to, resetUrl, username }) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { isConfigured, sendContactMessage, sendTest, sendInvite, sendPasswordReset }
|
||||
/**
|
||||
* Send a Team notification — one event (`immediate` mode) or a day's worth
|
||||
* (`digest` mode). TEAMS.md §6.4.
|
||||
*
|
||||
* **This one carries CONTENT, and the push tickle beside it deliberately does
|
||||
* not.** A tickle goes to ntfy, an untrusted relay reachable by an unguessable
|
||||
* topic, so it carries `{ stream, ref }` and the app pulls the real thing over an
|
||||
* access-checked API. A mailbox is a destination the recipient chose. Same
|
||||
* reasoning as the Discord bridge (§7.2), and it is why this function takes
|
||||
* excerpts rather than ids.
|
||||
*
|
||||
* **Excerpts, never full posts.** Partly courtesy, mostly so that the blast radius
|
||||
* of a mis-addressed or forwarded mail is a sentence rather than a thread. The
|
||||
* caller does the truncation, because it is the caller that knows the body was
|
||||
* already stripped of markup.
|
||||
*
|
||||
* The `List-Unsubscribe` pair is what makes a mail client's own unsubscribe button
|
||||
* appear, and both halves are needed: the `mailto:`-free URL form for clients that
|
||||
* open the link, and `List-Unsubscribe-Post` for RFC 8058 one-click, which POSTs
|
||||
* without ever showing the user a page. Both reach the same tokened endpoint that
|
||||
* writes the same per-Team mute the site shows.
|
||||
*
|
||||
* Never throws. A notification failing must not fail the forum write that caused
|
||||
* it, and there is nobody up the stack to catch it — the digest worker runs on a
|
||||
* timer and the immediate send is fired from a request that has already replied.
|
||||
*/
|
||||
async function sendTeamNotification({ to, subject, intro, items, teamUrl, unsubscribeUrl, unsubscribeApiUrl }) {
|
||||
const built = await buildTransport()
|
||||
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
|
||||
const { transport, config } = built
|
||||
|
||||
const lines = [intro, '']
|
||||
for (const item of items || []) {
|
||||
lines.push(`${item.heading}`)
|
||||
if (item.excerpt) lines.push(` ${item.excerpt}`)
|
||||
if (item.url) lines.push(` ${item.url}`)
|
||||
lines.push('')
|
||||
}
|
||||
if (teamUrl) lines.push(teamUrl, '')
|
||||
if (unsubscribeUrl) {
|
||||
lines.push('To stop these emails for this team, use this link:', unsubscribeUrl)
|
||||
}
|
||||
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: fromHeader(config),
|
||||
to,
|
||||
subject,
|
||||
text: lines.join('\n'),
|
||||
// The header carries the API url, not the one in the body: a one-click
|
||||
// client POSTs to whatever is here without rendering anything, so it has to
|
||||
// be an endpoint. Falls back to the body's url when no API one was passed.
|
||||
headers: (unsubscribeApiUrl || unsubscribeUrl)
|
||||
? {
|
||||
'List-Unsubscribe': `<${unsubscribeApiUrl || unsubscribeUrl}>`,
|
||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
// Logged and swallowed, unlike every other sender in this file. Those are
|
||||
// called by a request that can report the failure to whoever caused it; this
|
||||
// one is not, and recordStatus already puts the error where an admin reads it.
|
||||
log.warn('team notification send failed', { message: err.message })
|
||||
await emailConfig.recordStatus({ status: 'error', statusDetail: err.message }).catch(() => {})
|
||||
return { sent: false, reason: 'SEND_FAILED' }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isConfigured,
|
||||
sendContactMessage,
|
||||
sendTest,
|
||||
sendInvite,
|
||||
sendPasswordReset,
|
||||
sendTeamNotification,
|
||||
}
|
||||
|
||||
@@ -108,4 +108,40 @@ async function publish(streamId, { ref, ownerUserId } = {}, deps = {}) {
|
||||
await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps)))
|
||||
}
|
||||
|
||||
module.exports = { publish, isAllowedEndpoint }
|
||||
// `Number.isInteger` alone is not enough: `Number(null)` is 0 and 0 is an
|
||||
// integer, so a null slipping into a caller's list would become user id 0 and
|
||||
// ride into an IN clause. No row has id 0, so it is harmless today — which is
|
||||
// exactly why it would never be noticed.
|
||||
const isUserId = (n) => Number.isInteger(n) && n > 0
|
||||
|
||||
/**
|
||||
* Publish one content-free tickle to a COMPUTED SET of users (TEAMS.md §6.2).
|
||||
*
|
||||
* The third fan-out shape. `publish` answers "everyone subscribed" and "this one
|
||||
* owner"; Team notifications need "these N users", because the four `team.*`
|
||||
* streams are global and which Team an event belongs to is expressed by who is in
|
||||
* the set. Nothing about the tickle changes — same `{ stream, ref }`, same
|
||||
* untrusted-relay assumption, same SSRF gate on every endpoint.
|
||||
*
|
||||
* The set arrives already resolved: the caller has asked the access resolver who
|
||||
* may read this Team and subtracted the per-Team mutes. What this function still
|
||||
* enforces is each recipient's own stream subscription, in the query. Never
|
||||
* throws — a notification failing must not fail the write that produced it.
|
||||
*/
|
||||
async function publishToUsers(streamId, { ref, userIds } = {}, deps = {}) {
|
||||
const devices = deps.pushDevices || pushDevicesModel
|
||||
const ids = [...new Set((userIds || []).map(Number).filter(isUserId))]
|
||||
if (ids.length === 0) return
|
||||
let rows
|
||||
try {
|
||||
rows = await devices.endpointsForUsersStream(ids, streamId)
|
||||
} catch (err) {
|
||||
log.warn('push endpoint lookup failed', { streamId, message: err.message })
|
||||
return
|
||||
}
|
||||
if (!rows || rows.length === 0) return
|
||||
const bodyStr = JSON.stringify({ stream: streamId, ref: ref ?? null })
|
||||
await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps)))
|
||||
}
|
||||
|
||||
module.exports = { publish, publishToUsers, isAllowedEndpoint }
|
||||
|
||||
158
server/src/utils/teamDigestWorker.js
Normal file
158
server/src/utils/teamDigestWorker.js
Normal file
@@ -0,0 +1,158 @@
|
||||
// ── Team forum digest worker (TEAMS.md §6.4, phase 6) ──────────────────────
|
||||
//
|
||||
// Daily, per (user, Team): "here is what you missed". The same in-process shape as
|
||||
// utils/teamActivityPrune and utils/announceWorker — setInterval + unref + stop(),
|
||||
// wired into server.js start/shutdown. There is no cron in this stack.
|
||||
//
|
||||
// **It computes at send time and keeps no queue.** The only state is
|
||||
// `team_notification_prefs.last_digest_at`; everything else is re-derived from the
|
||||
// forum tables when the mail is about to go out. Three properties fall out of that,
|
||||
// and they are why the design chose it over a pending-items table:
|
||||
//
|
||||
// 1. A deployment that was down for two days sends ONE correct digest, not two
|
||||
// days of replay.
|
||||
// 2. A post a moderator hid after it was written is not in the query, so it is
|
||||
// not in the mail. A queue written at publish time would have to remember to
|
||||
// go back and remove it.
|
||||
// 3. A user who lost forum access between the post and the send is no longer in
|
||||
// the recipient set, so they are not emailed content they can no longer read.
|
||||
// This is the one that would have been a security bug.
|
||||
//
|
||||
// **The first run is delayed, for the same reason the prune's is**: a boot that is
|
||||
// crash-looping must not send mail on every loop.
|
||||
|
||||
const teamNotify = require('../model/teams/teamNotify.model')
|
||||
const forumSettings = require('../model/teams/teamForumSettings.model')
|
||||
const mailer = require('./mailer')
|
||||
const notify = require('./teamNotify')
|
||||
const brand = require('../config/brand')
|
||||
const log = require('./logger')('team-digest')
|
||||
|
||||
const INTERVAL_MS = Number(process.env.TEAM_DIGEST_INTERVAL_MS) || 24 * 60 * 60 * 1000
|
||||
const FIRST_RUN_MS = Number(process.env.TEAM_DIGEST_DELAY_MS) || 10 * 60 * 1000
|
||||
|
||||
// How far back a recipient with no `last_digest_at` reaches. A first digest must
|
||||
// not be the entire history of the forum, and this is also the clamp that stops a
|
||||
// long outage producing one enormous mail — `since` is never older than this,
|
||||
// however long ago the last send was.
|
||||
const MAX_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
// Posts per digest. Beyond this the mail is a list, not a summary, and the link
|
||||
// to the Team is the better answer.
|
||||
const MAX_ITEMS = 20
|
||||
|
||||
let timer = null
|
||||
let firstRun = null
|
||||
|
||||
const clampSince = (last, now) => {
|
||||
const floor = new Date(now.getTime() - MAX_LOOKBACK_MS)
|
||||
// No previous send: reach back one interval, not to the floor. A brand-new
|
||||
// subscriber's first digest should cover today, not the past week.
|
||||
if (!last) return new Date(now.getTime() - INTERVAL_MS)
|
||||
const at = new Date(last)
|
||||
return at < floor ? floor : at
|
||||
}
|
||||
|
||||
/**
|
||||
* One recipient's digest for one Team. Returns true if a mail went out.
|
||||
*
|
||||
* Stamps `last_digest_at` ONLY on a successful send. A failed SMTP call leaves the
|
||||
* stamp alone so the next run tries the same window again — the alternative,
|
||||
* stamping first, silently eats a day of somebody's notifications every time the
|
||||
* mail provider has a bad minute.
|
||||
*/
|
||||
async function sendOne(team, recipient, now) {
|
||||
const since = clampSince(recipient.last_digest_at, now)
|
||||
const posts = await teamNotify.digestPostsSince(team.id, since, MAX_ITEMS)
|
||||
// Nothing new for THIS recipient — which is not the same as nothing new for the
|
||||
// Team, because each recipient has their own `since`. No mail, and no stamp: a
|
||||
// stamp here would move the window past posts they have not been told about.
|
||||
if (posts.length === 0) return false
|
||||
|
||||
const label = notify.teamLabel(team)
|
||||
const res = await mailer.sendTeamNotification({
|
||||
to: recipient.email,
|
||||
subject: `[${brand.name}] ${label}: ${posts.length} new post${posts.length === 1 ? '' : 's'}`,
|
||||
intro: `Since your last digest, ${posts.length} new post${posts.length === 1 ? '' : 's'} in ${label}:`,
|
||||
items: posts.map((p) => ({
|
||||
heading: `${p.title} — ${p.author_username || 'someone'}`,
|
||||
excerpt: notify.excerpt(p.body_html),
|
||||
url: notify.threadUrl(team, p.thread_id),
|
||||
})),
|
||||
teamUrl: notify.teamPageUrl(team),
|
||||
unsubscribeUrl: notify.unsubscribeUrl(recipient.user_id, team.id),
|
||||
unsubscribeApiUrl: notify.unsubscribeApiUrl(recipient.user_id, team.id),
|
||||
})
|
||||
if (!res || !res.sent) return false
|
||||
await teamNotify.stampDigest(recipient.user_id, team.id, now)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* One sweep. Never throws — it runs on a timer with nobody to catch it.
|
||||
*
|
||||
* Returns a small summary so a test (and the log line) can tell "nothing to do"
|
||||
* from "did nothing".
|
||||
*/
|
||||
async function tick(now = new Date()) {
|
||||
const summary = { teams: 0, sent: 0, skipped: null }
|
||||
try {
|
||||
// Two cheap gates before any query that costs anything. Forums switched off
|
||||
// means the content this digest summarises is not readable on the site
|
||||
// either, and un-configured email means there is no sink at all (§6.4).
|
||||
if (!(await forumSettings.forumsEnabled())) return { ...summary, skipped: 'forums-disabled' }
|
||||
if (!(await mailer.isConfigured())) return { ...summary, skipped: 'email-unconfigured' }
|
||||
|
||||
const floor = new Date(now.getTime() - MAX_LOOKBACK_MS)
|
||||
const teams = await teamNotify.teamsWithForumActivitySince(floor)
|
||||
summary.teams = teams.length
|
||||
|
||||
for (const team of teams) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const rows = await teamNotify.emailRecipients(team.id)
|
||||
for (const r of rows.filter((x) => x.email_mode === 'digest')) {
|
||||
try {
|
||||
// Serial, like the immediate sender and for the same reason: one SMTP
|
||||
// conversation at a time against a provider with its own rate limits.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (await sendOne(team, r, now)) summary.sent += 1
|
||||
} catch (err) {
|
||||
// One recipient's failure must not end the sweep for the rest. The
|
||||
// unstamped preference means the next run retries this one.
|
||||
log.warn('digest send failed', { teamId: team.id, userId: r.user_id, message: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (summary.sent > 0) log.info('team digests sent', summary)
|
||||
return summary
|
||||
} catch (err) {
|
||||
log.error('team digest sweep failed', { message: err.message })
|
||||
return { ...summary, skipped: 'error' }
|
||||
}
|
||||
}
|
||||
|
||||
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('team forum digests 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, clampSince, MAX_LOOKBACK_MS, MAX_ITEMS }
|
||||
225
server/src/utils/teamNotify.js
Normal file
225
server/src/utils/teamNotify.js
Normal file
@@ -0,0 +1,225 @@
|
||||
// ── Team notification fan-out (TEAMS.md Part 6, phase 6) ───────────────────
|
||||
//
|
||||
// One event in, up to two sinks out: a content-free push tickle and — for forum
|
||||
// content only — an email. The expensive part of a notification is working out
|
||||
// who should get it, and that is computed once here and handed to both.
|
||||
//
|
||||
// **Nothing in this file ever throws.** Every entry point is called from a path
|
||||
// that has already done the real work: a forum reply is written and answered
|
||||
// before this runs, and the roster sync's whole job is the roster. A notification
|
||||
// is a courtesy, and a courtesy that can fail the transaction behind it is a
|
||||
// defect. So every export catches, logs and returns.
|
||||
//
|
||||
// **Push and email do not carry the same thing, on purpose.** The tickle is
|
||||
// `{ stream, ref }` and goes to ntfy, an untrusted relay reached by an unguessable
|
||||
// topic; the app wakes and PULLS the real content over the authenticated,
|
||||
// access-checked API. The email carries a title and an excerpt, because a mailbox
|
||||
// is a destination the recipient chose rather than a relay (§6.4). The asymmetry
|
||||
// is the security model, not an inconsistency to tidy up.
|
||||
//
|
||||
// **Roster events are push-only, and forum events are the only ones that email.**
|
||||
// §6.4's argument for the email sink is the web-only user who never learns that
|
||||
// someone replied to their own thread. "Someone joined the guild" is not that: it
|
||||
// arrives from a sweep that runs every fifteen minutes, it is already on the
|
||||
// activity feed, and mailing it is how a notification feature earns a spam
|
||||
// complaint. The streams exist for all four events; the SINKS differ, and this is
|
||||
// the file that says so.
|
||||
|
||||
const pushDispatch = require('./pushDispatch')
|
||||
const teamNotify = require('../model/teams/teamNotify.model')
|
||||
const forumSettings = require('../model/teams/teamForumSettings.model')
|
||||
const mailer = require('./mailer')
|
||||
const registries = require('../modules/registries')
|
||||
const unsubscribeToken = require('./unsubscribeToken')
|
||||
const brand = require('../config/brand')
|
||||
const log = require('./logger')('team-notify')
|
||||
|
||||
const STREAMS = {
|
||||
MEMBER_JOINED: 'team.member.joined',
|
||||
LEADERSHIP_CHANGED: 'team.leadership.changed',
|
||||
FORUM_POST: 'team.forum.post',
|
||||
ANNOUNCEMENT: 'team.announcement',
|
||||
}
|
||||
|
||||
// How much of a post body an email carries. Long enough to tell whether the
|
||||
// thread is worth opening, short enough that the mail is not a copy of the forum.
|
||||
const EXCERPT_CHARS = 200
|
||||
|
||||
const baseUrl = () => (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* Where this Team's page lives, or null.
|
||||
*
|
||||
* Core does not own a Team page — the module that owns the vocabulary owns the
|
||||
* page (Part 3) — so the only way core can write a link to one is the optional
|
||||
* `pageUrlTemplate` the provider registers. A deployment whose module omits it
|
||||
* gets email that names the Team and cannot link to it, which is a worse email
|
||||
* and not a broken one.
|
||||
*/
|
||||
function teamPageUrl(team) {
|
||||
const provider = registries.registeredTeamProvider()
|
||||
const template = provider && provider.pageUrlTemplate
|
||||
if (!template || !team) return null
|
||||
const path = template
|
||||
.replace('{externalId}', encodeURIComponent(team.external_id ?? team.externalId ?? ''))
|
||||
.replace('{slug}', encodeURIComponent(team.slug ?? ''))
|
||||
return `${baseUrl()}${path}`
|
||||
}
|
||||
|
||||
const threadUrl = (team, threadId) => {
|
||||
const page = teamPageUrl(team)
|
||||
// The forum navigates by SEARCH PARAM rather than by a route, because core has
|
||||
// no route on a page it does not own (TeamForumPanel.jsx). So a deep link to a
|
||||
// thread is the module's page plus `?thread=`, and it works under whatever path
|
||||
// the module chose.
|
||||
return page ? `${page}?thread=${Number(threadId)}` : null
|
||||
}
|
||||
|
||||
// TWO urls from one token, and they are not interchangeable.
|
||||
//
|
||||
// `unsubscribeUrl` is the human one that goes in the mail body: the site's own
|
||||
// page, which explains what is about to happen and POSTs once a person has read
|
||||
// it. `unsubscribeApiUrl` is the machine one that goes in the `List-Unsubscribe`
|
||||
// header, where RFC 8058 says a client may POST without showing anybody anything —
|
||||
// so it has to be an endpoint, not a page. The API route answers GET on the same
|
||||
// path with a redirect to the page, which covers the clients that render the
|
||||
// header as an ordinary link.
|
||||
const unsubscribeUrl = (userId, teamId) =>
|
||||
`${baseUrl()}/unsubscribe/${unsubscribeToken.sign(userId, teamId)}`
|
||||
|
||||
const unsubscribeApiUrl = (userId, teamId) =>
|
||||
`${baseUrl()}/api/v1/public/teams/unsubscribe/${unsubscribeToken.sign(userId, teamId)}`
|
||||
|
||||
const teamLabel = (team) => (team && (team.display_name_override || team.name)) || 'your team'
|
||||
|
||||
/** Markup out, whitespace collapsed, truncated. The email is plain text. */
|
||||
function excerpt(html) {
|
||||
const text = String(html || '')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return text.length > EXCERPT_CHARS ? `${text.slice(0, EXCERPT_CHARS - 1)}…` : text
|
||||
}
|
||||
|
||||
/** The push half. Resolves recipients, honours mutes and subscriptions, never throws. */
|
||||
async function tickle(streamId, team, { ref, exclude = [] } = {}) {
|
||||
const userIds = await teamNotify.recipientIds(team.id, { exclude })
|
||||
if (userIds.length === 0) return 0
|
||||
await pushDispatch.publishToUsers(streamId, { ref, userIds })
|
||||
return userIds.length
|
||||
}
|
||||
|
||||
// ── Roster events (push only, see the header) ──────────────────────────────
|
||||
|
||||
// No `memberName` argument, and that is the point: a tickle is content-free, so
|
||||
// there is nothing about WHO joined for this function to carry. The name is on
|
||||
// the activity feed the app pulls after waking.
|
||||
async function memberJoined(team) {
|
||||
try {
|
||||
return await tickle(STREAMS.MEMBER_JOINED, team, { ref: `team:${team.id}` })
|
||||
} catch (err) {
|
||||
log.warn('member-joined notification failed', { teamId: team && team.id, message: err.message })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async function leadershipChanged(team) {
|
||||
try {
|
||||
return await tickle(STREAMS.LEADERSHIP_CHANGED, team, { ref: `team:${team.id}` })
|
||||
} catch (err) {
|
||||
log.warn('leadership notification failed', { teamId: team && team.id, message: err.message })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── Forum events (push + immediate email) ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* A new thread or reply.
|
||||
*
|
||||
* `type` picks the stream: an announcement is its own stream so a user can take
|
||||
* the thing a leader wants everyone to read and mute the day-to-day chatter,
|
||||
* which is the split §6.2 drew and the reason there are four streams and not two.
|
||||
*
|
||||
* The author is excluded from both sinks. Not as a nicety — a forum that emails
|
||||
* you your own post is the first thing anyone turns off, and turning it off costs
|
||||
* the deployment every other notification too.
|
||||
*/
|
||||
async function forumPost({ team, threadId, threadTitle, type, authorUserId, authorName, bodyHtml }) {
|
||||
try {
|
||||
// Belt and braces with the routes, which already 404 when forums are off. The
|
||||
// digest worker has no route in front of it, so the check has to live here as
|
||||
// well as there — and a switch flipped between a write and its notification
|
||||
// must silence the notification.
|
||||
if (!(await forumSettings.forumsEnabled())) return { push: 0, emails: 0 }
|
||||
|
||||
const stream = type === 'announcement' ? STREAMS.ANNOUNCEMENT : STREAMS.FORUM_POST
|
||||
const exclude = authorUserId ? [authorUserId] : []
|
||||
const push = await tickle(stream, team, { ref: `team:${team.id}:thread:${threadId}`, exclude })
|
||||
const emails = await emailImmediate({ team, threadId, threadTitle, type, exclude, authorName, bodyHtml })
|
||||
return { push, emails }
|
||||
} catch (err) {
|
||||
log.warn('forum notification failed', { teamId: team && team.id, message: err.message })
|
||||
return { push: 0, emails: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `immediate` email mode: one mail per event, to the people who asked for
|
||||
* exactly that.
|
||||
*
|
||||
* Skipped entirely when no email is configured — §6.4's "off unless configured"
|
||||
* — and checked BEFORE the recipient query so a deployment with no Gmail
|
||||
* connected pays nothing for the sink it does not have.
|
||||
*/
|
||||
async function emailImmediate({ team, threadId, threadTitle, type, exclude, authorName, bodyHtml }) {
|
||||
if (!(await mailer.isConfigured())) return 0
|
||||
const rows = await teamNotify.emailRecipients(team.id, { exclude })
|
||||
const recipients = rows.filter((r) => r.email_mode === 'immediate')
|
||||
if (recipients.length === 0) return 0
|
||||
|
||||
const label = teamLabel(team)
|
||||
const kind = type === 'announcement' ? 'announcement' : 'post'
|
||||
const url = threadUrl(team, threadId)
|
||||
let sent = 0
|
||||
|
||||
for (const r of recipients) {
|
||||
// Serial rather than Promise.all: this is an SMTP conversation per recipient
|
||||
// against a provider with its own rate limits, and a burst of them from a
|
||||
// busy thread is how a Gmail sender gets throttled. The loop is also why the
|
||||
// send below is fire-and-report rather than fire-and-throw.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await mailer.sendTeamNotification({
|
||||
to: r.email,
|
||||
subject: `[${brand.name}] ${label}: ${threadTitle}`,
|
||||
intro: `${authorName || 'Someone'} posted a new ${kind} in ${label}.`,
|
||||
items: [{ heading: threadTitle, excerpt: excerpt(bodyHtml), url }],
|
||||
teamUrl: teamPageUrl(team),
|
||||
unsubscribeUrl: unsubscribeUrl(r.user_id, team.id),
|
||||
unsubscribeApiUrl: unsubscribeApiUrl(r.user_id, team.id),
|
||||
})
|
||||
if (res && res.sent) sent += 1
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STREAMS,
|
||||
memberJoined,
|
||||
leadershipChanged,
|
||||
forumPost,
|
||||
// Exported for the digest worker and for the tests, which is the whole reason
|
||||
// they are not inlined: a URL that only ever appears inside a mail body is a
|
||||
// URL nothing can assert on.
|
||||
teamPageUrl,
|
||||
threadUrl,
|
||||
unsubscribeUrl,
|
||||
unsubscribeApiUrl,
|
||||
excerpt,
|
||||
teamLabel,
|
||||
}
|
||||
91
server/src/utils/unsubscribeToken.js
Normal file
91
server/src/utils/unsubscribeToken.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// ── One-click unsubscribe tokens (TEAMS.md §6.4) ───────────────────────────
|
||||
//
|
||||
// A stateless HMAC over (userId, teamId, version), not a row in a table.
|
||||
//
|
||||
// **Why stateless.** The alternative is a `password_resets`-shaped token table,
|
||||
// and it is the wrong shape for this: an unsubscribe link sits in a mailbox for
|
||||
// months and must still work, so it has no useful expiry; it is not single-use,
|
||||
// because clicking it twice must mean the same thing as clicking it once; and a
|
||||
// table would need pruning for a capability that never expires. Every property
|
||||
// that makes a reset token a row is absent here.
|
||||
//
|
||||
// **What the capability actually is.** Holding a token lets the holder set
|
||||
// `muted = 1` for ONE (user, Team) pair. It cannot read anything, cannot unmute,
|
||||
// cannot touch email mode, and names no other Team. So the honest threat model is:
|
||||
// someone who intercepts the mail can silence one Team's notifications for that
|
||||
// account, visibly and reversibly on the account screen. That is a smaller
|
||||
// capability than the mail itself already carries (it contains the content).
|
||||
//
|
||||
// **`v` is the version prefix, and it is what makes rotation possible at all.** A
|
||||
// stateless token cannot be revoked individually; bumping VERSION invalidates
|
||||
// every outstanding link at once, which is the only revocation a design with no
|
||||
// server-side state can offer, and it needs to exist before it is needed.
|
||||
//
|
||||
// The key is SECRET_ENC_KEY, derived through the same dev fallback as
|
||||
// utils/secretBox — a separate label so an unsubscribe token can never be
|
||||
// confused with, or replayed as, anything else keyed by the same secret.
|
||||
|
||||
const crypto = require('crypto')
|
||||
require('dotenv').config()
|
||||
|
||||
const log = require('./logger')('unsub-token')
|
||||
|
||||
const VERSION = 1
|
||||
|
||||
function resolveKey() {
|
||||
const explicit = process.env.SECRET_ENC_KEY
|
||||
if (explicit) return crypto.createHash('sha256').update(`unsubscribe:${explicit}`).digest()
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('SECRET_ENC_KEY must be set in production')
|
||||
}
|
||||
const jwt = process.env.JWT_SECRET || 'dev-insecure-jwt-secret-do-not-use-in-production'
|
||||
log.warn('SECRET_ENC_KEY is not set — deriving an insecure unsubscribe key from JWT_SECRET for development.')
|
||||
return crypto.createHash('sha256').update(`unsubscribe:${jwt}`).digest()
|
||||
}
|
||||
|
||||
let cachedKey = null
|
||||
const key = () => {
|
||||
// Lazily, not at require time. utils/secretBox resolves its key on import and
|
||||
// that is fine for a module every boot loads anyway; this one is reached from a
|
||||
// mail template, and a test that never sends mail should not have to set an env
|
||||
// var to require the module that sends it.
|
||||
if (!cachedKey) cachedKey = resolveKey()
|
||||
return cachedKey
|
||||
}
|
||||
|
||||
// base64url so the token survives being a path segment, a query value and a mail
|
||||
// client's own re-wrapping of a long URL without any of the three escaping it.
|
||||
const b64u = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
|
||||
function sign(userId, teamId) {
|
||||
const body = `${VERSION}.${Number(userId)}.${Number(teamId)}`
|
||||
const mac = crypto.createHmac('sha256', key()).update(body).digest()
|
||||
// Truncated to 16 bytes (128 bits). Full-length would double the URL for no
|
||||
// reachable gain: forging this buys one mute, and 128 bits is far past the
|
||||
// point where that is worth anyone's compute.
|
||||
return `${body}.${b64u(mac.subarray(0, 16))}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a token. Returns { userId, teamId } or null — null for every failure
|
||||
* mode, deliberately, so a caller cannot accidentally report which part was wrong.
|
||||
*/
|
||||
function verify(token) {
|
||||
const parts = String(token || '').split('.')
|
||||
if (parts.length !== 4) return null
|
||||
const [v, uid, tid] = parts
|
||||
if (Number(v) !== VERSION) return null
|
||||
const userId = Number(uid)
|
||||
const teamId = Number(tid)
|
||||
if (!Number.isInteger(userId) || !Number.isInteger(teamId)) return null
|
||||
|
||||
const expected = sign(userId, teamId)
|
||||
const a = Buffer.from(expected)
|
||||
const b = Buffer.from(String(token))
|
||||
// Length-check first: timingSafeEqual throws on a length mismatch, and the
|
||||
// length of a token is not a secret.
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null
|
||||
return { userId, teamId }
|
||||
}
|
||||
|
||||
module.exports = { sign, verify, VERSION }
|
||||
@@ -8364,6 +8364,114 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/me/notifications/teams": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Auth · Me"
|
||||
],
|
||||
"summary": "Get the current user’s per-Team notification preferences",
|
||||
"description": "One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they have a stored preference for. Defaults are applied server-side: `muted` false, `emailMode` \"off\".",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Per-Team preferences",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TeamNotificationPrefs"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Auth · Me"
|
||||
],
|
||||
"summary": "Replace the current user’s per-Team notification preferences",
|
||||
"description": "Replaces the whole set. The `teams` array is required even when empty. Entries naming a Team the caller has no access to are ignored; the stored set is echoed back.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Updated preferences",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TeamNotificationPrefs"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TeamNotificationPrefs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/me/sessions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -12070,6 +12178,67 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/teams/unsubscribe/{token}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Public · Teams"
|
||||
],
|
||||
"summary": "Unsubscribe from one Team’s notification emails",
|
||||
"description": "Honours the tokened link in a Team notification email, including RFC 8058 one-click. Sets the same per-Team mute the account screen shows. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, Team) pairs exist.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The signed token from the email link."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Acknowledged",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OkFlag"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{}
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Teams"
|
||||
],
|
||||
"summary": "Land a human on the unsubscribe page",
|
||||
"description": "For mail clients that render the List-Unsubscribe URL as an ordinary link. Redirects to the site’s own confirmation page and changes nothing — a GET must not mutate, or a link scanner would mute Teams nobody asked to leave.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "token",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The signed token from the email link."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"302": {
|
||||
"description": "Redirect to the site’s unsubscribe page"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/public/teams/{slug}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -16081,6 +16250,143 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TeamNotificationPref": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "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`)."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"teamId": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
"slug": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "the-silver-hand"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "The Silver Hand"
|
||||
}
|
||||
}
|
||||
},
|
||||
"archived": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"muted": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"emailMode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"off",
|
||||
"digest",
|
||||
"immediate"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TeamNotificationPrefs": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Per-Team notification preferences (used for both GET and PUT). The `teams` array is required on PUT even when empty."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"teams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TeamNotificationPref"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Appeal": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -573,6 +573,25 @@ const doc = {
|
||||
},
|
||||
},
|
||||
},
|
||||
TeamNotificationPref: {
|
||||
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`).",
|
||||
properties: {
|
||||
teamId: { type: 'integer', example: 3 },
|
||||
slug: { type: 'string', example: 'the-silver-hand' },
|
||||
name: { type: 'string', example: 'The Silver Hand' },
|
||||
archived: { type: 'boolean', example: false },
|
||||
muted: { type: 'boolean', example: false },
|
||||
emailMode: { type: 'string', enum: ['off', 'digest', 'immediate'], example: 'off' },
|
||||
},
|
||||
},
|
||||
TeamNotificationPrefs: {
|
||||
type: 'object',
|
||||
description: 'Per-Team notification preferences (used for both GET and PUT). The `teams` array is required on PUT even when empty.',
|
||||
properties: {
|
||||
teams: { type: 'array', items: { $ref: '#/components/schemas/TeamNotificationPref' } },
|
||||
},
|
||||
},
|
||||
// ── Moderation appeals (Phase 6c/6d) ────────────────────────────────────
|
||||
Appeal: {
|
||||
type: 'object',
|
||||
|
||||
@@ -44,11 +44,22 @@ function tryApply(owner, build) {
|
||||
test('registerCore registers exactly what core owns, and nothing else', () => {
|
||||
registries.registerCore()
|
||||
|
||||
// One stream, one leg, no filled slot. Before Phase 3 this was eight streams,
|
||||
// Five streams, one leg, no filled slot. Before Phase 3 this was eight streams,
|
||||
// two legs and a core-filled `admin.users.detail` — core was holding shard
|
||||
// CONTENT so the seam would be exercised on every boot before a module first
|
||||
// used it. module-uo registers all of it now, through the same door.
|
||||
assert.deepEqual(registries.allStreams().map((s) => s.id), ['news.post'])
|
||||
//
|
||||
// The four `team.*` streams arrived with Teams phase 6 and ARE core's: a module
|
||||
// supplies who is in a Team, but who may be told about it is the access
|
||||
// resolver's answer. Asserted as an exact list so a shard-content stream
|
||||
// creeping back into core's registration fails here rather than shipping.
|
||||
assert.deepEqual(registries.allStreams().map((s) => s.id), [
|
||||
'news.post',
|
||||
'team.member.joined',
|
||||
'team.leadership.changed',
|
||||
'team.forum.post',
|
||||
'team.announcement',
|
||||
])
|
||||
assert.deepEqual(registries.announceLegIds(), ['discord'])
|
||||
assert.equal(registries.slotFilledBy('admin.users.detail'), null)
|
||||
assert.equal(registries.isCoreRegistered(), true)
|
||||
|
||||
@@ -25,6 +25,11 @@ test('/auth/me push routes reject unauthenticated callers with 401', async () =>
|
||||
['GET', '/api/v1/auth/me/notifications/streams'],
|
||||
['GET', '/api/v1/auth/me/notifications/subscriptions'],
|
||||
['PUT', '/api/v1/auth/me/notifications/subscriptions', { streams: ['news.post'] }],
|
||||
// Phase 6's two. Per-Team preferences are as much a private fact as the
|
||||
// subscriptions beside them — the LIST of Teams a user may be notified
|
||||
// about answers "which guilds is this person in".
|
||||
['GET', '/api/v1/auth/me/notifications/teams'],
|
||||
['PUT', '/api/v1/auth/me/notifications/teams', { teams: [] }],
|
||||
]
|
||||
for (const [method, path, body] of calls) {
|
||||
const res = await fetch(app.url + path, {
|
||||
@@ -38,3 +43,40 @@ test('/auth/me push routes reject unauthenticated callers with 401', async () =>
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
// ── The unsubscribe endpoint (TEAMS.md §6.4) ───────────────────────────────
|
||||
//
|
||||
// The mirror image of every test above: this one is reached WITHOUT a session, on
|
||||
// purpose, because the person following it is reading their mail. So the things
|
||||
// worth asserting are that it is mounted unauthenticated, that it says the same
|
||||
// thing whatever the token was, and that the GET twin does not write.
|
||||
|
||||
const publicRouter = require('../src/router/v1/public')
|
||||
|
||||
test('unsubscribe answers 200 unauthenticated, and says nothing about the token', async () => {
|
||||
const app = await startApp((a) => a.use('/api/v1/public', publicRouter))
|
||||
try {
|
||||
// A forged token and a well-formed one must be indistinguishable from the
|
||||
// outside — otherwise this is an oracle for which (user, Team) pairs exist.
|
||||
for (const token of ['1.1.1.AAAAAAAAAAAAAAAAAAAAAA', 'total-nonsense']) {
|
||||
const res = await fetch(`${app.url}/api/v1/public/teams/unsubscribe/${token}`, { method: 'POST' })
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(await res.json(), { ok: true })
|
||||
}
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('a GET on the same path redirects and does not act', async () => {
|
||||
const app = await startApp((a) => a.use('/api/v1/public', publicRouter))
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/public/teams/unsubscribe/1.1.1.AAAAAAAAAAAAAAAAAAAAAA`, {
|
||||
redirect: 'manual',
|
||||
})
|
||||
assert.equal(res.status, 302)
|
||||
assert.match(res.headers.get('location') || '', /\/unsubscribe\//)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -126,3 +126,53 @@ test('publish skips endpoints that fail the SSRF guard', async () => {
|
||||
assert.equal(calls[0].url, 'https://relay.test/ok')
|
||||
})
|
||||
})
|
||||
|
||||
// ── publishToUsers — the third fan-out shape (TEAMS.md §6.2) ───────────────
|
||||
//
|
||||
// `publish` answers "everyone subscribed" and "this one owner". Team
|
||||
// notifications need "these N users", because the four `team.*` streams are
|
||||
// global and which Team an event belongs to lives in the SET, not the stream id.
|
||||
|
||||
test('publishToUsers tickles the given set, and asks for exactly that set', async () => {
|
||||
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
|
||||
const { calls, fetchImpl } = captureFetch()
|
||||
const asked = []
|
||||
const pushDevices = {
|
||||
endpointsForUsersStream: async (ids, stream) => {
|
||||
asked.push({ ids, stream })
|
||||
return [{ endpoint: 'https://relay.test/a' }, { endpoint: 'https://relay.test/b' }]
|
||||
},
|
||||
}
|
||||
await pushDispatch.publishToUsers('team.forum.post', { ref: 'team:1:thread:7', userIds: [4, 9] }, { pushDevices, fetchImpl })
|
||||
assert.deepEqual(asked, [{ ids: [4, 9], stream: 'team.forum.post' }])
|
||||
assert.equal(calls.length, 2)
|
||||
assert.deepEqual(JSON.parse(calls[0].opts.body), { stream: 'team.forum.post', ref: 'team:1:thread:7' })
|
||||
})
|
||||
})
|
||||
|
||||
test('publishToUsers with an empty set never touches the database', async () => {
|
||||
const { calls, fetchImpl } = captureFetch()
|
||||
let looked = false
|
||||
const pushDevices = { endpointsForUsersStream: async () => { looked = true; return [] } }
|
||||
await pushDispatch.publishToUsers('team.forum.post', { ref: 'x', userIds: [] }, { pushDevices, fetchImpl })
|
||||
assert.equal(looked, false, 'an empty IN () is a syntax error, so the query must not be made at all')
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
test('publishToUsers de-duplicates and drops non-numeric ids', async () => {
|
||||
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
|
||||
const { fetchImpl } = captureFetch()
|
||||
const asked = []
|
||||
const pushDevices = {
|
||||
endpointsForUsersStream: async (ids) => { asked.push(ids); return [] },
|
||||
}
|
||||
await pushDispatch.publishToUsers('team.forum.post', { ref: 'x', userIds: [4, 4, null, 'nope', 9] }, { pushDevices, fetchImpl })
|
||||
assert.deepEqual(asked, [[4, 9]])
|
||||
})
|
||||
})
|
||||
|
||||
test('publishToUsers never throws when the lookup fails', async () => {
|
||||
const { fetchImpl } = captureFetch()
|
||||
const pushDevices = { endpointsForUsersStream: async () => { throw new Error('down') } }
|
||||
await pushDispatch.publishToUsers('team.forum.post', { ref: 'x', userIds: [1] }, { pushDevices, fetchImpl })
|
||||
})
|
||||
|
||||
289
server/test/teamNotify.test.js
Normal file
289
server/test/teamNotify.test.js
Normal file
@@ -0,0 +1,289 @@
|
||||
// Team notifications (docs/website/TEAMS.md Part 6, phase 6).
|
||||
//
|
||||
// The db layer is stubbed and in-memory tables stand in for `team_members`,
|
||||
// `team_forum_grants` and `team_notification_prefs`, so these are assertions
|
||||
// about the RULES rather than about SQL. Five are worth protecting because each
|
||||
// one is a leak or a nuisance if it is "simplified" away:
|
||||
//
|
||||
// 1. a revoked guest and a departed member are NOT recipients — the fan-out
|
||||
// asks the same two conditions the access resolver asks, and a fan-out that
|
||||
// forgot one would mail a private forum to somebody who was removed from it;
|
||||
// 2. a mute subtracts from the recipient set, per Team, and does not touch the
|
||||
// user's other Teams;
|
||||
// 3. the author of a post is never a recipient of the notification about it;
|
||||
// 4. email is opt-IN (`off` unless chosen) while push is opt-OUT, which is the
|
||||
// one asymmetry in the whole feature and the one most likely to be
|
||||
// "tidied up" into a single default;
|
||||
// 5. an unsubscribe token is honoured for exactly the pair it was signed for,
|
||||
// and forging one is not distinguishable from failing.
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const notifyDb = require('../src/model/teams/teamNotify.db')
|
||||
const notifyModel = require('../src/model/teams/teamNotify.model')
|
||||
const unsubscribeToken = require('../src/utils/unsubscribeToken')
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// ── The in-memory stand-in ─────────────────────────────────────────────────
|
||||
//
|
||||
// Modelled on the three real tables rather than on the queries, so a rule the SQL
|
||||
// gets wrong is a rule this gets wrong too. `members` and `grants` carry their
|
||||
// status columns for exactly that reason: the interesting cases are the rows that
|
||||
// exist and do not count.
|
||||
let store
|
||||
|
||||
function stub() {
|
||||
store = {
|
||||
teams: [
|
||||
{ id: 1, slug: 'silver-hand', name: 'The Silver Hand', display_name_override: null, status: 'active' },
|
||||
{ id: 2, slug: 'iron-few', name: 'The Iron Few', display_name_override: null, status: 'active' },
|
||||
],
|
||||
members: [
|
||||
{ team_id: 1, user_id: 10, status: 'active' },
|
||||
{ team_id: 1, user_id: 11, status: 'active' },
|
||||
{ team_id: 1, user_id: 12, status: 'departed' }, // left the guild
|
||||
{ team_id: 1, user_id: null, status: 'active' }, // unlinked character
|
||||
{ team_id: 2, user_id: 10, status: 'active' },
|
||||
],
|
||||
grants: [
|
||||
{ team_id: 1, user_id: 20, revoked_at: null }, // a forum guest
|
||||
{ team_id: 1, user_id: 21, revoked_at: '2026-08-01' }, // revoked
|
||||
],
|
||||
users: [
|
||||
{ id: 10, username: 'ten', email: 'ten@example.test', status: 'active' },
|
||||
{ id: 11, username: 'eleven', email: null, status: 'active' },
|
||||
{ id: 20, username: 'twenty', email: 'twenty@example.test', status: 'active' },
|
||||
{ id: 30, username: 'banned', email: 'banned@example.test', status: 'banned' },
|
||||
],
|
||||
prefs: [], // { user_id, team_id, muted, email_mode, last_digest_at }
|
||||
}
|
||||
|
||||
const prefFor = (userId, teamId) =>
|
||||
store.prefs.find((p) => p.user_id === userId && p.team_id === teamId)
|
||||
|
||||
// The union the real RECIPIENT_UNION builds, with the same two conditions.
|
||||
const union = (teamId) => {
|
||||
const ids = new Set()
|
||||
for (const m of store.members) {
|
||||
if (m.team_id === teamId && m.status === 'active' && m.user_id != null) ids.add(m.user_id)
|
||||
}
|
||||
for (const g of store.grants) {
|
||||
if (g.team_id === teamId && g.revoked_at == null) ids.add(g.user_id)
|
||||
}
|
||||
return [...ids]
|
||||
}
|
||||
|
||||
patch(notifyDb, 'recipientIds', async (teamId, { exclude = [] } = {}) =>
|
||||
union(teamId)
|
||||
.filter((id) => !exclude.includes(id))
|
||||
.filter((id) => !(prefFor(id, teamId)?.muted)))
|
||||
|
||||
patch(notifyDb, 'emailRecipients', async (teamId, { exclude = [] } = {}) =>
|
||||
union(teamId)
|
||||
.filter((id) => !exclude.includes(id))
|
||||
.filter((id) => !(prefFor(id, teamId)?.muted))
|
||||
.map((id) => ({ id, user: store.users.find((u) => u.id === id) }))
|
||||
.filter(({ user }) => user && user.email && user.status === 'active')
|
||||
.map(({ id, user }) => ({
|
||||
user_id: id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
email_mode: prefFor(id, teamId)?.email_mode || 'off',
|
||||
last_digest_at: prefFor(id, teamId)?.last_digest_at || null,
|
||||
})))
|
||||
|
||||
patch(notifyDb, 'prefsForUser', async (userId) =>
|
||||
store.teams
|
||||
.filter((t) => union(t.id).includes(userId) || prefFor(userId, t.id))
|
||||
.map((t) => ({
|
||||
team_id: t.id,
|
||||
slug: t.slug,
|
||||
name: t.name,
|
||||
display_name_override: t.display_name_override,
|
||||
team_status: t.status,
|
||||
muted: prefFor(userId, t.id)?.muted ? 1 : 0,
|
||||
email_mode: prefFor(userId, t.id)?.email_mode || 'off',
|
||||
})))
|
||||
|
||||
patch(notifyDb, 'prefFor', async (userId, teamId) => prefFor(userId, teamId))
|
||||
|
||||
patch(notifyDb, 'setPref', async (userId, teamId, { muted, emailMode }) => {
|
||||
let row = prefFor(userId, teamId)
|
||||
if (!row) {
|
||||
row = { user_id: userId, team_id: teamId, muted: 0, email_mode: 'off', last_digest_at: null }
|
||||
store.prefs.push(row)
|
||||
}
|
||||
// Only the columns the caller named, exactly as the ON DUPLICATE KEY clause
|
||||
// does — this is the property the unsubscribe path depends on.
|
||||
if (muted != null) row.muted = muted ? 1 : 0
|
||||
if (emailMode != null) row.email_mode = emailMode
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(stub)
|
||||
afterEach(restore)
|
||||
|
||||
// ── 1. Access, and the rows that exist but do not count ────────────────────
|
||||
|
||||
test('recipients are active linked members plus active grants, and nobody else', async () => {
|
||||
const ids = await notifyModel.recipientIds(1)
|
||||
assert.deepEqual(ids.sort((a, b) => a - b), [10, 11, 20])
|
||||
})
|
||||
|
||||
test('a departed member and a revoked guest are not recipients', async () => {
|
||||
const ids = await notifyModel.recipientIds(1)
|
||||
assert.equal(ids.includes(12), false, 'a departed member is still in the table and must not be notified')
|
||||
assert.equal(ids.includes(21), false, 'a revoked grant is still in the ledger and must not be notified')
|
||||
})
|
||||
|
||||
test('an unlinked member contributes no recipient rather than a null one', async () => {
|
||||
const ids = await notifyModel.recipientIds(1)
|
||||
assert.equal(ids.includes(null), false)
|
||||
assert.equal(ids.every((id) => Number.isInteger(id)), true)
|
||||
})
|
||||
|
||||
// ── 2. Mutes ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('a mute removes that user from that Team only', async () => {
|
||||
await notifyModel.mute(10, 1)
|
||||
assert.deepEqual((await notifyModel.recipientIds(1)).sort((a, b) => a - b), [11, 20])
|
||||
assert.deepEqual(await notifyModel.recipientIds(2), [10], 'the same user is untouched in another Team')
|
||||
})
|
||||
|
||||
test('unmuting puts them back', async () => {
|
||||
await notifyModel.mute(10, 1)
|
||||
await notifyModel.unmute(10, 1)
|
||||
assert.equal((await notifyModel.recipientIds(1)).includes(10), true)
|
||||
})
|
||||
|
||||
test('a mute does not disturb an email mode the user chose', async () => {
|
||||
await notifyModel.replacePrefs(10, [{ teamId: 1, muted: false, emailMode: 'immediate' }])
|
||||
await notifyModel.mute(10, 1)
|
||||
const pref = await notifyModel.prefFor(10, 1)
|
||||
assert.equal(pref.muted, true)
|
||||
assert.equal(pref.emailMode, 'immediate', 'unsubscribing from one email must not silently rewrite the mode')
|
||||
})
|
||||
|
||||
// ── 3. The author ──────────────────────────────────────────────────────────
|
||||
|
||||
test('the author of a post is excluded from the notification about it', async () => {
|
||||
const ids = await notifyModel.recipientIds(1, { exclude: [10] })
|
||||
assert.equal(ids.includes(10), false)
|
||||
assert.deepEqual(ids.sort((a, b) => a - b), [11, 20])
|
||||
})
|
||||
|
||||
// ── 4. The two defaults, which point opposite ways ─────────────────────────
|
||||
|
||||
test('push is opt-OUT: a user who has never configured anything is a recipient', async () => {
|
||||
assert.equal(store.prefs.length, 0)
|
||||
assert.equal((await notifyModel.recipientIds(1)).includes(10), true)
|
||||
})
|
||||
|
||||
test('email is opt-IN: the same user is in no email mode until they pick one', async () => {
|
||||
const rows = await notifyModel.emailRecipients(1)
|
||||
assert.equal(rows.every((r) => r.email_mode === 'off'), true)
|
||||
assert.equal(rows.filter((r) => r.email_mode === 'digest' || r.email_mode === 'immediate').length, 0)
|
||||
})
|
||||
|
||||
test('a user with no email address is a push recipient and not an email one', async () => {
|
||||
assert.equal((await notifyModel.recipientIds(1)).includes(11), true)
|
||||
assert.equal((await notifyModel.emailRecipients(1)).some((r) => r.user_id === 11), false)
|
||||
})
|
||||
|
||||
// ── 5. Preferences a caller may write ──────────────────────────────────────
|
||||
|
||||
test('replacePrefs ignores a Team the caller is not in', async () => {
|
||||
// User 20 holds a grant on Team 1 and has nothing at all on Team 2.
|
||||
const { written } = await notifyModel.replacePrefs(20, [
|
||||
{ teamId: 1, muted: true, emailMode: 'digest' },
|
||||
{ teamId: 2, muted: true, emailMode: 'digest' },
|
||||
])
|
||||
assert.deepEqual(written, [1])
|
||||
assert.equal(store.prefs.some((p) => p.user_id === 20 && p.team_id === 2), false)
|
||||
})
|
||||
|
||||
test('replacePrefs really replaces: an omitted Team returns to its defaults', async () => {
|
||||
await notifyModel.replacePrefs(10, [
|
||||
{ teamId: 1, muted: true, emailMode: 'immediate' },
|
||||
{ teamId: 2, muted: true, emailMode: 'digest' },
|
||||
])
|
||||
// Now save a set that names only Team 1. Team 2 was not mentioned, so it goes
|
||||
// back to defaults — otherwise "PUT the whole set" is a lie and `teams: []`
|
||||
// clears nothing, which is the body the route requires so that clearing
|
||||
// everything is expressible in the first place.
|
||||
await notifyModel.replacePrefs(10, [{ teamId: 1, muted: true, emailMode: 'immediate' }])
|
||||
assert.deepEqual(await notifyModel.prefFor(10, 1), { teamId: 1, muted: true, emailMode: 'immediate' })
|
||||
assert.deepEqual(await notifyModel.prefFor(10, 2), { teamId: 2, muted: false, emailMode: 'off' })
|
||||
})
|
||||
|
||||
test('an empty set clears every preference the caller holds', async () => {
|
||||
await notifyModel.replacePrefs(10, [{ teamId: 1, muted: true, emailMode: 'digest' }])
|
||||
await notifyModel.replacePrefs(10, [])
|
||||
assert.equal((await notifyModel.prefFor(10, 1)).muted, false)
|
||||
assert.equal((await notifyModel.recipientIds(1)).includes(10), true)
|
||||
})
|
||||
|
||||
test('the reset does not touch last_digest_at — that is the worker’s column', async () => {
|
||||
await notifyModel.replacePrefs(10, [{ teamId: 1, muted: false, emailMode: 'digest' }])
|
||||
const row = store.prefs.find((p) => p.user_id === 10 && p.team_id === 1)
|
||||
row.last_digest_at = '2026-08-18T00:00:00Z'
|
||||
await notifyModel.replacePrefs(10, [])
|
||||
assert.equal(row.last_digest_at, '2026-08-18T00:00:00Z',
|
||||
'dropping it would re-open a day-wide window on every visit to the settings screen')
|
||||
})
|
||||
|
||||
test('an unknown email mode falls back to off rather than reaching the column', async () => {
|
||||
await notifyModel.replacePrefs(10, [{ teamId: 1, muted: false, emailMode: 'hourly' }])
|
||||
assert.equal((await notifyModel.prefFor(10, 1)).emailMode, 'off')
|
||||
})
|
||||
|
||||
test('listPrefs offers a Team the user has never configured', async () => {
|
||||
const prefs = await notifyModel.listPrefs(10)
|
||||
assert.deepEqual(prefs.map((p) => p.teamId).sort(), [1, 2])
|
||||
assert.equal(prefs.every((p) => p.muted === false && p.emailMode === 'off'), true)
|
||||
})
|
||||
|
||||
test('listPrefs names a Team by its display-name override when staff set one', async () => {
|
||||
store.teams[0].display_name_override = 'A Renamed Guild'
|
||||
const prefs = await notifyModel.listPrefs(10)
|
||||
assert.equal(prefs.find((p) => p.teamId === 1).name, 'A Renamed Guild')
|
||||
})
|
||||
|
||||
// ── 6. The unsubscribe token ───────────────────────────────────────────────
|
||||
|
||||
test('a token verifies for exactly the pair it was signed for', () => {
|
||||
const token = unsubscribeToken.sign(10, 1)
|
||||
assert.deepEqual(unsubscribeToken.verify(token), { userId: 10, teamId: 1 })
|
||||
})
|
||||
|
||||
test('editing the ids in a token invalidates it — the mac covers them', () => {
|
||||
const token = unsubscribeToken.sign(10, 1)
|
||||
const [v, uid, tid, mac] = token.split('.')
|
||||
assert.equal(unsubscribeToken.verify(`${v}.99.${tid}.${mac}`), null)
|
||||
assert.equal(unsubscribeToken.verify(`${v}.${uid}.99.${mac}`), null)
|
||||
})
|
||||
|
||||
test('a garbage token and a well-formed forgery both verify as null', () => {
|
||||
assert.equal(unsubscribeToken.verify('nonsense'), null)
|
||||
assert.equal(unsubscribeToken.verify(''), null)
|
||||
assert.equal(unsubscribeToken.verify(null), null)
|
||||
assert.equal(unsubscribeToken.verify('1.10.1.AAAAAAAAAAAAAAAAAAAAAA'), null)
|
||||
})
|
||||
|
||||
test('a version bump is what invalidates every outstanding link at once', () => {
|
||||
const token = unsubscribeToken.sign(10, 1)
|
||||
const [, uid, tid, mac] = token.split('.')
|
||||
assert.equal(unsubscribeToken.verify(`${unsubscribeToken.VERSION + 1}.${uid}.${tid}.${mac}`), null)
|
||||
})
|
||||
281
server/test/teamNotifyDispatch.test.js
Normal file
281
server/test/teamNotifyDispatch.test.js
Normal file
@@ -0,0 +1,281 @@
|
||||
// The Team notification fan-out and the digest worker (TEAMS.md §6.2/§6.4).
|
||||
//
|
||||
// The layer above teamNotify.test.js: that one asserts WHO a recipient set
|
||||
// contains, this one asserts what actually happens to them — which stream fires,
|
||||
// what a mail carries, and the four ways a notification is correctly suppressed.
|
||||
//
|
||||
// The suppressions are the point. A notification feature is mostly refusals, and
|
||||
// each of these is one that would be invisible until it went wrong in production:
|
||||
//
|
||||
// • forums switched off silences forum notifications, including the digest;
|
||||
// • no email configured means the sink is absent, not broken;
|
||||
// • a Team's FIRST roster does not wake 155 phones;
|
||||
// • a failed send does not stamp `last_digest_at`, so the window is retried
|
||||
// rather than silently skipped.
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const notify = require('../src/utils/teamNotify')
|
||||
const digest = require('../src/utils/teamDigestWorker')
|
||||
const pushDispatch = require('../src/utils/pushDispatch')
|
||||
const mailer = require('../src/utils/mailer')
|
||||
const forumSettings = require('../src/model/teams/teamForumSettings.model')
|
||||
const notifyModel = require('../src/model/teams/teamNotify.model')
|
||||
const registries = require('../src/modules/registries')
|
||||
|
||||
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 TEAM = { id: 1, slug: 'silver-hand', name: 'The Silver Hand', external_id: 'g1', display_name_override: null }
|
||||
|
||||
let sent // tickles
|
||||
let mails // emails
|
||||
let world
|
||||
|
||||
function stub({ forumsEnabled = true, emailConfigured = true, recipients = [10, 11], emailRows = [] } = {}) {
|
||||
sent = []
|
||||
mails = []
|
||||
world = { stamped: [] }
|
||||
|
||||
patch(forumSettings, 'forumsEnabled', async () => forumsEnabled)
|
||||
patch(mailer, 'isConfigured', async () => emailConfigured)
|
||||
patch(mailer, 'sendTeamNotification', async (msg) => {
|
||||
mails.push(msg)
|
||||
return { sent: true }
|
||||
})
|
||||
patch(pushDispatch, 'publishToUsers', async (streamId, payload) => { sent.push({ streamId, ...payload }) })
|
||||
patch(notifyModel, 'recipientIds', async (teamId, { exclude = [] } = {}) =>
|
||||
recipients.filter((id) => !exclude.includes(id)))
|
||||
patch(notifyModel, 'emailRecipients', async (teamId, { exclude = [] } = {}) =>
|
||||
emailRows.filter((r) => !exclude.includes(r.user_id)))
|
||||
patch(notifyModel, 'stampDigest', async (userId, teamId, at) => { world.stamped.push({ userId, teamId, at }) })
|
||||
// No module registered: the default in most tests, so the link-building ones
|
||||
// have to opt in and the absence is exercised rather than assumed.
|
||||
patch(registries, 'registeredTeamProvider', () => null)
|
||||
}
|
||||
|
||||
beforeEach(() => stub())
|
||||
afterEach(restore)
|
||||
|
||||
// ── Which stream fires ─────────────────────────────────────────────────────
|
||||
|
||||
test('a discussion reply fires team.forum.post; an announcement fires its own stream', async () => {
|
||||
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
|
||||
await notify.forumPost({ team: TEAM, threadId: 8, threadTitle: 'Notice', type: 'announcement', authorUserId: 10 })
|
||||
assert.deepEqual(sent.map((s) => s.streamId), ['team.forum.post', 'team.announcement'])
|
||||
})
|
||||
|
||||
test('the tickle is content-free and refs the thread, never the body', async () => {
|
||||
await notify.forumPost({
|
||||
team: TEAM, threadId: 7, threadTitle: 'Secret plans', type: 'discussion', authorUserId: 99,
|
||||
bodyHtml: '<p>the actual private text</p>',
|
||||
})
|
||||
assert.deepEqual(Object.keys(sent[0]).sort(), ['ref', 'streamId', 'userIds'])
|
||||
assert.equal(sent[0].ref, 'team:1:thread:7')
|
||||
assert.equal(JSON.stringify(sent[0]).includes('private text'), false)
|
||||
})
|
||||
|
||||
test('the author is not among the tickled', async () => {
|
||||
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
|
||||
assert.deepEqual(sent[0].userIds, [11])
|
||||
})
|
||||
|
||||
test('roster events fire one tickle for the run, not one per member', async () => {
|
||||
await notify.memberJoined(TEAM)
|
||||
await notify.leadershipChanged(TEAM)
|
||||
assert.deepEqual(sent.map((s) => s.streamId), ['team.member.joined', 'team.leadership.changed'])
|
||||
assert.equal(sent.every((s) => s.ref === 'team:1'), true)
|
||||
})
|
||||
|
||||
// ── Suppression ────────────────────────────────────────────────────────────
|
||||
|
||||
test('forums switched off silences a forum notification entirely', async () => {
|
||||
stub({ forumsEnabled: false })
|
||||
const res = await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
|
||||
assert.deepEqual(res, { push: 0, emails: 0 })
|
||||
assert.equal(sent.length, 0)
|
||||
})
|
||||
|
||||
test('a roster tickle survives forums being off — it is not forum content', async () => {
|
||||
stub({ forumsEnabled: false })
|
||||
await notify.memberJoined(TEAM)
|
||||
assert.equal(sent.length, 1)
|
||||
})
|
||||
|
||||
test('no recipients means no publish call at all', async () => {
|
||||
stub({ recipients: [] })
|
||||
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
|
||||
assert.equal(sent.length, 0)
|
||||
})
|
||||
|
||||
test('a fan-out never throws, whatever the layer below does', async () => {
|
||||
patch(notifyModel, 'recipientIds', async () => { throw new Error('database is on fire') })
|
||||
const res = await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
|
||||
assert.deepEqual(res, { push: 0, emails: 0 })
|
||||
assert.equal(await notify.memberJoined(TEAM), 0)
|
||||
})
|
||||
|
||||
// ── Email, the immediate mode ──────────────────────────────────────────────
|
||||
|
||||
const IMMEDIATE = [{ user_id: 11, username: 'eleven', email: 'e@example.test', email_mode: 'immediate' }]
|
||||
|
||||
test('only the immediate-mode recipients are emailed per event', async () => {
|
||||
stub({
|
||||
emailRows: [
|
||||
...IMMEDIATE,
|
||||
{ user_id: 12, username: 'twelve', email: 'd@example.test', email_mode: 'digest' },
|
||||
{ user_id: 13, username: 'thirteen', email: 'o@example.test', email_mode: 'off' },
|
||||
],
|
||||
})
|
||||
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10, bodyHtml: '<p>hello</p>' })
|
||||
assert.deepEqual(mails.map((m) => m.to), ['e@example.test'])
|
||||
})
|
||||
|
||||
test('an email carries an excerpt and never the whole post', async () => {
|
||||
stub({ emailRows: IMMEDIATE })
|
||||
const long = `<p>${'x'.repeat(500)}</p>`
|
||||
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10, bodyHtml: long })
|
||||
const body = mails[0].items[0].excerpt
|
||||
assert.equal(body.length < 250, true)
|
||||
assert.equal(body.endsWith('…'), true)
|
||||
})
|
||||
|
||||
test('no email configured means no send and no recipient query', async () => {
|
||||
stub({ emailConfigured: false, emailRows: IMMEDIATE })
|
||||
const res = await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
|
||||
assert.equal(res.emails, 0)
|
||||
assert.equal(mails.length, 0)
|
||||
})
|
||||
|
||||
test('every email carries an unsubscribe url for that recipient and that Team', async () => {
|
||||
stub({ emailRows: IMMEDIATE })
|
||||
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
|
||||
assert.equal(typeof mails[0].unsubscribeUrl, 'string')
|
||||
// The header one is an API endpoint (a one-click client POSTs to it without
|
||||
// rendering anything); the body one is the site's page. They must differ.
|
||||
assert.notEqual(mails[0].unsubscribeUrl, mails[0].unsubscribeApiUrl)
|
||||
assert.match(mails[0].unsubscribeApiUrl, /\/api\/v1\/public\/teams\/unsubscribe\//)
|
||||
})
|
||||
|
||||
// ── Links, and the module that supplies them ───────────────────────────────
|
||||
|
||||
test('with no module-supplied template there is no Team link, and nothing breaks', async () => {
|
||||
assert.equal(notify.teamPageUrl(TEAM), null)
|
||||
assert.equal(notify.threadUrl(TEAM, 7), null)
|
||||
})
|
||||
|
||||
test('a registered template becomes an absolute link, and a thread deep-links by search param', () => {
|
||||
patch(registries, 'registeredTeamProvider', () => ({ pageUrlTemplate: '/uo/guilds/{externalId}' }))
|
||||
assert.match(notify.teamPageUrl(TEAM), /\/uo\/guilds\/g1$/)
|
||||
assert.match(notify.threadUrl(TEAM, 7), /\/uo\/guilds\/g1\?thread=7$/)
|
||||
})
|
||||
|
||||
test('a Team is labelled by its display-name override where staff set one', () => {
|
||||
assert.equal(notify.teamLabel(TEAM), 'The Silver Hand')
|
||||
assert.equal(notify.teamLabel({ ...TEAM, display_name_override: 'Renamed' }), 'Renamed')
|
||||
})
|
||||
|
||||
// ── The digest worker ──────────────────────────────────────────────────────
|
||||
|
||||
const DIGEST_ROW = { user_id: 11, username: 'eleven', email: 'e@example.test', email_mode: 'digest', last_digest_at: null }
|
||||
|
||||
function stubDigest({ posts = [], teams = [TEAM], rows = [DIGEST_ROW], sendOk = true } = {}) {
|
||||
patch(notifyModel, 'teamsWithForumActivitySince', async () => teams)
|
||||
patch(notifyModel, 'emailRecipients', async () => rows)
|
||||
patch(notifyModel, 'digestPostsSince', async () => posts)
|
||||
patch(mailer, 'sendTeamNotification', async (msg) => {
|
||||
mails.push(msg)
|
||||
return { sent: sendOk }
|
||||
})
|
||||
}
|
||||
|
||||
test('a digest gathers the Team’s new posts into one mail', async () => {
|
||||
stubDigest({
|
||||
posts: [
|
||||
{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>tonight</p>', author_username: 'ten' },
|
||||
{ id: 2, thread_id: 7, title: 'Raid', body_html: '<p>bring rope</p>', author_username: 'eleven' },
|
||||
],
|
||||
})
|
||||
const res = await digest.tick(new Date())
|
||||
assert.equal(res.sent, 1)
|
||||
assert.equal(mails.length, 1)
|
||||
assert.equal(mails[0].items.length, 2)
|
||||
})
|
||||
|
||||
test('a recipient with nothing new gets no mail and no stamp', async () => {
|
||||
stubDigest({ posts: [] })
|
||||
const res = await digest.tick(new Date())
|
||||
assert.equal(res.sent, 0)
|
||||
assert.equal(mails.length, 0)
|
||||
assert.equal(world.stamped.length, 0, 'stamping here would move the window past unsent posts')
|
||||
})
|
||||
|
||||
test('a failed send leaves last_digest_at alone so the window is retried', async () => {
|
||||
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }], sendOk: false })
|
||||
const res = await digest.tick(new Date())
|
||||
assert.equal(res.sent, 0)
|
||||
assert.equal(world.stamped.length, 0)
|
||||
})
|
||||
|
||||
test('a successful send stamps exactly that (user, Team)', async () => {
|
||||
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }] })
|
||||
const now = new Date()
|
||||
await digest.tick(now)
|
||||
assert.deepEqual(world.stamped, [{ userId: 11, teamId: 1, at: now }])
|
||||
})
|
||||
|
||||
test('only digest-mode recipients are swept', async () => {
|
||||
stubDigest({
|
||||
posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }],
|
||||
rows: [
|
||||
DIGEST_ROW,
|
||||
{ user_id: 12, username: 'twelve', email: 'i@example.test', email_mode: 'immediate', last_digest_at: null },
|
||||
{ user_id: 13, username: 'thirteen', email: 'o@example.test', email_mode: 'off', last_digest_at: null },
|
||||
],
|
||||
})
|
||||
await digest.tick(new Date())
|
||||
assert.deepEqual(mails.map((m) => m.to), ['e@example.test'])
|
||||
})
|
||||
|
||||
test('the sweep is skipped whole when forums are off or email is unconfigured', async () => {
|
||||
stub({ forumsEnabled: false })
|
||||
assert.equal((await digest.tick(new Date())).skipped, 'forums-disabled')
|
||||
stub({ emailConfigured: false })
|
||||
assert.equal((await digest.tick(new Date())).skipped, 'email-unconfigured')
|
||||
})
|
||||
|
||||
test('the sweep never throws, and says so in its summary', async () => {
|
||||
patch(notifyModel, 'teamsWithForumActivitySince', async () => { throw new Error('nope') })
|
||||
assert.equal((await digest.tick(new Date())).skipped, 'error')
|
||||
})
|
||||
|
||||
// ── The digest window ──────────────────────────────────────────────────────
|
||||
|
||||
test('a first digest reaches back one interval, not to the lookback floor', () => {
|
||||
const now = new Date('2026-08-18T12:00:00Z')
|
||||
const since = digest.clampSince(null, now)
|
||||
assert.equal(now - since <= 24 * 60 * 60 * 1000, true)
|
||||
})
|
||||
|
||||
test('a long outage is clamped: one digest, not a week of replay', () => {
|
||||
const now = new Date('2026-08-18T12:00:00Z')
|
||||
const ancient = new Date('2026-01-01T00:00:00Z')
|
||||
const since = digest.clampSince(ancient, now)
|
||||
assert.equal(now - since, digest.MAX_LOOKBACK_MS)
|
||||
})
|
||||
|
||||
test('an ordinary last-send is used as-is', () => {
|
||||
const now = new Date('2026-08-18T12:00:00Z')
|
||||
const yesterday = new Date('2026-08-17T12:00:00Z')
|
||||
assert.equal(digest.clampSince(yesterday, now).getTime(), yesterday.getTime())
|
||||
})
|
||||
@@ -309,6 +309,39 @@ test('a non-function projectRoster is rejected at registration, not at call time
|
||||
)
|
||||
})
|
||||
|
||||
// ── pageUrlTemplate: the optional fifth member (§6.4) ──────────────────────
|
||||
//
|
||||
// Data, not a method, and the only thing core can use to link to a Team page —
|
||||
// Teams have no core surface, so the module that owns the page has to say where
|
||||
// it is. Validated hard because the output goes into an email as a link.
|
||||
|
||||
test('pageUrlTemplate is optional: a provider without it registers fine', () => {
|
||||
const api = registries.stage('uo')
|
||||
assert.doesNotThrow(() => api.registerTeamProvider(ok()))
|
||||
})
|
||||
|
||||
test('a relative template is kept exactly as given', () => {
|
||||
register('uo', { ...ok(), pageUrlTemplate: '/uo/guilds/{externalId}' })
|
||||
assert.equal(registries.registeredTeamProvider().pageUrlTemplate, '/uo/guilds/{externalId}')
|
||||
})
|
||||
|
||||
test('an absolute template is refused — a module may not redirect the site’s mail', () => {
|
||||
const api = registries.stage('uo')
|
||||
for (const bad of [
|
||||
'https://evil.test/{externalId}',
|
||||
'//evil.test/x',
|
||||
'uo/guilds/{externalId}', // not rooted
|
||||
'/uo/guilds/{externalId}?x=<script>',
|
||||
42,
|
||||
]) {
|
||||
assert.throws(
|
||||
() => api.registerTeamProvider({ ...ok(), pageUrlTemplate: bad }),
|
||||
/pageUrlTemplate must be a relative path/,
|
||||
`expected "${bad}" to be refused`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unregistered method cannot ride along into the provider core calls', () => {
|
||||
register('uo', { ...ok(), somethingElse: async () => 'hi' })
|
||||
assert.equal(registries.registeredTeamProvider().somethingElse, undefined)
|
||||
|
||||
@@ -13,6 +13,7 @@ const registries = require('../src/modules/registries')
|
||||
const teamsDb = require('../src/model/teams/teams.db')
|
||||
const moderation = require('../src/model/teams/teamModeration.model')
|
||||
const activity = require('../src/model/teams/teamActivity.model')
|
||||
const teamNotify = require('../src/utils/teamNotify')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const teamSync = require('../src/model/teams/teamSync.model')
|
||||
|
||||
@@ -203,6 +204,13 @@ function stubDb() {
|
||||
store.activity.push(item)
|
||||
return true
|
||||
})
|
||||
|
||||
// The push half of the same run (TEAMS.md §6.2, phase 6). Captured the same way
|
||||
// and for the same reason: the fan-out is its own unit, and what belongs here is
|
||||
// WHEN the reconciler decides to fire one.
|
||||
store.tickles = []
|
||||
patch(teamNotify, 'memberJoined', async () => { store.tickles.push('member.joined') })
|
||||
patch(teamNotify, 'leadershipChanged', async () => { store.tickles.push('leadership.changed') })
|
||||
}
|
||||
|
||||
// A provider whose answers the test controls. Defaults are authoritative and
|
||||
@@ -851,6 +859,39 @@ test('the FIRST roster emits nothing — an import is not 155 people joining', a
|
||||
await teamSync.reconcileNow('setup')
|
||||
assert.equal(activeMembers(1).length, 2, 'the members did land')
|
||||
assert.deepEqual(store.activity, [], 'and none of them was announced')
|
||||
// The same suppression, and the half where it matters more: a notification per
|
||||
// imported member would wake every phone in a 155-member guild at once.
|
||||
assert.deepEqual(store.tickles, [], 'and nobody was notified either')
|
||||
})
|
||||
|
||||
test('a roster run tickles ONCE per stream, however many members moved', async () => {
|
||||
provide(withMembers([member('0x1')]))
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
await resync(withMembers([
|
||||
member('0x1'),
|
||||
member('0x2', { displayName: 'Brenna' }),
|
||||
member('0x3', { displayName: 'Cael' }),
|
||||
]), 'test')
|
||||
|
||||
// Two joins, one tickle. The feed above records each of them, because it is a
|
||||
// record; the tickle says "something happened here" and is content-free, so a
|
||||
// second identical one carries no second piece of information.
|
||||
assert.equal(store.activity.length, 2)
|
||||
assert.deepEqual(store.tickles, ['member.joined'])
|
||||
})
|
||||
|
||||
test('a leadership change tickles its own stream, separately from joins', async () => {
|
||||
provide(withMembers([member('0x1'), member('0x2')]))
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
await resync({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||||
getTeamMembers: async () => ({ ok: true, complete: true, members: [member('0x1'), member('0x2')] }),
|
||||
getTeamLeaders: async () => ({ ok: true, leaders: ['0x2'] }),
|
||||
}, 'test')
|
||||
|
||||
assert.deepEqual(store.tickles, ['leadership.changed'])
|
||||
})
|
||||
|
||||
test('a member arriving after the first roster is announced', async () => {
|
||||
|
||||
Reference in New Issue
Block a user