Merge pull request 'feat(engagement): the engagement system — cutover 3 of 7 (edge → main)' (#180) from edge into main
Reviewed-on: #180 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
14
.env.example
14
.env.example
@@ -57,7 +57,7 @@ DB_ROOT_PASSWORD=change-me-root-password
|
||||
# Auth
|
||||
JWT_SECRET=change-me-to-a-long-random-string
|
||||
# Encrypts every secret this site stores at rest (AES-256-GCM): OAuth client
|
||||
# secrets, the Discord bot token, the Gmail refresh token, the uo-link auth
|
||||
# secrets, the Discord bot token, the mail transport credentials, the uo-link auth
|
||||
# token. REQUIRED in production — with NODE_ENV=production the app REFUSES TO
|
||||
# START without it (utils/secretBox.js), so a Compose deployment that leaves it
|
||||
# blank crash-loops before it ever listens. Development falls back to a key
|
||||
@@ -98,10 +98,14 @@ TOTP_CHALLENGE_TTL=5m
|
||||
ADMIN_USERNAME=
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not via
|
||||
# env. It reuses the Google auth provider's OAuth client and stores an encrypted
|
||||
# refresh token in the DB. Until it's connected, the contact form falls back to
|
||||
# a mailto: link (recipient = the `contact_email` site setting).
|
||||
# Email is configured in Admin → Settings → Email, not via env: pick a mail
|
||||
# transport (SMTP) and enter its host, port and credentials, which are stored
|
||||
# encrypted in the DB. Three postures work — a relay (Mailgun/SES/Postmark) is
|
||||
# the recommended one, a mailbox provider over SMTP (e.g. smtp.gmail.com:587
|
||||
# with an app password) is the simplest, and an unauthenticated local MTA on
|
||||
# port 25 needs no credentials at all. Until one is configured the contact form
|
||||
# falls back to a mailto: link (recipient = the `contact_email` site setting).
|
||||
# Upgrading from the removed Gmail connect flow: see docs/website/UPGRADE_NOTES.md.
|
||||
|
||||
# CORS — only needed for local dev when the Vite dev server is a different origin.
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
|
||||
@@ -56,6 +56,12 @@ jobs:
|
||||
# something found under a pile of unrelated failures, and it costs
|
||||
# nothing when it passes.
|
||||
run: npm run check:modules
|
||||
- name: Check the engagement subsystem names no external host
|
||||
# ENGAGEMENT.md §3.2 rule 4 — no transport may ship a default host,
|
||||
# endpoint or sender. Dependency-free and runs before the install for the
|
||||
# same reason as the check above: a phone-home is a design break, not a
|
||||
# test failure, and it should be the first thing a reviewer sees.
|
||||
run: npm run check:hosts
|
||||
- name: Install server deps
|
||||
run: npm ci --prefix server
|
||||
- name: Run server tests
|
||||
@@ -69,6 +75,15 @@ jobs:
|
||||
# of a reviewer instead of letting it pass silently.
|
||||
run: npm run routes:manifest --prefix server -- --check
|
||||
|
||||
- name: Check the engagement trigger manifest is current
|
||||
# ENGAGEMENT.md 4.3 property 4 - the same mechanism as the route manifest
|
||||
# above, for the event contract instead of the URL surface. A trigger
|
||||
# declaration is what a stored template interpolates and what a stored
|
||||
# rule is written against, so renaming a variable or widening a ceiling
|
||||
# breaks them silently, at send time, in mail someone already received.
|
||||
# Regenerating and diffing makes that change something a reviewer reads.
|
||||
run: npm run engagement:manifest --prefix server -- --check
|
||||
|
||||
client-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
12
README.md
12
README.md
@@ -151,7 +151,7 @@ flowchart TB
|
||||
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
|
||||
| Database | MariaDB 11 (own container) |
|
||||
| Frontend | React 18, Vite 5, React Router 6 |
|
||||
| Email | Nodemailer via Gmail OAuth2 (configured in admin), with a `mailto:` fallback |
|
||||
| Email | Nodemailer over a configurable mail transport — SMTP (relay, mailbox provider or your own MTA), set up in the admin panel — with a `mailto:` fallback |
|
||||
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
|
||||
| Deploy | Docker Compose, any reverse proxy (Pangolin, Nginx, Caddy, Traefik, …) |
|
||||
|
||||
@@ -584,7 +584,7 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
||||
| `TOTP_ISSUER` | `BRAND_NAME` | label shown in authenticator apps for optional per-user 2FA |
|
||||
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
|
||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
|
||||
| _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting |
|
||||
| _Email_ | — | configured in Admin → Settings → Email (transport + credentials), never via env; recipient = `contact_email` setting. Upgrading from the removed Gmail connect flow: see [`docs/website/UPGRADE_NOTES.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/UPGRADE_NOTES.md) |
|
||||
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
||||
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
|
||||
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
|
||||
@@ -677,9 +677,11 @@ run this repo as UOMysticmoon.
|
||||
|
||||
- `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs
|
||||
behind a reverse proxy (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
|
||||
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail
|
||||
OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the
|
||||
contact form falls back to a `mailto:` link when unconfigured.
|
||||
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through a mail
|
||||
transport configured in the admin, whose credentials are stored AES-GCM-encrypted and are
|
||||
write-only over the API (never returned, never in env); no transport ships a default host or
|
||||
sender, so an unconfigured deployment sends nowhere. The contact form falls back to a `mailto:`
|
||||
link when unconfigured.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@ import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||
import UserDetail from './routes/admin/views/UserDetail.jsx'
|
||||
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
|
||||
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
|
||||
import EngagementRules from './routes/admin/views/EngagementRules.jsx'
|
||||
import EngagementAudiences from './routes/admin/views/EngagementAudiences.jsx'
|
||||
import EngagementTemplates from './routes/admin/views/EngagementTemplates.jsx'
|
||||
import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
|
||||
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
|
||||
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
|
||||
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
|
||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||
@@ -54,10 +60,12 @@ import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
||||
import PlayerRegister from './routes/player/PlayerRegister.jsx'
|
||||
import ForgotPassword from './routes/player/ForgotPassword.jsx'
|
||||
import ResetPassword from './routes/player/ResetPassword.jsx'
|
||||
import VerifyEmail from './routes/player/VerifyEmail.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 PlayerInbox from './routes/player/PlayerInbox.jsx'
|
||||
import Unsubscribe from './routes/player/Unsubscribe.jsx'
|
||||
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
||||
|
||||
@@ -183,7 +191,34 @@ export default function App() {
|
||||
actions that publish a game-written name is applied per request
|
||||
on the server, from the caller's live role (TEAMS.md 2.9). */}
|
||||
<Route path="teams" element={<TeamsAdmin />} />
|
||||
{/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the
|
||||
server: every route under /admin/engagement re-gates to `admin`
|
||||
on top of the group's staff gate, because this is the group that
|
||||
decides who receives mail. */}
|
||||
<Route
|
||||
path="engagement"
|
||||
element={
|
||||
<RoleGate roles={['admin']}>
|
||||
<Outlet />
|
||||
</RoleGate>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="rules" replace />} />
|
||||
<Route path="rules" element={<EngagementRules />} />
|
||||
<Route path="audiences" element={<EngagementAudiences />} />
|
||||
<Route path="templates" element={<EngagementTemplates />} />
|
||||
<Route path="triggers" element={<EngagementTriggers />} />
|
||||
<Route path="sends" element={<EngagementSendLog />} />
|
||||
<Route path="suppressions" element={<EngagementSuppressions />} />
|
||||
</Route>
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
{/* Staff have an inbox and channel preferences like anyone else —
|
||||
`/auth/me/notifications` is behind requireAuth only — but
|
||||
`RequirePlayer` sends them out of the player portal, so the two
|
||||
screens are mounted here as well. Same components, same API,
|
||||
two paths; `lib/notificationPaths.js` is the one mapping. */}
|
||||
<Route path="notifications" element={<PlayerInbox />} />
|
||||
<Route path="notifications/settings" element={<PlayerNotifications />} />
|
||||
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
||||
RequireAuth + AdminLayout. A module cannot supply its own auth
|
||||
wrapper — only an optional { roles }, which core applies as the
|
||||
@@ -205,6 +240,9 @@ export default function App() {
|
||||
<Route path="/account/register" element={<PlayerRegister />} />
|
||||
<Route path="/account/forgot" element={<ForgotPassword />} />
|
||||
<Route path="/account/reset/:token" element={<ResetPassword />} />
|
||||
{/* Opened from a mailbox, so public like the reset page above — the
|
||||
token is the proof, and confirming issues no session. */}
|
||||
<Route path="/account/verify-email/:token" element={<VerifyEmail />} />
|
||||
<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
|
||||
@@ -224,7 +262,14 @@ 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 />} />
|
||||
{/* The inbox took `/account/notifications` in engagement Phase 7
|
||||
and the preferences screen moved under it. Content and
|
||||
settings are different kinds of thing, and the plain word
|
||||
belongs to the one a person means when they say it — which is
|
||||
also what the bell in the header opens. The server's routes
|
||||
split at the same place. */}
|
||||
<Route path="/account/notifications" element={<PlayerInbox />} />
|
||||
<Route path="/account/notifications/settings" element={<PlayerNotifications />} />
|
||||
{/* Installed modules' player-portal pages, at /player/<id>/…. This
|
||||
group's own routes are absolute (its layout route has no path),
|
||||
so the prefix is written here rather than inherited — the one
|
||||
|
||||
@@ -105,6 +105,36 @@ export const api = {
|
||||
revokeTrustedDevice: (id) =>
|
||||
req(`/auth/me/trusted-devices/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
revokeAllTrustedDevices: () => req('/auth/me/trusted-devices', { method: 'DELETE' }),
|
||||
// Self-service account security, role-agnostic under /auth/me/account. This is
|
||||
// the ONLY surface for it: the /admin/account/* and /player/account/* copies
|
||||
// were deleted (both were strictly smaller — neither carried recovery codes),
|
||||
// which is why recovery codes below already lived here while the rest did not.
|
||||
// The change endpoints re-issue the session cookie server-side, so the caller
|
||||
// stays signed in.
|
||||
myAccount: () => req('/auth/me/account'),
|
||||
changeUsername: (username) =>
|
||||
req('/auth/me/account/username', { method: 'PATCH', body: { username } }),
|
||||
changePassword: (newPassword, currentPassword) =>
|
||||
req('/auth/me/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
|
||||
// Email address (engagement Phase 1b). changeEmail STAGES the address — the
|
||||
// account keeps its current one until the emailed link is opened — so the UI
|
||||
// must show `email_pending` as pending, never as the address in force.
|
||||
changeEmail: (email, currentPassword) =>
|
||||
req('/auth/me/account/email', { method: 'PATCH', body: { email, currentPassword } }),
|
||||
resendEmailVerification: () => req('/auth/me/account/email/resend', { method: 'POST' }),
|
||||
cancelEmailChange: () => req('/auth/me/account/email/pending', { method: 'DELETE' }),
|
||||
// The confirm half is public and token-gated — it is reached from a mailbox,
|
||||
// often with no session, so it deliberately sits outside /auth/me.
|
||||
lookupEmailVerification: (token) => req(`/auth/email/verify/${encodeURIComponent(token)}`),
|
||||
confirmEmailVerification: (token) =>
|
||||
req(`/auth/email/verify/${encodeURIComponent(token)}`, { method: 'POST' }),
|
||||
totpSetup: () => req('/auth/me/account/totp/setup', { method: 'POST' }),
|
||||
totpEnable: (code) => req('/auth/me/account/totp/enable', { method: 'POST', body: { code } }),
|
||||
totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }),
|
||||
// Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link.
|
||||
myIdentities: () => req('/auth/me/account/identities'),
|
||||
unlinkIdentity: (provider) =>
|
||||
req(`/auth/me/account/identities/${encodeURIComponent(provider)}`, { method: 'DELETE' }),
|
||||
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
|
||||
// returned ONCE (password step-up for accounts that have a password).
|
||||
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
|
||||
@@ -200,6 +230,25 @@ export const api = {
|
||||
// field, so clearing the last subscription must not become an absent key.
|
||||
setNotificationSubscriptions: (streams) =>
|
||||
req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }),
|
||||
// Per-channel preferences (ENGAGEMENT.md Phase 3). A SPARSE update: only the
|
||||
// (id, channel) pairs sent are written, so a screen managing one channel need
|
||||
// not know what the others hold. Shipped with no surface at all until Phase 7.
|
||||
notificationChannelPrefs: () => req('/auth/me/notifications/channels'),
|
||||
setNotificationChannelPrefs: (prefs) =>
|
||||
req('/auth/me/notifications/channels', { method: 'PUT', body: { prefs } }),
|
||||
// The in-app inbox (ENGAGEMENT.md Phase 7). `before` is a keyset cursor — the
|
||||
// id of the last item on the previous page — not an offset: the list gains
|
||||
// rows at the top while it is being read.
|
||||
notifications: ({ limit, before, unread } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
if (before) qs.set('before', String(before))
|
||||
if (unread) qs.set('unread', 'true')
|
||||
return req(`/auth/me/notifications${withQs(qs.toString())}`)
|
||||
},
|
||||
notificationsUnreadCount: () => req('/auth/me/notifications/unread-count'),
|
||||
markNotificationRead: (id) => req(`/auth/me/notifications/${id}/read`, { method: 'POST' }),
|
||||
markAllNotificationsRead: () => req('/auth/me/notifications/read-all', { method: 'POST' }),
|
||||
teamNotificationPrefs: () => req('/auth/me/notifications/teams'),
|
||||
setTeamNotificationPrefs: (teams) =>
|
||||
req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }),
|
||||
@@ -294,6 +343,12 @@ export const api = {
|
||||
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||
// Accounts whose address was cleared when addresses became unique (Phase 1b).
|
||||
// They can still sign in but can receive no mail until they set a new one, so
|
||||
// they are the list an operator has to work through.
|
||||
emailDedupeReport: () => req('/admin/users/email-dedupe-report'),
|
||||
acknowledgeEmailDedupeReport: () =>
|
||||
req('/admin/users/email-dedupe-report/acknowledge', { method: 'POST' }),
|
||||
// A user's trusted devices + MFA reset (admin only).
|
||||
userTrustedDevices: (id) => req(`/admin/users/${id}/trusted-devices`),
|
||||
revokeUserTrustedDevice: (id, deviceId) =>
|
||||
@@ -321,6 +376,83 @@ export const api = {
|
||||
setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
|
||||
restartServer: () => req('/admin/modules/restart', { method: 'POST' }),
|
||||
|
||||
// Engagement (docs/website/ENGAGEMENT.md Phase 4b). The first three are the
|
||||
// catalog — triggers, audiences and channels, all served from the registries
|
||||
// rather than from tables, so an installed module's declarations appear here
|
||||
// without a client release.
|
||||
//
|
||||
// `setEngagementRuleEnabled` is its own call rather than a `saveEngagementRule`
|
||||
// with one field, because the route is its own route: turning a rule off must
|
||||
// work on a rule the registries would now refuse, which is exactly the rule an
|
||||
// operator most wants stopped.
|
||||
//
|
||||
// `previewEngagementReach` answers with a COUNT and never a list of people.
|
||||
engagementTriggers: () => req('/admin/engagement/triggers'),
|
||||
engagementAudiences: () => req('/admin/engagement/audiences'),
|
||||
engagementChannels: () => req('/admin/engagement/channels'),
|
||||
listEngagementRules: () => req('/admin/engagement/rules'),
|
||||
createEngagementRule: (body) => req('/admin/engagement/rules', { method: 'POST', body }),
|
||||
updateEngagementRule: (id, body) => req(`/admin/engagement/rules/${id}`, { method: 'PUT', body }),
|
||||
setEngagementRuleEnabled: (id, enabled) =>
|
||||
req(`/admin/engagement/rules/${id}/enabled`, { method: 'PATCH', body: { enabled } }),
|
||||
deleteEngagementRule: (id) => req(`/admin/engagement/rules/${id}`, { method: 'DELETE' }),
|
||||
listEngagementSegments: () => req('/admin/engagement/segments'),
|
||||
createEngagementSegment: (body) => req('/admin/engagement/segments', { method: 'POST', body }),
|
||||
updateEngagementSegment: (id, body) => req(`/admin/engagement/segments/${id}`, { method: 'PUT', body }),
|
||||
deleteEngagementSegment: (id) => req(`/admin/engagement/segments/${id}`, { method: 'DELETE' }),
|
||||
previewEngagementReach: ({ audience, audienceSegmentId, triggerId } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (audienceSegmentId) qs.set('audienceSegmentId', String(audienceSegmentId))
|
||||
else if (audience) qs.set('audience', audience)
|
||||
if (triggerId) qs.set('triggerId', triggerId)
|
||||
return req(`/admin/engagement/audience-preview${withQs(qs.toString())}`)
|
||||
},
|
||||
|
||||
// Templates and the send log (engagement Phase 5b). `previewEngagementTemplate`
|
||||
// and `testSendEngagementTemplate` are POSTs that write nothing: both act on
|
||||
// the draft in the request, so the editor can show and send what is on screen
|
||||
// rather than what was last saved.
|
||||
listEngagementTemplates: () => req('/admin/engagement/templates'),
|
||||
getEngagementTemplate: (id) => req(`/admin/engagement/templates/${id}`),
|
||||
updateEngagementTemplate: (id, body) =>
|
||||
req(`/admin/engagement/templates/${id}`, { method: 'PUT', body }),
|
||||
duplicateEngagementTemplate: (id, body) =>
|
||||
req(`/admin/engagement/templates/${id}/duplicate`, { method: 'POST', body }),
|
||||
deleteEngagementTemplate: (id) => req(`/admin/engagement/templates/${id}`, { method: 'DELETE' }),
|
||||
previewEngagementTemplate: (id, body) =>
|
||||
req(`/admin/engagement/templates/${id}/preview`, { method: 'POST', body }),
|
||||
testSendEngagementTemplate: (id, body) =>
|
||||
req(`/admin/engagement/templates/${id}/test-send`, { method: 'POST', body }),
|
||||
listEngagementSends: ({ limit, offset, triggerId, ruleId, userId, status } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
if (offset) qs.set('offset', String(offset))
|
||||
if (triggerId) qs.set('triggerId', triggerId)
|
||||
if (ruleId) qs.set('ruleId', String(ruleId))
|
||||
if (userId) qs.set('userId', String(userId))
|
||||
if (status) qs.set('status', status)
|
||||
return req(`/admin/engagement/sends${withQs(qs.toString())}`)
|
||||
},
|
||||
|
||||
// Suppressions (Phase 9). `unsuppressAddress` sends the address in the BODY
|
||||
// of a DELETE rather than in the path, and that is not style: a path
|
||||
// parameter lands in the access log, the browser history and every proxy in
|
||||
// front of the deployment, and this one is a real person's address. The list
|
||||
// never returns a hash to use instead.
|
||||
listEngagementSuppressions: ({ limit, offset, reason, channel, search } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
if (offset) qs.set('offset', String(offset))
|
||||
if (reason) qs.set('reason', reason)
|
||||
if (channel) qs.set('channel', channel)
|
||||
if (search) qs.set('search', search)
|
||||
return req(`/admin/engagement/suppressions${withQs(qs.toString())}`)
|
||||
},
|
||||
suppressAddress: (address, detail) =>
|
||||
req('/admin/engagement/suppressions', { method: 'POST', body: { address, detail } }),
|
||||
unsuppressAddress: (address, channel) =>
|
||||
req('/admin/engagement/suppressions', { method: 'DELETE', body: { address, channel } }),
|
||||
|
||||
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
|
||||
// different depending on who calls them: for a moderator, unhide and
|
||||
// setTeamDisplayName file a request and the response says `pending: true`.
|
||||
@@ -435,16 +567,6 @@ export const api = {
|
||||
req(`/admin/moderation/appeals/${id}/resolve`, { method: 'POST', body: data }),
|
||||
getUserAppeals: (discordId) => req(`/admin/moderation/user/${discordId}/appeals`),
|
||||
|
||||
// ----- account security (self-service 2FA) -----
|
||||
getAccount: () => req('/admin/account'),
|
||||
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
||||
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
|
||||
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
|
||||
|
||||
// ----- linked SSO identities (self-service) -----
|
||||
linkedIdentities: () => req('/admin/account/identities'),
|
||||
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- auth providers / SSO config (admin only) -----
|
||||
listAuthProviders: () => req('/admin/auth/providers'),
|
||||
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
||||
@@ -455,29 +577,19 @@ export const api = {
|
||||
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
||||
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
||||
|
||||
// ----- Email delivery / Gmail OAuth2 (admin only) -----
|
||||
// ----- Email delivery (admin only) -----
|
||||
// The connect-flow call went with Gmail OAuth2 (ENGAGEMENT.md §1.2a); the
|
||||
// config response now carries the transport catalog the form renders from.
|
||||
getEmailConfig: () => req('/admin/email/config'),
|
||||
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
||||
emailConnectUrl: () => req('/admin/email/connect/start'),
|
||||
testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }),
|
||||
disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }),
|
||||
},
|
||||
|
||||
// ----- player self-service (role: 'player') -----
|
||||
// Mirrors the admin account methods but self-scoped under /player. The change
|
||||
// endpoints re-issue the session cookie server-side, so the caller stays signed in.
|
||||
// Account security is NOT here — it is role-agnostic and lives at the root of
|
||||
// this object, on /auth/me/account. What remains is genuinely player-scoped.
|
||||
player: {
|
||||
getAccount: () => req('/player/account'),
|
||||
changeUsername: (username) =>
|
||||
req('/player/account/username', { method: 'PATCH', body: { username } }),
|
||||
changePassword: (newPassword, currentPassword) =>
|
||||
req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
|
||||
totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }),
|
||||
totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }),
|
||||
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
|
||||
linkedIdentities: () => req('/player/account/identities'),
|
||||
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- moderation appeals (self-service) -----
|
||||
getMyAppeals: () => req('/player/appeals'),
|
||||
getEligibleAppeals: () => req('/player/appeals/eligible'),
|
||||
|
||||
353
client/src/components/NotificationBell.jsx
Normal file
353
client/src/components/NotificationBell.jsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { api } from '../api/client.js'
|
||||
import { inboxPath } from '../lib/notificationPaths.js'
|
||||
|
||||
// The in-app inbox's header surface (ENGAGEMENT.md Phase 7): a bell with an
|
||||
// unread badge, and a panel with the most recent items.
|
||||
//
|
||||
// **The badge is polled, not pushed**, and the reason is that there is nothing
|
||||
// to push over. The site's two SSE streams are the shard's; neither is
|
||||
// per-user, and adding a third authenticated stream to carry an integer would
|
||||
// mean one open connection per signed-in tab for the rest of the deployment's
|
||||
// life. A minute-granular badge on a page somebody is already looking at is the
|
||||
// same answer for a fraction of that. The poll pauses while the tab is hidden —
|
||||
// a background tab has nobody to show a badge to — and refreshes the moment it
|
||||
// comes back, which is also the moment it would be most wrong.
|
||||
//
|
||||
// **The panel shows a handful and links out.** Paging belongs on the page; a
|
||||
// dropdown that scrolls is a list in the wrong place.
|
||||
//
|
||||
// Dismissal follows `NavDropdown`'s contract exactly — Escape closes and
|
||||
// returns focus, an outside `mousedown` closes, navigating closes — because
|
||||
// this sits beside it in the same header and two menus that dismiss differently
|
||||
// is a bug nobody files.
|
||||
|
||||
const POLL_MS = 60_000
|
||||
const PANEL_ITEMS = 6
|
||||
|
||||
function BellIcon({ size = 17 }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.7 21a2 2 0 01-3.4 0" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// "3m", "4h", "6d" — a relative stamp, because the only question a reader has
|
||||
// about an inbox item's time is how fresh it is.
|
||||
function ago(iso) {
|
||||
const then = new Date(iso).getTime()
|
||||
if (!Number.isFinite(then)) return ''
|
||||
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
|
||||
if (secs < 60) return 'now'
|
||||
if (secs < 3600) return `${Math.floor(secs / 60)}m`
|
||||
if (secs < 86400) return `${Math.floor(secs / 3600)}h`
|
||||
return `${Math.floor(secs / 86400)}d`
|
||||
}
|
||||
|
||||
export default function NotificationBell() {
|
||||
const { user } = useAuth()
|
||||
const [unread, setUnread] = useState(0)
|
||||
const [items, setItems] = useState([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const wrapRef = useRef(null)
|
||||
const triggerRef = useRef(null)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Every read here swallows its failure. A count that could not be fetched is
|
||||
// a bell with no badge, which is what a bell with nothing to report looks
|
||||
// like anyway — the alternative is an error banner in the site header for a
|
||||
// number nobody asked for.
|
||||
const refreshCount = useCallback(async () => {
|
||||
if (!user) return
|
||||
try {
|
||||
const res = await api.notificationsUnreadCount()
|
||||
setUnread(res.unread || 0)
|
||||
} catch {
|
||||
/* leave the badge as it was */
|
||||
}
|
||||
}, [user])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return undefined
|
||||
refreshCount()
|
||||
const timer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') refreshCount()
|
||||
}, POLL_MS)
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') refreshCount()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
}
|
||||
}, [user, refreshCount])
|
||||
|
||||
// The panel's items are fetched when it opens, never kept warm: a list nobody
|
||||
// has asked to see is a request per minute for content nobody is reading.
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.notifications({ limit: PANEL_ITEMS })
|
||||
setItems(res.items || [])
|
||||
setUnread(res.unread || 0)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load notifications')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => setOpen(false), [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Escape') return
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
const onOutside = (e) => {
|
||||
if (!wrapRef.current?.contains(e.target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
document.addEventListener('mousedown', onOutside)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.removeEventListener('mousedown', onOutside)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!user) return null
|
||||
|
||||
const toggle = () => {
|
||||
const next = !open
|
||||
setOpen(next)
|
||||
if (next) load()
|
||||
}
|
||||
|
||||
// Opening an item marks it read and then goes where it points. The mark is
|
||||
// awaited rather than fired off, so the badge the next screen renders is the
|
||||
// one this click produced; a failed mark still navigates, because the item's
|
||||
// link is the thing the user asked for.
|
||||
const openItem = async (item) => {
|
||||
setOpen(false)
|
||||
if (!item.read) {
|
||||
try {
|
||||
const res = await api.markNotificationRead(item.id)
|
||||
setUnread(res.unread ?? Math.max(0, unread - 1))
|
||||
} catch {
|
||||
/* the link still works */
|
||||
}
|
||||
}
|
||||
navigate(item.url || inboxPath(user))
|
||||
}
|
||||
|
||||
const markAll = async () => {
|
||||
try {
|
||||
await api.markAllNotificationsRead()
|
||||
setUnread(0)
|
||||
setItems((list) => list.map((i) => ({ ...i, read: true })))
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not mark them read')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="pill"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
// The count is in the label, not only in the badge: a screen reader gets
|
||||
// "Notifications, 3 unread" rather than "Notifications" and a number it
|
||||
// has no way to relate to it.
|
||||
aria-label={unread ? `Notifications, ${unread} unread` : 'Notifications'}
|
||||
onClick={toggle}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
position: 'relative',
|
||||
...(open ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : {}),
|
||||
}}
|
||||
>
|
||||
<BellIcon />
|
||||
{unread > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="sans"
|
||||
style={{
|
||||
minWidth: 17,
|
||||
height: 17,
|
||||
padding: '0 4px',
|
||||
borderRadius: 9,
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--bg-deep)',
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: '17px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label="Notifications"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 6px)',
|
||||
right: 0,
|
||||
width: 320,
|
||||
maxWidth: 'calc(100vw - 24px)',
|
||||
padding: 6,
|
||||
borderRadius: 'var(--radius-card)',
|
||||
border: '1px solid var(--line)',
|
||||
background: 'var(--panel-flat)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 10,
|
||||
padding: '4px 8px 8px',
|
||||
}}
|
||||
>
|
||||
<strong className="sans" style={{ fontSize: '0.82rem', color: 'var(--head)' }}>
|
||||
Notifications
|
||||
</strong>
|
||||
{unread > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAll}
|
||||
className="sans"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.78rem',
|
||||
}}
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ margin: '0 8px 8px', fontSize: '0.8rem', color: '#d98b84' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!error && items.length === 0 && (
|
||||
<p className="sans dim" style={{ margin: '0 8px 10px', fontSize: '0.82rem' }}>
|
||||
Nothing here yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => openItem(item)}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '8px 10px',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: item.read ? 'transparent' : 'var(--panel)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.85rem',
|
||||
color: item.read ? 'var(--muted)' : 'var(--head)',
|
||||
fontWeight: item.read ? 400 : 600,
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
{item.body && (
|
||||
<span
|
||||
className="dim"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
marginTop: 2,
|
||||
// The body is stored and rendered as TEXT, never as markup —
|
||||
// `white-space: pre-line` is what keeps the template's own
|
||||
// line breaks without ever interpreting anything.
|
||||
whiteSpace: 'pre-line',
|
||||
// Two lines, then an ellipsis. `-webkit-box` is the only
|
||||
// clamp with real support; it is also why there is no second
|
||||
// `display: block` above it.
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{item.body}
|
||||
</span>
|
||||
)}
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.72rem', marginTop: 3 }}>
|
||||
{ago(item.createdAt)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<Link
|
||||
to={inboxPath(user)}
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'block',
|
||||
marginTop: 4,
|
||||
padding: '8px 10px',
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--accent)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
See all notifications →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import BrandLogo from './BrandLogo.jsx'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import NavDropdown from './NavDropdown.jsx'
|
||||
import NotificationBell from './NotificationBell.jsx'
|
||||
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
|
||||
import { parseJsonSetting } from '../lib/settingsJson.js'
|
||||
import { withModuleNav } from '../modules/nav.js'
|
||||
@@ -107,6 +108,10 @@ export default function SiteHeader() {
|
||||
</NavLink>
|
||||
),
|
||||
)}
|
||||
{/* Renders nothing when signed out, so the header keeps its shape for
|
||||
a visitor. It is here rather than only in the portal because an
|
||||
inbox item is worth seeing from the page you are already on. */}
|
||||
{!loading && <NotificationBell />}
|
||||
{!loading && (
|
||||
<NavLink
|
||||
to={account.to}
|
||||
|
||||
175
client/src/components/security/EmailAddressPanel.jsx
Normal file
175
client/src/components/security/EmailAddressPanel.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// Self-service email address (engagement Phase 1b). Shared by the player portal
|
||||
// and the admin account screen, the same way TrustedDevicesPanel and
|
||||
// RecoveryCodesPanel are — /auth/me/account is one surface for every role, so its
|
||||
// UI is one component too.
|
||||
//
|
||||
// The property this component exists to make visible: a requested address is
|
||||
// STAGED, not applied. The account keeps receiving mail — password resets
|
||||
// included — at the address it already has until the emailed link is opened. If
|
||||
// the UI let a pending address look like the address in force, someone who
|
||||
// mistyped would believe the change took and would only discover otherwise when
|
||||
// they could not recover their account.
|
||||
//
|
||||
// `hasPassword` decides whether the current-password field appears: an address is
|
||||
// where account recovery lands, so changing it is re-authenticated, with the same
|
||||
// carve-out the password form makes for an SSO-only account.
|
||||
export default function EmailAddressPanel({ account, reload, embedded = false }) {
|
||||
const hasPassword = account.has_password !== false
|
||||
const [email, setEmail] = useState('')
|
||||
const [current, setCurrent] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const pending = account.email_pending
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault()
|
||||
setMsg('')
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await api.changeEmail(email.trim(), hasPassword ? current : undefined)
|
||||
setEmail('')
|
||||
setCurrent('')
|
||||
// Report an unsent mail honestly. Saying "check your inbox" about a message
|
||||
// that was never sent turns a configuration problem into a user who waits.
|
||||
if (res.emailed === false) {
|
||||
setMsg(
|
||||
res.reason === 'NOT_CONFIGURED'
|
||||
? 'Address saved, but this site cannot send email right now. Ask an administrator, then use Resend.'
|
||||
: 'Address saved, but the confirmation email could not be sent. Try Resend in a moment.',
|
||||
)
|
||||
} else {
|
||||
setMsg(
|
||||
`Confirmation sent to ${res.email_pending}. Your current address stays in use until you open that link.`,
|
||||
)
|
||||
}
|
||||
await reload()
|
||||
} catch (err) {
|
||||
if (err.status === 429) setError('Too many confirmation emails. Try again later.')
|
||||
else setError(err.message || 'Could not change your email address.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function resend() {
|
||||
setMsg('')
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await api.resendEmailVerification()
|
||||
setMsg(
|
||||
res.emailed === false
|
||||
? 'Could not send the confirmation email.'
|
||||
: `Confirmation re-sent to ${res.email_pending}.`,
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not resend the confirmation email.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function discard() {
|
||||
setMsg('')
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.cancelEmailChange()
|
||||
setMsg('Pending address discarded.')
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not discard the pending address.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const wrap = embedded
|
||||
? {}
|
||||
: { marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }
|
||||
|
||||
return (
|
||||
<div style={wrap}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
Email address
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
{account.email ? (
|
||||
<>
|
||||
Currently <strong style={{ color: 'var(--head)' }}>{account.email}</strong>
|
||||
{account.email_verified ? ' (confirmed)' : ' (not yet confirmed)'}. This is where password-reset
|
||||
email is sent.
|
||||
</>
|
||||
) : (
|
||||
'You have no email address on file, so you cannot reset your password by email.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
{pending && (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
border: '1px solid var(--line-soft)',
|
||||
borderRadius: 6,
|
||||
padding: '10px 12px',
|
||||
marginBottom: 16,
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: 'var(--head)' }}>{pending}</strong> is waiting to be confirmed. It is not in
|
||||
use until you open the link in that email.
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||
<button type="button" onClick={resend} disabled={busy} className="btn btn-sq">
|
||||
Resend
|
||||
</button>
|
||||
<button type="button" onClick={discard} disabled={busy} className="btn btn-sq">
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={save} style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 320 }}>
|
||||
<label>
|
||||
<span className="field-label">{pending ? 'Use a different address' : 'New email address'}</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</label>
|
||||
{hasPassword && (
|
||||
<label>
|
||||
<span className="field-label">Current password</span>
|
||||
<input
|
||||
type="password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div>
|
||||
<button type="submit" disabled={busy || !email.trim()} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Send confirmation'}
|
||||
</button>
|
||||
</div>
|
||||
{(msg || error) && (
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.85rem', color: error ? '#e08a8a' : 'var(--muted)' }}>
|
||||
{error || msg}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
client/src/emailBlocks/index.js
Normal file
12
client/src/emailBlocks/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
// Client email-block registry entrypoint. Importing this module registers every
|
||||
// `email.*` authoring definition exactly once, then re-exports the registry API.
|
||||
// The template editor imports from HERE, never from ./registry, so the
|
||||
// definitions are loaded before anything reads the palette.
|
||||
//
|
||||
// Same shape as `blocks/index.js` — and the same reason for existing.
|
||||
|
||||
export * from './registry'
|
||||
export { VariablePalette } from './types.jsx'
|
||||
|
||||
// ── Definitions (self-register on import) ──────────────────────────────────
|
||||
import './types.jsx'
|
||||
100
client/src/emailBlocks/registry.js
Normal file
100
client/src/emailBlocks/registry.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// ── The client-side `email.*` block registry ───────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.6.2, Phase 5b. A sibling of `blocks/registry.js` for the same
|
||||
// reason its server counterpart is a sibling of `blocks/registry.js` on that side
|
||||
// — and with ONE structural difference that is the whole argument for the shape of
|
||||
// this screen:
|
||||
//
|
||||
// **an email block definition here has no `component`.**
|
||||
//
|
||||
// A page block carries a React renderer because a page IS React. A mail body is a
|
||||
// string this deployment's server produces, and the preview shows exactly that
|
||||
// string. Giving these entries a React renderer would mean two renderers for one
|
||||
// artifact — one drawing the editor's preview, one producing what actually lands
|
||||
// in someone's inbox — and nothing would make them agree. They would agree on the
|
||||
// day they were written and drift from the first Outlook fix onward, at which
|
||||
// point the preview becomes a confident lie about mail nobody can see.
|
||||
//
|
||||
// So the division is: **this registry owns authoring, the server owns rendering.**
|
||||
// Everything here is about the editing experience — the palette entry, the prop
|
||||
// form, the starting props — and the preview arrives from
|
||||
// `POST /admin/engagement/templates/:id/preview` as HTML that goes into a
|
||||
// sandboxed iframe.
|
||||
//
|
||||
// `type` and `version` must match the server definition in
|
||||
// `server/src/emailBlocks/types/`. That pairing is the same discipline the page
|
||||
// family already runs on, and the save is the thing that enforces it: the server
|
||||
// validates against its own registry, so a client entry that has drifted produces
|
||||
// a refused save rather than a bad row.
|
||||
|
||||
const registry = new Map()
|
||||
|
||||
// The same reserved envelope keys the server's `RESERVED_KEYS` names. Duplicated
|
||||
// rather than imported because the client cannot import from `server/`, exactly as
|
||||
// `blocks/registry.js` duplicates them — and, as there, the server is the one that
|
||||
// decides: a block this list let through is still refused at the save.
|
||||
export const RESERVED_KEYS = ['id', 'type', 'version', 'visible', 'props']
|
||||
|
||||
/**
|
||||
* Register an email block definition.
|
||||
*
|
||||
* @param {object} def
|
||||
* @param {string} def.type must match the server type, e.g. 'email.heading'
|
||||
* @param {number} def.version must match the server schema version
|
||||
* @param {string} def.label palette display name
|
||||
* @param {string} def.icon palette icon glyph
|
||||
* @param {Function} def.editor ({ props, onChange, variables }) => JSX
|
||||
* @param {Function} def.defaults starting props when the block is added
|
||||
*/
|
||||
export function registerEmailBlock(def) {
|
||||
if (!def || typeof def.type !== 'string' || !def.type.startsWith('email.')) {
|
||||
throw new Error('registerEmailBlock: a definition needs a type namespaced "email."')
|
||||
}
|
||||
if (registry.has(def.type)) {
|
||||
throw new Error(`registerEmailBlock: block type already registered: ${def.type}`)
|
||||
}
|
||||
const entry = {
|
||||
type: def.type,
|
||||
version: Number.isInteger(def.version) ? def.version : 1,
|
||||
label: def.label || def.type,
|
||||
icon: def.icon || null,
|
||||
// The one-line description under the palette button. Mail blocks are less
|
||||
// self-evident than page ones — "Item list" does not say that it repeats over
|
||||
// a variable — and the palette is where that has to be said.
|
||||
hint: def.hint || '',
|
||||
editor: def.editor || null,
|
||||
defaults: typeof def.defaults === 'function' ? def.defaults : () => ({}),
|
||||
}
|
||||
registry.set(entry.type, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/** @returns {object|null} the definition for `type`, or null if unknown. */
|
||||
export function getEmailBlock(type) {
|
||||
return registry.get(type) || null
|
||||
}
|
||||
|
||||
/** @returns {object[]} every definition, in registration order — the palette. */
|
||||
export function listEmailBlocks() {
|
||||
return [...registry.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh block envelope of `type`, ready to push onto the array.
|
||||
*
|
||||
* The id is random rather than sequential because block ids are unique across the
|
||||
* whole document and an operator can delete block 2 and add another; a counter
|
||||
* would hand out an id that is already taken and the save would be refused for a
|
||||
* reason nothing on screen explains.
|
||||
*/
|
||||
export function newEmailBlock(type) {
|
||||
const def = getEmailBlock(type)
|
||||
if (!def) return null
|
||||
return {
|
||||
id: `b${Math.random().toString(36).slice(2, 10)}`,
|
||||
type: def.type,
|
||||
version: def.version,
|
||||
visible: true,
|
||||
props: def.defaults(),
|
||||
}
|
||||
}
|
||||
272
client/src/emailBlocks/types.jsx
Normal file
272
client/src/emailBlocks/types.jsx
Normal file
@@ -0,0 +1,272 @@
|
||||
// The six `email.*` block editors, in one file rather than one file each.
|
||||
//
|
||||
// The page family gives every block its own module because each carries a React
|
||||
// RENDERER as well as a form, and those are substantial. An email block carries
|
||||
// only a form — the rendering is the server's (see ./registry.js) — and six short
|
||||
// prop panels split across six files would be six imports of the same three
|
||||
// controls to no benefit.
|
||||
//
|
||||
// Every `type` and `version` here pairs with a definition in
|
||||
// `server/src/emailBlocks/types/`, and the field lists are the server's `onlyKeys`
|
||||
// lists. Where a server schema has a bound (`MAX_TEXT`, `MAX_LABEL`), the input
|
||||
// carries the same `maxLength` — not as the check, which is the server's, but so
|
||||
// that an operator meets the limit while typing rather than at the save.
|
||||
import { TextField, TextAreaField, SelectField, Field } from '../blocks/editorKit.jsx'
|
||||
import { registerEmailBlock } from './registry'
|
||||
|
||||
/**
|
||||
* The variable palette, rendered under whichever field is being edited.
|
||||
*
|
||||
* Clicking a variable APPENDS its token rather than inserting at the caret. That
|
||||
* is a deliberate simplification: tracking a caret across a controlled React input
|
||||
* that a parent may re-render costs a ref and a selection-restore on every change,
|
||||
* and appending is both predictable and trivially undone. §4.6.2's requirement is
|
||||
* that inserting a variable "writes a token; it is never free-text" — which this
|
||||
* satisfies — not that it lands at the cursor.
|
||||
*/
|
||||
export function VariablePalette({ variables, onInsert }) {
|
||||
if (!variables || !variables.length) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
|
||||
{variables.map((v) => (
|
||||
<button
|
||||
key={v.name}
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
title={`${v.type || 'string'}${v.required ? ' · required' : ''}${v.description ? ` — ${v.description}` : ''}`}
|
||||
onClick={() => onInsert(`{{${v.name}}}`)}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.72rem', padding: '2px 6px' }}
|
||||
>
|
||||
{v.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A text field with the palette attached — the shape four of the six blocks want. */
|
||||
function VariableTextField({ label, hint, value, onChange, variables, maxLength, area, rows }) {
|
||||
const Control = area ? TextAreaField : TextField
|
||||
return (
|
||||
<div>
|
||||
<Control
|
||||
label={label}
|
||||
hint={hint}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
maxLength={maxLength}
|
||||
rows={rows}
|
||||
/>
|
||||
<VariablePalette variables={variables} onInsert={(token) => onChange(`${value || ''}${token}`)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.heading',
|
||||
version: 1,
|
||||
label: 'Heading',
|
||||
icon: 'H',
|
||||
hint: 'A section heading, at one of three sizes.',
|
||||
defaults: () => ({ level: 'h2', text: 'Heading' }),
|
||||
editor: ({ props, onChange, variables }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SelectField
|
||||
label="Size"
|
||||
// Named "Size" and not "Level" for the reason the server block's header
|
||||
// gives: mail clients build no outline from a message, so this is
|
||||
// typography rather than structure, and calling it a level in the UI would
|
||||
// invite someone to use it as one.
|
||||
hint="Mail clients build no document outline, so this is a size, not a rank."
|
||||
value={props.level || 'h2'}
|
||||
onChange={(level) => onChange({ ...props, level })}
|
||||
options={[
|
||||
['h1', 'Large'],
|
||||
['h2', 'Medium'],
|
||||
['h3', 'Small'],
|
||||
]}
|
||||
/>
|
||||
<VariableTextField
|
||||
label="Text"
|
||||
value={props.text}
|
||||
maxLength={200}
|
||||
variables={variables}
|
||||
onChange={(text) => onChange({ ...props, text })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.text',
|
||||
version: 1,
|
||||
label: 'Paragraph',
|
||||
icon: '¶',
|
||||
hint: 'A paragraph of body text.',
|
||||
defaults: () => ({ text: 'Write your message here.', muted: false }),
|
||||
editor: ({ props, onChange, variables }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<VariableTextField
|
||||
label="Text"
|
||||
area
|
||||
rows={5}
|
||||
value={props.text}
|
||||
maxLength={4000}
|
||||
variables={variables}
|
||||
onChange={(text) => onChange({ ...props, text })}
|
||||
/>
|
||||
<Field label="Style">
|
||||
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(props.muted)}
|
||||
onChange={(e) => onChange({ ...props, muted: e.target.checked })}
|
||||
/>
|
||||
<span>Quieter — for footnotes and small print</span>
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.button',
|
||||
version: 1,
|
||||
label: 'Button / link',
|
||||
icon: '▭',
|
||||
hint: 'The call to action. Its plain-text form is a sentence plus the URL.',
|
||||
defaults: () => ({ label: 'Open', url: '/', textLead: 'Open it here:' }),
|
||||
editor: ({ props, onChange, variables }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<TextField
|
||||
label="Button text"
|
||||
value={props.label}
|
||||
maxLength={80}
|
||||
onChange={(label) => onChange({ ...props, label })}
|
||||
/>
|
||||
<VariableTextField
|
||||
label="Link"
|
||||
hint="Usually a variable, so the link is built for each recipient."
|
||||
value={props.url}
|
||||
maxLength={600}
|
||||
variables={variables}
|
||||
onChange={(url) => onChange({ ...props, url })}
|
||||
/>
|
||||
<TextField
|
||||
label="Plain-text lead-in"
|
||||
// The server block's header is worth repeating here in one line, because
|
||||
// this field looks optional and is the difference between a bare URL and a
|
||||
// sentence in every text-only inbox.
|
||||
hint="A button is nothing in plain text. This sentence introduces the link there, e.g. “Choose a new password here:”."
|
||||
value={props.textLead}
|
||||
maxLength={200}
|
||||
onChange={(textLead) => onChange({ ...props, textLead })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.divider',
|
||||
version: 1,
|
||||
label: 'Divider',
|
||||
icon: '—',
|
||||
hint: 'A horizontal rule.',
|
||||
defaults: () => ({}),
|
||||
editor: () => (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
A divider has nothing to configure.
|
||||
</p>
|
||||
),
|
||||
})
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.image',
|
||||
version: 1,
|
||||
label: 'Image',
|
||||
icon: '▣',
|
||||
hint: 'An image by URL. Many clients block images until the reader allows them.',
|
||||
defaults: () => ({ url: '/brand/logo.png', alt: 'Logo' }),
|
||||
editor: ({ props, onChange, variables }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<VariableTextField
|
||||
label="Image URL"
|
||||
value={props.url}
|
||||
maxLength={600}
|
||||
variables={variables}
|
||||
onChange={(url) => onChange({ ...props, url })}
|
||||
/>
|
||||
<TextField
|
||||
label="Alt text"
|
||||
hint="Most mail clients block images by default, so for many readers this IS the image."
|
||||
value={props.alt}
|
||||
maxLength={200}
|
||||
onChange={(alt) => onChange({ ...props, alt })}
|
||||
/>
|
||||
<Field label="Width" hint="Pixels, 16-560. Leave blank to let the image size itself.">
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={16}
|
||||
max={560}
|
||||
value={props.width ?? ''}
|
||||
// Blank REMOVES the prop rather than setting it to 0. The server accepts
|
||||
// `width` absent or between 16 and 560, so a 0 left behind by an empty
|
||||
// field is a refused save whose message names a field the operator
|
||||
// believes they cleared.
|
||||
onChange={(e) => {
|
||||
const next = { ...props }
|
||||
const value = Number(e.target.value)
|
||||
if (!e.target.value || !Number.isFinite(value)) delete next.width
|
||||
else next.width = Math.trunc(value)
|
||||
onChange(next)
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.itemList',
|
||||
version: 1,
|
||||
label: 'Item list',
|
||||
icon: '☰',
|
||||
hint: 'Repeats over a list variable — this is how a digest lists its items.',
|
||||
defaults: () => ({ variable: '', emptyText: '' }),
|
||||
editor: ({ props, onChange, variables }) => {
|
||||
// Only LIST variables may be chosen, and the field is a select rather than a
|
||||
// text input because this prop is a bare NAME, not a token: a typo here is the
|
||||
// one variable reference a reader of the template cannot see is wrong, and it
|
||||
// renders as an empty mail rather than as a visible gap.
|
||||
const lists = (variables || []).filter((v) => v.type === 'list' || v.type === 'array')
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{lists.length ? (
|
||||
<SelectField
|
||||
label="List variable"
|
||||
hint="Each item becomes a row with its heading, excerpt and link."
|
||||
value={props.variable || ''}
|
||||
onChange={(variable) => onChange({ ...props, variable })}
|
||||
options={[['', 'Choose a list…'], ...lists.map((v) => [v.name, v.name])]}
|
||||
/>
|
||||
) : (
|
||||
<Field label="List variable">
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem', margin: 0 }}>
|
||||
This template’s trigger declares no list variable, so an item list has nothing to
|
||||
repeat over. Point the template at a trigger that declares one — a digest, typically —
|
||||
or use paragraphs instead.
|
||||
</p>
|
||||
</Field>
|
||||
)}
|
||||
<TextField
|
||||
label="When the list is empty"
|
||||
hint="Shown instead of the list. Leave blank to show nothing at all."
|
||||
value={props.emptyText}
|
||||
maxLength={200}
|
||||
onChange={(emptyText) => onChange({ ...props, emptyText })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
348
client/src/lib/engagementRules.js
Normal file
348
client/src/lib/engagementRules.js
Normal file
@@ -0,0 +1,348 @@
|
||||
// What the Engagement screens say, and what they let an operator choose.
|
||||
//
|
||||
// ENGAGEMENT.md Phase 4b. Plain JS in its own file for the reason
|
||||
// `lib/moduleAdmin.js` is: it is the part of these two screens worth testing, and
|
||||
// the test runner cannot reach a `.jsx`.
|
||||
//
|
||||
// **None of this is a boundary.** `engagementRules.model.js` on the server
|
||||
// decides what may be saved, and the engine re-checks the audience ceiling again
|
||||
// at send time. Everything here is an affordance — not offering a choice the
|
||||
// server is going to refuse, and saying why in the form rather than in a toast.
|
||||
// The two copies are expected to drift, which is why the server's is the one
|
||||
// that decides.
|
||||
//
|
||||
// The one rule worth stating out loud, because it is the reason the audience
|
||||
// list is derived rather than hardcoded: **the ceiling vocabulary comes from the
|
||||
// server** (`GET /admin/engagement/triggers` serves `ceilings`, each with the set
|
||||
// it `permits`). A second copy of the lattice in the client would be a second
|
||||
// copy of a security rule, and a second copy is a copy that drifts.
|
||||
|
||||
/** A rule row as the API returns it → the shape the form edits. */
|
||||
export function formFromRule(rule) {
|
||||
return {
|
||||
id: rule?.id ?? null,
|
||||
triggerId: rule?.trigger_id ?? '',
|
||||
name: rule?.name ?? '',
|
||||
enabled: Boolean(rule?.enabled),
|
||||
audience: rule?.audience ?? 'owner',
|
||||
audienceSegmentId: rule?.audience_segment_id ?? null,
|
||||
channels: Array.isArray(rule?.channels) ? [...rule.channels] : [],
|
||||
templateKeys: { ...(rule?.template_keys || {}) },
|
||||
conditions: rule?.conditions ?? null,
|
||||
cooldownSeconds: Number(rule?.cooldown_seconds ?? 0),
|
||||
delaySeconds: Number(rule?.delay_seconds ?? 0),
|
||||
cancelOn: Array.isArray(rule?.cancel_on) ? [...rule.cancel_on] : [],
|
||||
maxSendsPerHour: Number(rule?.max_sends_per_hour ?? 100),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The form → a POST/PUT body.
|
||||
*
|
||||
* `templateKeys` is filtered to the rule's channels rather than sent whole,
|
||||
* because unticking a channel in the form leaves its template key behind and the
|
||||
* server refuses a key naming a channel the rule does not have. Dropping it here
|
||||
* makes unticking a channel do the obvious thing instead of producing an error
|
||||
* about a field the operator cannot see.
|
||||
*/
|
||||
export function ruleToPayload(form) {
|
||||
const channels = [...new Set(form.channels || [])]
|
||||
const templateKeys = {}
|
||||
for (const channel of channels) {
|
||||
const key = (form.templateKeys || {})[channel]
|
||||
if (key) templateKeys[channel] = key
|
||||
}
|
||||
return {
|
||||
triggerId: form.triggerId,
|
||||
name: (form.name || '').trim(),
|
||||
enabled: Boolean(form.enabled),
|
||||
audience: form.audience,
|
||||
audienceSegmentId: form.audienceSegmentId ?? null,
|
||||
channels,
|
||||
templateKeys,
|
||||
conditions: form.conditions ?? null,
|
||||
cooldownSeconds: Number(form.cooldownSeconds) || 0,
|
||||
delaySeconds: Number(form.delaySeconds) || 0,
|
||||
cancelOn: [...new Set(form.cancelOn || [])],
|
||||
maxSendsPerHour: Number(form.maxSendsPerHour) || 100,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which plain audiences this trigger's ceiling allows, in lattice order.
|
||||
*
|
||||
* Derived from the `permits` list the server sends with each ceiling, so a
|
||||
* trigger declared `owner` offers only `owner` and the editor never presents a
|
||||
* choice the save is going to refuse. An unknown trigger (a dormant rule whose
|
||||
* module is gone) offers nothing rather than everything — failing closed is the
|
||||
* same posture `ceilings.permits` takes on the server.
|
||||
*/
|
||||
export function audienceChoicesFor(trigger, ceilings) {
|
||||
if (!trigger || !Array.isArray(ceilings)) return []
|
||||
const declared = ceilings.find((c) => c.id === trigger.ceiling)
|
||||
if (!declared) return []
|
||||
const allowed = new Set(declared.permits || [])
|
||||
return ceilings.filter((c) => allowed.has(c.id))
|
||||
}
|
||||
|
||||
/** Segments a rule under this trigger may point at — the same test, on the stored ceiling. */
|
||||
export function segmentChoicesFor(trigger, ceilings, segments) {
|
||||
const allowed = new Set(audienceChoicesFor(trigger, ceilings).map((c) => c.id))
|
||||
return (segments || []).filter((s) => allowed.has(s.ceiling))
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence rendered beside a reach preview.
|
||||
*
|
||||
* Every branch here exists because the bare number would be a lie in that case:
|
||||
* a capped count is a floor, an `owner` audience has no advance answer, a dormant
|
||||
* segment resolves to nobody for a reason worth naming, and a count the trigger's
|
||||
* ceiling forbids is a number the save is about to refuse.
|
||||
*/
|
||||
export function describeReach(preview) {
|
||||
if (!preview) return ''
|
||||
const why = operatorWords(preview.reason)
|
||||
if (preview.dormant) return `Resolves to nobody right now — ${why || 'dormant'}.`
|
||||
if (preview.permitted === false) {
|
||||
return `Reaches ${preview.count}, but this trigger does not permit that audience — saving will be refused.`
|
||||
}
|
||||
if (why) return `${preview.count} right now — ${why}.`
|
||||
if (preview.capped) return `At least ${preview.count} people (the preview stops counting there).`
|
||||
return preview.count === 1 ? '1 person right now.' : `${preview.count} people right now.`
|
||||
}
|
||||
|
||||
/**
|
||||
* The server says "segment"; these screens say "saved audience".
|
||||
*
|
||||
* The API, the schema and the docs all call it a segment and should keep doing
|
||||
* so - it is one word for one table. But an operator meets the concept here,
|
||||
* under a heading that says "Audiences", and a sentence that switches vocabulary
|
||||
* mid-screen reads as a sentence about something else.
|
||||
*/
|
||||
export function operatorWords(text) {
|
||||
if (!text) return text
|
||||
// Word-wise rather than a regex, so "segmented" and the like are left alone.
|
||||
const swap = { segment: 'saved audience', segments: 'saved audiences' }
|
||||
return String(text)
|
||||
.split(' ')
|
||||
.map((word) => swap[word] || word)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* The one audience choice that silently reaches nobody, said out loud.
|
||||
*
|
||||
* `members` is the ceiling for "a module-declared list". Without a saved
|
||||
* audience naming WHICH list there is no list, and core knows no game vocabulary
|
||||
* with which to guess - so the rule resolves to the empty set every time it
|
||||
* fires. It is also the DEFAULT the moment an operator picks a `members`-ceiling
|
||||
* trigger, which is what makes it a trap rather than a curiosity: the rule saves,
|
||||
* switches on, and mails nobody, with nothing on the screen saying so unless the
|
||||
* operator happens to press Preview.
|
||||
*
|
||||
* Returns a sentence, or null when there is nothing to warn about.
|
||||
*/
|
||||
export function audienceWarning(form) {
|
||||
if (!form) return null
|
||||
if (form.audienceSegmentId) return null
|
||||
if (form.audience === 'members') {
|
||||
return 'This reaches nobody as it stands. “Members of a module-declared list” needs a saved audience naming which list.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Segment expressions ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `not` is legal only as a child of `and` — the server's rule, checked here so
|
||||
* the composer can grey the button out instead of letting the operator build
|
||||
* something and then be refused.
|
||||
*
|
||||
* The reason, from §5.1a: a complement needs a universe, and the only one that
|
||||
* does not widen is the set its siblings produced. `A AND NOT B` is "A, less B".
|
||||
* A bare `NOT B`, or `A OR NOT B`, would have to mean "everyone except…", which
|
||||
* is a way to build the whole deployment out of one narrow audience.
|
||||
*/
|
||||
export function notPlacementError(expression) {
|
||||
const walk = (node, underAnd) => {
|
||||
if (!node || typeof node !== 'object') return null
|
||||
if (!node.op) return null
|
||||
if (node.op === 'not' && !underAnd) {
|
||||
return 'An excluded audience can only be used alongside an included one — on its own it would mean “everyone except…”.'
|
||||
}
|
||||
// The same rule from the other side: a group of nothing but exclusions has
|
||||
// no set to take them from. The composer offers "exclude" on every row, so
|
||||
// this is one checkbox away at all times and is worth saying before the
|
||||
// round trip - the server refuses it, correctly, but only after a save.
|
||||
if ((node.op === 'and' || node.op === 'or') && (node.nodes || []).length) {
|
||||
if ((node.nodes || []).every((c) => c && c.op === 'not')) {
|
||||
return 'At least one audience has to be included — a list made only of exclusions has nothing to exclude from.'
|
||||
}
|
||||
}
|
||||
for (const child of node.nodes || []) {
|
||||
const err = walk(child, node.op === 'and')
|
||||
if (err) return err
|
||||
}
|
||||
return null
|
||||
}
|
||||
return walk(expression, false)
|
||||
}
|
||||
|
||||
/** A one-line summary of a segment expression, for the list. */
|
||||
export function describeExpression(node, audiencesById = {}) {
|
||||
if (!node || typeof node !== 'object') return '—'
|
||||
if (!node.op) {
|
||||
const label = audiencesById[node.audienceId]?.label || node.audienceId
|
||||
const params = Object.entries(node.params || {})
|
||||
return params.length ? `${label} (${params.map(([k, v]) => `${k}: ${v}`).join(', ')})` : label
|
||||
}
|
||||
const parts = (node.nodes || []).map((n) => describeExpression(n, audiencesById))
|
||||
if (node.op === 'not') return `not ${parts.join(', ')}`
|
||||
return parts.join(node.op === 'and' ? ' and ' : ' or ')
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line summary of a rule, for the list.
|
||||
*
|
||||
* `dormant` is deliberately not folded in here — the list renders that as its own
|
||||
* badge, because "this rule cannot fire" is a different fact from "this is what
|
||||
* the rule says" and an operator needs both.
|
||||
*/
|
||||
export function describeRule(rule, { segmentsById = {} } = {}) {
|
||||
const parts = []
|
||||
const audience = rule.audience_segment_id
|
||||
? segmentsById[rule.audience_segment_id]?.name || `segment ${rule.audience_segment_id}`
|
||||
: rule.audience
|
||||
parts.push(`to ${audience}`)
|
||||
parts.push(`via ${(rule.channels || []).join(', ') || 'no channel'}`)
|
||||
if (rule.delay_seconds) parts.push(`after ${humanSeconds(rule.delay_seconds)}`)
|
||||
if (rule.cooldown_seconds) parts.push(`at most once per ${humanSeconds(rule.cooldown_seconds)}`)
|
||||
parts.push(`≤ ${rule.max_sends_per_hour}/hour`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
// ── Conditions ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The stored grammar is and/or/not over comparisons; the editor offers the flat
|
||||
// half of it — one and/or over a list of comparisons — because that is what a
|
||||
// dropdown-per-operator can render honestly and it covers the rules anyone
|
||||
// writes by hand.
|
||||
//
|
||||
// **A tree the editor cannot render is shown, not silently flattened.**
|
||||
// Flattening `A AND (B OR C)` into `A AND B AND C` changes which events fire the
|
||||
// rule, and the operator would have no way to know the save had done it. Such a
|
||||
// rule opens read-only with its JSON visible and one honest choice: leave it, or
|
||||
// clear it and start again.
|
||||
|
||||
/** Which comparison operators apply to a variable of this declared type? */
|
||||
export function operatorsForType(operators, type) {
|
||||
return (operators || []).filter((o) => !type || (o.types || []).includes(type))
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored conditions tree → the flat rows the editor edits.
|
||||
*
|
||||
* `editable: false` means "this file will not pretend it can round-trip that",
|
||||
* and the screen renders the tree read-only rather than losing part of it.
|
||||
*/
|
||||
export function conditionRowsFrom(conditions) {
|
||||
if (!conditions) return { op: 'and', rows: [], editable: true }
|
||||
if (conditions.cmp) return { op: 'and', rows: [rowFrom(conditions)], editable: true }
|
||||
if (conditions.op === 'and' || conditions.op === 'or') {
|
||||
const children = conditions.nodes || []
|
||||
if (children.every((n) => n && n.cmp)) {
|
||||
return { op: conditions.op, rows: children.map(rowFrom), editable: true }
|
||||
}
|
||||
}
|
||||
return { op: 'and', rows: [], editable: false }
|
||||
}
|
||||
|
||||
const rowFrom = (node) => ({
|
||||
variable: node.variable,
|
||||
cmp: node.cmp,
|
||||
// A list operator's value arrives as an array and is edited as comma-separated
|
||||
// text; everything else is edited as the literal it is.
|
||||
value: Array.isArray(node.value) ? node.value.join(', ') : node.value === undefined ? '' : String(node.value),
|
||||
})
|
||||
|
||||
/**
|
||||
* The editor's rows → a conditions tree, with each literal coerced to the type
|
||||
* the trigger DECLARED for that variable.
|
||||
*
|
||||
* The coercion is the point. Every value in an HTML input is a string, and the
|
||||
* server refuses `{ cmp: 'gt', value: "5" }` against an `int` variable — rightly,
|
||||
* because a rule whose comparison silently compares a number to a string is a
|
||||
* rule that quietly never fires. Doing it here means the form's error is about
|
||||
* something the operator typed rather than about JSON.
|
||||
*/
|
||||
export function conditionsFromRows(op, rows, variables) {
|
||||
const byName = Object.fromEntries((variables || []).map((v) => [v.name, v]))
|
||||
const nodes = (rows || [])
|
||||
.filter((r) => r.variable && r.cmp)
|
||||
.map((r) => {
|
||||
const type = byName[r.variable]?.type || 'string'
|
||||
const node = { variable: r.variable, cmp: r.cmp }
|
||||
if (r.cmp === 'present' || r.cmp === 'absent') return node
|
||||
if (r.cmp === 'in' || r.cmp === 'nin') {
|
||||
node.value = String(r.value ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => coerceLiteral(type, s))
|
||||
} else {
|
||||
node.value = coerceLiteral(type, r.value)
|
||||
}
|
||||
return node
|
||||
})
|
||||
if (!nodes.length) return null
|
||||
if (nodes.length === 1) return nodes[0]
|
||||
return { op, nodes }
|
||||
}
|
||||
|
||||
/**
|
||||
* One typed literal out of one string.
|
||||
*
|
||||
* A value that does not parse is passed through UNCHANGED rather than turned
|
||||
* into `NaN` or `false`: the server's type check will then refuse it and name the
|
||||
* variable, which is a better error than a rule that saves cleanly and compares
|
||||
* against a number the operator never typed.
|
||||
*/
|
||||
export function coerceLiteral(type, raw) {
|
||||
if (raw === null || raw === undefined) return raw
|
||||
const text = typeof raw === 'string' ? raw.trim() : raw
|
||||
switch (type) {
|
||||
case 'int': {
|
||||
const n = Number(text)
|
||||
return Number.isInteger(n) && text !== '' ? n : text
|
||||
}
|
||||
case 'float': {
|
||||
const n = Number(text)
|
||||
return Number.isFinite(n) && text !== '' ? n : text
|
||||
}
|
||||
case 'boolean': {
|
||||
if (text === true || text === 'true') return true
|
||||
if (text === false || text === 'false') return false
|
||||
return text
|
||||
}
|
||||
default:
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
/** Seconds as the coarsest exact unit — 3600 is "1 hour", 3660 is "61 minutes". */
|
||||
export function humanSeconds(seconds) {
|
||||
const n = Number(seconds) || 0
|
||||
if (n === 0) return 'none'
|
||||
const units = [
|
||||
[86_400, 'day'],
|
||||
[3_600, 'hour'],
|
||||
[60, 'minute'],
|
||||
]
|
||||
for (const [size, name] of units) {
|
||||
if (n % size === 0) {
|
||||
const count = n / size
|
||||
return `${count} ${name}${count === 1 ? '' : 's'}`
|
||||
}
|
||||
}
|
||||
return `${n} seconds`
|
||||
}
|
||||
21
client/src/lib/notificationPaths.js
Normal file
21
client/src/lib/notificationPaths.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// Where a given account's notification screens live.
|
||||
//
|
||||
// **Staff and players reach the same two screens at different paths, and that is
|
||||
// this file's whole reason to exist.** `/auth/me/notifications` is role-agnostic
|
||||
// — behind `requireAuth` only, like every other `/auth/me` route — but the WEB
|
||||
// has two logged-in shells: `RequirePlayer` sends anyone who is not a player to
|
||||
// the admin area, where staff manage their own account under `/admin/account`.
|
||||
// So a bell that always pointed at `/account/notifications` would, for every
|
||||
// staff member, point at a page that redirects.
|
||||
//
|
||||
// Discovered in the Phase 7 rig: signed in as an admin, the inbox was simply
|
||||
// unreachable on the web. Two routes, one pair of components, one mapping here.
|
||||
|
||||
export const isStaff = (user) => !!(user && user.role && user.role !== 'player')
|
||||
|
||||
/** The inbox — what the bell opens. */
|
||||
export const inboxPath = (user) => (isStaff(user) ? '/admin/notifications' : '/account/notifications')
|
||||
|
||||
/** The per-channel preferences screen. */
|
||||
export const notificationSettingsPath = (user) =>
|
||||
isStaff(user) ? '/admin/notifications/settings' : '/account/notifications/settings'
|
||||
@@ -96,7 +96,7 @@ export default function TeamNotifyToggle({ externalId, moduleId }) {
|
||||
{/* 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>
|
||||
<Link to="/account/notifications/settings" className="dim">All notification settings</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,26 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.9.0 - a module may ship its own message bodies and rules:
|
||||
// `api.registerEngagementSeeds({ templates, ruleGroups })` (ENGAGEMENT.md Phase
|
||||
// 11b, decision 7). Nothing on this half changed - a seed is server-side data
|
||||
// and core's seeders write it on the boot path - but the bodies it ships are
|
||||
// edited through the template editor this half already renders, and an operator
|
||||
// meets them there. This file bumps for the reason at the top: the two halves
|
||||
// state ONE version, and a module declares one `coreApi` range against both.
|
||||
// 1.8.0 - the ceiling lattice gains `admin` (ENGAGEMENT.md Phase 11). Nothing on
|
||||
// this half changed: a ceiling is declared on the server's `api` and enforced
|
||||
// there, and the admin screens that render one read the vocabulary from
|
||||
// `GET /admin/engagement/triggers` rather than holding a copy. This file bumps
|
||||
// anyway, for the reason at the top - the two halves state ONE version.
|
||||
// 1.7.0 — the engagement contract (docs/website/ENGAGEMENT.md Phase 2). Nothing
|
||||
// on this half changed: every member the version adds is on the server's `api`
|
||||
// and `ctx` (registerEventTriggers, registerAudiences, ctx.events.emit,
|
||||
// ctx.inbox.push). This file bumps anyway, for the reason at the top — the two
|
||||
// halves state ONE version, and a module declares one `coreApi` range against
|
||||
// both. The web surfaces the engagement system needs (the rules and template
|
||||
// editors, the in-app inbox) land in Phases 4, 5 and 7 and will add to this half
|
||||
// then.
|
||||
// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Nothing on this half
|
||||
// changed yet: the two client additions the version covers are the `team.overview`
|
||||
// and `team.member.row` slots, and a slot can only be declared by the page that
|
||||
@@ -45,4 +65,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.6.0'
|
||||
export const MODULE_API_VERSION = '1.9.0'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import NotificationBell from '../../components/NotificationBell.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
||||
@@ -43,9 +44,15 @@ const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18
|
||||
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
|
||||
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
|
||||
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
|
||||
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
|
||||
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
|
||||
const IconModules = () => <Icon><path d="M12 3l8 4.5-8 4.5-8-4.5z" /><path d="M4 12l8 4.5 8-4.5" /><path d="M4 16.5L12 21l8-4.5" /></Icon>
|
||||
const IconMail = () => <Icon><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M3.5 6.5L12 13l8.5-6.5" /></Icon>
|
||||
const IconList = () => <Icon><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1.2" /><circle cx="4" cy="12" r="1.2" /><circle cx="4" cy="18" r="1.2" /></Icon>
|
||||
const IconTemplate = () => <Icon><rect x="4" y="3" width="16" height="18" rx="2" /><path d="M8 8h8M8 12h8M8 16h4" /></Icon>
|
||||
const IconSpark = () => <Icon><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" /><path d="M18 16l.9 2.1L21 19l-2.1.9L18 22l-.9-2.1L15 19l2.1-.9z" /></Icon>
|
||||
const IconLog = () => <Icon><path d="M4 5h16v14H4z" /><path d="M8 9h8M8 12h8M8 15h5" /></Icon>
|
||||
|
||||
// Nav is grouped into collapsible categories. A group with no `title` renders
|
||||
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
||||
@@ -89,6 +96,25 @@ export const NAV = [
|
||||
{ to: '/admin/teams', label: 'Teams', icon: IconUsers, roles: ['admin', 'moderator'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Its own top-level group (ENGAGEMENT.md §7.1 Q4), not a section of
|
||||
// Settings. Settings is already one long page of sections, and these six
|
||||
// screens are two editors, a catalog and two paged tables, none of which is
|
||||
// a settings section. Email Delivery stays under Settings: configuring a
|
||||
// transport is not the same job as deciding who gets mail.
|
||||
title: 'Engagement',
|
||||
items: [
|
||||
{ to: '/admin/engagement/rules', label: 'Rules', icon: IconMail, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/audiences', label: 'Audiences', icon: IconList, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/templates', label: 'Templates', icon: IconTemplate, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/triggers', label: 'Triggers', icon: IconSpark, roles: ['admin'] },
|
||||
{ to: '/admin/engagement/sends', label: 'Send Log', icon: IconLog, roles: ['admin'] },
|
||||
// Beside the Send Log rather than inside it (Phase 9): the log answers
|
||||
// "did that message go out", and this answers "why is this person not
|
||||
// getting any" - and it is the only screen that can lift a suppression.
|
||||
{ to: '/admin/engagement/suppressions', label: 'Suppressions', icon: IconLog, roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
@@ -109,6 +135,11 @@ export const NAV = [
|
||||
},
|
||||
{
|
||||
items: [
|
||||
// No `end`: `allowedPathsFor` turns an `end` row into an EXACT match, so
|
||||
// marking this one exact would leave `/admin/notifications/settings`
|
||||
// outside the allowlist and bounce a staff member off their own
|
||||
// preferences screen. The row covering its sub-routes is the point.
|
||||
{ to: '/admin/notifications', label: 'Notifications', icon: IconBell },
|
||||
{ to: '/admin/account', label: 'Account', icon: IconUser },
|
||||
],
|
||||
},
|
||||
@@ -157,6 +188,14 @@ const TITLES = {
|
||||
'/admin/users': 'Users',
|
||||
'/admin/invites': 'Invites',
|
||||
'/admin/account': 'Account Security',
|
||||
'/admin/notifications': 'Notifications',
|
||||
'/admin/notifications/settings': 'Notification settings',
|
||||
'/admin/engagement/rules': 'Engagement Rules',
|
||||
'/admin/engagement/audiences': 'Engagement Audiences',
|
||||
'/admin/engagement/templates': 'Message Templates',
|
||||
'/admin/engagement/triggers': 'Triggers',
|
||||
'/admin/engagement/suppressions': 'Suppressions',
|
||||
'/admin/engagement/sends': 'Send Log',
|
||||
}
|
||||
|
||||
// An installed module's admin pages are not in TITLES and cannot be — core does
|
||||
@@ -176,6 +215,7 @@ function moduleTitle(baseNav, pathname) {
|
||||
function sectionTitle(pathname) {
|
||||
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
|
||||
if (pathname.startsWith('/admin/users/')) return 'User'
|
||||
if (pathname.startsWith('/admin/engagement')) return 'Engagement'
|
||||
return 'Admin'
|
||||
}
|
||||
|
||||
@@ -417,6 +457,11 @@ export default function AdminLayout() {
|
||||
{title}
|
||||
</h1>
|
||||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: '0.84rem', color: 'var(--muted)' }}>
|
||||
{/* Staff have an inbox like anyone else — `/auth/me/notifications`
|
||||
is role-agnostic — and `RequirePlayer` keeps them out of the
|
||||
player portal, so without this the one place they spend their
|
||||
time is the one place the bell is missing. */}
|
||||
<NotificationBell />
|
||||
<a href="/" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
View site →
|
||||
</a>
|
||||
|
||||
@@ -4,6 +4,7 @@ import ProviderIcon from '../../../components/ProviderIcon.jsx'
|
||||
import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisplay.jsx'
|
||||
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
|
||||
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Link/unlink external SSO identities to this account. Linking redirects through
|
||||
@@ -25,7 +26,7 @@ function LinkedAccounts() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [ids, avail] = await Promise.all([
|
||||
api.admin.linkedIdentities(),
|
||||
api.myIdentities(),
|
||||
api.authProviders().catch(() => []),
|
||||
])
|
||||
setLinked(ids)
|
||||
@@ -44,7 +45,7 @@ function LinkedAccounts() {
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
try {
|
||||
await api.admin.unlinkIdentity(provider)
|
||||
await api.unlinkIdentity(provider)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
@@ -134,7 +135,7 @@ export default function AccountAdmin() {
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setAccount(await api.admin.getAccount())
|
||||
setAccount(await api.myAccount())
|
||||
} catch {
|
||||
setError('Could not load your account.')
|
||||
} finally {
|
||||
@@ -154,7 +155,7 @@ export default function AccountAdmin() {
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
setSetup(await api.admin.totpSetup())
|
||||
setSetup(await api.totpSetup())
|
||||
setCode('')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not start setup.')
|
||||
@@ -168,7 +169,7 @@ export default function AccountAdmin() {
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.admin.totpEnable(code.trim())
|
||||
const res = await api.totpEnable(code.trim())
|
||||
setSetup(null)
|
||||
setCode('')
|
||||
setNewCodes(res?.recoveryCodes || null)
|
||||
@@ -186,7 +187,7 @@ export default function AccountAdmin() {
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.totpDisable(code.trim())
|
||||
await api.totpDisable(code.trim())
|
||||
setCode('')
|
||||
setMsg('Two-factor authentication has been disabled.')
|
||||
await load()
|
||||
@@ -322,6 +323,10 @@ export default function AccountAdmin() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* The self-service address, from the same component the player portal
|
||||
renders — /auth/me/account is one surface for every role. */}
|
||||
{account && <EmailAddressPanel account={account} reload={load} />}
|
||||
|
||||
<LinkedAccounts />
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -66,6 +66,34 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<section>
|
||||
{/* Operator warnings: things that are quietly not working and would
|
||||
otherwise be discovered by someone not receiving an email. The list is
|
||||
normally empty, which is why it sits above the fold rather than in a
|
||||
panel — see ENGAGEMENT.md §1.2a (G22). */}
|
||||
{(dash.warnings || []).map((w) => (
|
||||
<div
|
||||
key={w.code}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.86rem',
|
||||
lineHeight: 1.5,
|
||||
borderRadius: 10,
|
||||
padding: '12px 16px',
|
||||
marginBottom: 18,
|
||||
border: '1px solid #7a6440',
|
||||
background: 'rgba(224,176,112,0.08)',
|
||||
color: '#e0b070',
|
||||
}}
|
||||
>
|
||||
{w.message}
|
||||
{w.href && (
|
||||
<>
|
||||
{' '}
|
||||
<a href={w.href} style={{ color: '#e0b070', textDecoration: 'underline' }}>Open settings</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -2,11 +2,20 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
|
||||
// Email delivery panel (Gmail over OAuth2), rendered as a section on the Settings
|
||||
// page. Sending is authorized by an in-app "Connect Gmail" consent flow that
|
||||
// captures a refresh token server-side — the token is write-only over the API
|
||||
// (stored encrypted, never returned). Reuses the Google SSO OAuth client, so it
|
||||
// requires the Google provider to be configured on the Authentication page first.
|
||||
// Email delivery panel, rendered as a section on the Settings page. Sending goes
|
||||
// through a registered mail transport (SMTP today) whose credentials the operator
|
||||
// types here; they are stored encrypted server-side and are write-only over the
|
||||
// API — a secret field comes back as "set", never as its value.
|
||||
//
|
||||
// **The form is not written here.** The server ships each transport's declared
|
||||
// `credentialFields` with the config, and this renders them. That is the whole
|
||||
// point of the declaration (ENGAGEMENT.md §3.1): adding a transport must not mean
|
||||
// editing this file. So there is no `host`, `port` or `password` anywhere below —
|
||||
// only field kinds.
|
||||
//
|
||||
// The "Connect Gmail" button, its redirect banner and its six error strings went
|
||||
// with the OAuth2 flow (§1.2a). Gmail is still reachable, as an ordinary SMTP
|
||||
// relay with an app password — which the operator types in like any other host.
|
||||
|
||||
const STATUS_COLOR = {
|
||||
connected: '#7fd0a4',
|
||||
@@ -14,17 +23,6 @@ const STATUS_COLOR = {
|
||||
unconfigured: 'var(--muted)',
|
||||
}
|
||||
|
||||
// Human-friendly text for the ?email_error=<code> the callback may redirect with.
|
||||
const ERROR_TEXT = {
|
||||
denied: 'Google sign-in was cancelled or denied.',
|
||||
bad_state: 'The connect session expired. Please try again.',
|
||||
no_client: 'The Google OAuth client is not configured.',
|
||||
no_refresh_token:
|
||||
'Google did not return a refresh token. Remove this app under your Google Account → Security → Third-party access, then reconnect.',
|
||||
no_email: 'Could not read the Gmail address from Google.',
|
||||
error: 'Could not connect the Gmail account. Please try again.',
|
||||
}
|
||||
|
||||
function StatusPanel({ config }) {
|
||||
const color = STATUS_COLOR[config.status] || 'var(--muted)'
|
||||
return (
|
||||
@@ -52,60 +50,100 @@ function StatusPanel({ config }) {
|
||||
)
|
||||
}
|
||||
|
||||
// One declared credential field. A `secret` already held renders empty with a
|
||||
// "leave blank to keep" hint, matching the server's patch semantics: an empty
|
||||
// secret is omitted from the save, not written as a blank.
|
||||
function CredentialField({ field, value, isSet, onChange }) {
|
||||
const hint = [field.help, field.kind === 'secret' && isSet ? 'Currently set — leave blank to keep it.' : null]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
if (field.kind === 'boolean') {
|
||||
return (
|
||||
<label className="sans" style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<input type="checkbox" checked={Boolean(value)} onChange={(e) => onChange(e.target.checked)} style={{ marginTop: 3 }} />
|
||||
<span>
|
||||
{field.label}
|
||||
{hint && <span className="sans dim" style={{ display: 'block', fontSize: '0.78rem' }}>{hint}</span>}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">
|
||||
{field.label}
|
||||
{field.required ? '' : ' (optional)'}
|
||||
</span>
|
||||
<input
|
||||
type={field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="input"
|
||||
autoComplete={field.kind === 'secret' ? 'new-password' : 'off'}
|
||||
placeholder={field.placeholder || ''}
|
||||
/>
|
||||
{hint && <span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>{hint}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default function EmailDelivery() {
|
||||
const { siteTitle } = useSite()
|
||||
const [config, setConfig] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [transport, setTransport] = useState('smtp')
|
||||
const [senderEmail, setSenderEmail] = useState('')
|
||||
const [senderName, setSenderName] = useState('')
|
||||
const [replyTo, setReplyTo] = useState('')
|
||||
const [credential, setCredential] = useState({})
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [busy, setBusy] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [banner, setBanner] = useState(null) // fields kind ('ok' or 'err') and text
|
||||
|
||||
// Seed the credential inputs from the non-secret values the server returned,
|
||||
// falling back to each field's declared default. Secrets are never seeded —
|
||||
// the server does not send them and an empty box means "keep what you have".
|
||||
const seedCredential = useCallback((c, transportId) => {
|
||||
const def = (c.transports || []).find((t) => t.id === transportId)
|
||||
const next = {}
|
||||
for (const f of def?.credentialFields || []) {
|
||||
if (f.kind === 'secret') continue
|
||||
next[f.key] = c.credential?.[f.key] ?? (f.default === null ? '' : f.default)
|
||||
}
|
||||
return next
|
||||
}, [])
|
||||
|
||||
const load = useCallback(async (seedForm = false) => {
|
||||
try {
|
||||
const c = await api.admin.getEmailConfig()
|
||||
setConfig(c)
|
||||
if (seedForm) {
|
||||
setTransport(c.transport || 'smtp')
|
||||
setSenderEmail(c.senderEmail || '')
|
||||
setSenderName(c.senderName || '')
|
||||
setReplyTo(c.replyTo || '')
|
||||
setEnabled(c.enabled)
|
||||
setCredential(seedCredential(c, c.transport || 'smtp'))
|
||||
}
|
||||
return c
|
||||
} catch {
|
||||
setError('Could not load email settings.')
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
}, [seedCredential])
|
||||
|
||||
// On mount, surface the outcome of a just-completed connect redirect, strip the
|
||||
// query params so a refresh doesn't replay the banner, then load config.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.has('email_connected')) {
|
||||
setBanner({ kind: 'ok', text: 'Gmail account connected.' })
|
||||
} else if (params.has('email_error')) {
|
||||
setBanner({ kind: 'err', text: ERROR_TEXT[params.get('email_error')] || 'Could not connect email.' })
|
||||
}
|
||||
if (params.has('email_connected') || params.has('email_error')) {
|
||||
params.delete('email_connected')
|
||||
params.delete('email_error')
|
||||
const qs = params.toString()
|
||||
window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : ''))
|
||||
}
|
||||
load(true)
|
||||
}, [load])
|
||||
|
||||
async function connect() {
|
||||
setBusy('connect')
|
||||
setActionError('')
|
||||
try {
|
||||
const { url } = await api.admin.emailConnectUrl()
|
||||
window.location.href = url
|
||||
} catch (err) {
|
||||
setActionError(err.message || 'Could not start the connect flow.')
|
||||
setBusy('')
|
||||
}
|
||||
// Switching transport starts from the new one's declared defaults, because the
|
||||
// server does the same: a credential blob is never carried across transports.
|
||||
function changeTransport(id) {
|
||||
setTransport(id)
|
||||
setCredential(seedCredential(config, id))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
@@ -113,10 +151,19 @@ export default function EmailDelivery() {
|
||||
setMsg('')
|
||||
setActionError('')
|
||||
try {
|
||||
const saved = await api.admin.saveEmailConfig({ senderName, enabled })
|
||||
const saved = await api.admin.saveEmailConfig({ transport, senderEmail, senderName, replyTo, credential, enabled })
|
||||
setConfig(saved)
|
||||
setEnabled(saved.enabled)
|
||||
setCredential(seedCredential(saved, saved.transport))
|
||||
setMsg('Saved.')
|
||||
} catch (err) {
|
||||
// A refused enable comes back with the reverted config attached, so the
|
||||
// screen shows what is actually stored rather than the state that was
|
||||
// rejected.
|
||||
if (err.body?.config) {
|
||||
setConfig(err.body.config)
|
||||
setEnabled(err.body.config.enabled)
|
||||
}
|
||||
setActionError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
@@ -133,12 +180,13 @@ export default function EmailDelivery() {
|
||||
await load()
|
||||
} catch (err) {
|
||||
setActionError(err.message || 'Could not send the test email.')
|
||||
await load()
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
async function clearCredentials() {
|
||||
setBusy('disconnect')
|
||||
setMsg('')
|
||||
setActionError('')
|
||||
@@ -146,9 +194,11 @@ export default function EmailDelivery() {
|
||||
const c = await api.admin.disconnectEmail()
|
||||
setConfig(c)
|
||||
setEnabled(false)
|
||||
setMsg('Disconnected.')
|
||||
setSenderEmail('')
|
||||
setCredential(seedCredential(c, c.transport))
|
||||
setMsg('Credentials cleared.')
|
||||
} catch (err) {
|
||||
setActionError(err.message || 'Could not disconnect.')
|
||||
setActionError(err.message || 'Could not clear the credentials.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
@@ -157,84 +207,118 @@ export default function EmailDelivery() {
|
||||
if (error) return <p className="sans" style={{ color: '#d98b84' }}>{error}</p>
|
||||
if (!config) return null
|
||||
|
||||
const connected = config.hasRefreshToken
|
||||
const catalog = config.transports || []
|
||||
const selected = catalog.find((t) => t.id === transport)
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 16, marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 30 }}>
|
||||
<div>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Email delivery</h2>
|
||||
<p className="sans dim" style={{ margin: '6px 0 0', fontSize: '0.82rem' }}>
|
||||
Sends the contact form through Gmail over OAuth2, delivered to the
|
||||
<strong> Contact email</strong> above. Reuses the Google authentication
|
||||
client — configure that on the Authentication page first.
|
||||
Sends the contact form, invitations, password resets and team
|
||||
notifications. Contact-form mail is delivered to the
|
||||
<strong> Contact email</strong> above. Credentials are stored encrypted
|
||||
and never shown again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{banner && (
|
||||
{config.hadLegacyConnection && !config.hasCredential && (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.85rem',
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
border: `1px solid ${banner.kind === 'ok' ? '#3f6b52' : '#7a4440'}`,
|
||||
color: banner.kind === 'ok' ? '#7fd0a4' : '#d98b84',
|
||||
}}
|
||||
style={{ fontSize: '0.85rem', borderRadius: 8, padding: '10px 12px', border: '1px solid #7a6440', color: '#e0b070' }}
|
||||
>
|
||||
{banner.text}
|
||||
This deployment was connected with the old Gmail sign-in, which has been
|
||||
removed. <strong>No mail is being sent.</strong> Enter SMTP credentials
|
||||
below to restore it — for Gmail, use <code>smtp.gmail.com</code> port 587
|
||||
with an app password.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatusPanel config={config} />
|
||||
|
||||
{!config.googleConfigured && (
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: '#e0b070' }}>
|
||||
The Google authentication provider needs a client ID and secret before
|
||||
you can connect a Gmail account.
|
||||
</p>
|
||||
{catalog.length > 1 && (
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Transport</span>
|
||||
<select value={transport} onChange={(e) => changeTransport(e.target.value)} className="input">
|
||||
{catalog.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{!connected ? (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={connect} disabled={busy === 'connect' || !config.googleConfigured} className="btn btn-primary btn-sq">
|
||||
{busy === 'connect' ? 'Redirecting…' : 'Connect Gmail'}
|
||||
{selected?.help && (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>{selected.help}</p>
|
||||
)}
|
||||
|
||||
{(selected?.credentialFields || []).map((f) => (
|
||||
<CredentialField
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={credential[f.key]}
|
||||
isSet={Boolean(config.secretsSet?.[f.key])}
|
||||
onChange={(v) => setCredential((prev) => ({ ...prev, [f.key]: v }))}
|
||||
/>
|
||||
))}
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Send from</span>
|
||||
<input
|
||||
type="email"
|
||||
value={senderEmail}
|
||||
onChange={(e) => setSenderEmail(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder="noreply@example.com"
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
Must be an address this account is allowed to send as, or the relay will
|
||||
reject it. Use <strong>Send test</strong> to confirm.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">From display name (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={senderName}
|
||||
onChange={(e) => setSenderName(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder={siteTitle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Reply-To (optional)</span>
|
||||
<input
|
||||
type="email"
|
||||
value={replyTo}
|
||||
onChange={(e) => setReplyTo(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder="Leave blank to reply to the sending address"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
Enable email sending
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={save} disabled={busy === 'save'} className="btn btn-primary btn-sq">
|
||||
{busy === 'save' ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
<button onClick={sendTest} disabled={busy === 'test' || !config.hasCredential} className="pill">
|
||||
{busy === 'test' ? 'Sending…' : 'Send test'}
|
||||
</button>
|
||||
{config.hasCredential && (
|
||||
<button onClick={clearCredentials} disabled={busy === 'disconnect'} className="pill">
|
||||
Clear credentials
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
Enable email sending
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">From display name (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={senderName}
|
||||
onChange={(e) => setSenderName(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder={siteTitle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={save} disabled={busy === 'save'} className="btn btn-primary btn-sq">
|
||||
{busy === 'save' ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
<button onClick={sendTest} disabled={busy === 'test'} className="pill">
|
||||
{busy === 'test' ? 'Sending…' : 'Send test'}
|
||||
</button>
|
||||
<button onClick={connect} disabled={busy === 'connect'} className="pill">
|
||||
Reconnect
|
||||
</button>
|
||||
<button onClick={disconnect} disabled={busy === 'disconnect'} className="pill">
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ minHeight: 18 }}>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
|
||||
433
client/src/routes/admin/views/EngagementAudiences.jsx
Normal file
433
client/src/routes/admin/views/EngagementAudiences.jsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { describeExpression, describeReach, notPlacementError } from '../../../lib/engagementRules.js'
|
||||
|
||||
// Admin → Engagement → Audiences (ENGAGEMENT.md §5.1a, Phase 4b).
|
||||
//
|
||||
// A module declares named sets of users over its own data — "members of a team",
|
||||
// "the governors" — and an operator combines them here into a saved audience a
|
||||
// rule can point at. Core learns no game vocabulary: it knows an id, a label and
|
||||
// a resolver it may call.
|
||||
//
|
||||
// **Composition narrows and never widens**, and that is the whole security
|
||||
// content of this screen:
|
||||
//
|
||||
// • the saved ceiling is DERIVED from the tightest audience in the expression,
|
||||
// not chosen — including for "any of", where the intuitive answer (the widest
|
||||
// of the two) is the wrong one. A ceiling says what an expression is allowed
|
||||
// to reach, not what it will resolve to, so the boolean operator makes no
|
||||
// difference to it.
|
||||
// • two ceilings with no ordering between them (staff and owner, say) have no
|
||||
// answer at all, and the save is refused rather than guessing a side.
|
||||
// • "none of" is only available inside an "all of" group. On its own it would
|
||||
// have to mean "everyone except…" — a broadcast built out of one narrow list.
|
||||
// The composer does not offer it anywhere else, and the server refuses it
|
||||
// anyway.
|
||||
//
|
||||
// The three-level composer here is deliberate: one top-level all-of/any-of, one
|
||||
// level of groups inside it, and audiences at the leaves. The stored grammar
|
||||
// allows more nesting; anything deeper is left to the rule that made it and shown
|
||||
// read-only, the same way the rule editor treats a nested condition.
|
||||
|
||||
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
|
||||
|
||||
/** A fresh, empty top-level group. */
|
||||
const blankExpression = () => ({ op: 'and', nodes: [] })
|
||||
|
||||
/** Is this tree one the composer can render — a single group of leaves and not-groups? */
|
||||
function isComposable(node) {
|
||||
if (!node || typeof node !== 'object') return false
|
||||
if (!node.op) return true
|
||||
if (node.op === 'not') return (node.nodes || []).every((n) => n && !n.op)
|
||||
if (node.op !== 'and' && node.op !== 'or') return false
|
||||
return (node.nodes || []).every((n) => n && (!n.op || (n.op === 'not' && (n.nodes || []).every((c) => !c.op))))
|
||||
}
|
||||
|
||||
/** The composer edits a top-level group; a bare leaf is lifted into one. */
|
||||
const toGroup = (expression) =>
|
||||
!expression ? blankExpression() : expression.op ? expression : { op: 'and', nodes: [expression] }
|
||||
|
||||
// ── One leaf: an audience and its declared parameters ──────────────────────
|
||||
|
||||
function LeafRow({ audiences, node, onChange, onRemove, negated, onToggleNegate, canNegate, first }) {
|
||||
const declared = audiences.find((a) => a.id === node.audienceId)
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<label style={{ flex: '1 1 240px' }}>
|
||||
{/* The heading belongs to the group, not to every line in it. */}
|
||||
{first && <span className="field-label">Audience</span>}
|
||||
<select
|
||||
className="select"
|
||||
value={node.audienceId || ''}
|
||||
onChange={(e) => onChange({ audienceId: e.target.value, params: {} })}
|
||||
>
|
||||
<option value="">Choose…</option>
|
||||
{audiences.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.label} — reaches at most “{a.ceiling}”</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{(declared?.params || []).map((p) => (
|
||||
<label key={p.id} style={{ flex: '0 1 160px' }}>
|
||||
<span className="field-label">{p.id}{p.required ? ' *' : ''}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={node.params?.[p.id] ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...node,
|
||||
params: {
|
||||
...node.params,
|
||||
// `int` params are sent as numbers: the server type-checks each
|
||||
// declared param, and "3" against an int is a refusal.
|
||||
[p.id]: p.type === 'int' && e.target.value !== '' ? Number(e.target.value) : e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
{canNegate && (
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, paddingBottom: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={negated} onChange={onToggleNegate} />
|
||||
exclude
|
||||
</label>
|
||||
)}
|
||||
<button type="button" className="pill" style={{ ...DANGER, fontSize: '0.72rem', marginBottom: 6 }} onClick={onRemove}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The composer ───────────────────────────────────────────────────────────
|
||||
|
||||
function SegmentEditor({ audiences, segment, onSaved, onCancel }) {
|
||||
const [name, setName] = useState(segment?.name || '')
|
||||
const [group, setGroup] = useState(() => toGroup(segment?.expression))
|
||||
const [errors, setErrors] = useState([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const isNew = !segment
|
||||
|
||||
// `not` is only offered under "all of" (§5.1a). Under "any of" the checkbox
|
||||
// disappears rather than being offered and refused.
|
||||
const canNegate = group.op === 'and'
|
||||
|
||||
function setNodes(nodes) {
|
||||
setGroup((g) => ({ ...g, nodes }))
|
||||
}
|
||||
|
||||
function addLeaf() {
|
||||
setNodes([...group.nodes, { audienceId: '', params: {} }])
|
||||
}
|
||||
|
||||
function replaceAt(i, next) {
|
||||
setNodes(group.nodes.map((n, j) => (i === j ? next : n)))
|
||||
}
|
||||
|
||||
function toggleNegate(i) {
|
||||
const node = group.nodes[i]
|
||||
replaceAt(i, node.op === 'not' ? node.nodes[0] : { op: 'not', nodes: [node] })
|
||||
}
|
||||
|
||||
function changeOp(op) {
|
||||
// Switching to "any of" drops the exclusions rather than sending a tree the
|
||||
// server will refuse — and says so, because silently keeping them and failing
|
||||
// at save would be worse than either.
|
||||
const nodes = op === 'or' ? group.nodes.map((n) => (n.op === 'not' ? n.nodes[0] : n)) : group.nodes
|
||||
setGroup({ op, nodes })
|
||||
}
|
||||
|
||||
const expression = useMemo(() => {
|
||||
const nodes = group.nodes.filter((n) => (n.op === 'not' ? n.nodes[0]?.audienceId : n.audienceId))
|
||||
if (!nodes.length) return null
|
||||
if (nodes.length === 1 && !nodes[0].op) return nodes[0]
|
||||
return { op: group.op, nodes }
|
||||
}, [group])
|
||||
|
||||
const localError = expression ? notPlacementError(expression) : null
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setErrors([])
|
||||
if (!expression) return setErrors(['Add at least one audience.'])
|
||||
if (localError) return setErrors([localError])
|
||||
setBusy(true)
|
||||
try {
|
||||
const body = { name: name.trim(), expression }
|
||||
if (isNew) await api.admin.createEngagementSegment(body)
|
||||
else await api.admin.updateEngagementSegment(segment.id, body)
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
setErrors(err.body?.errors?.length ? err.body.errors : [err.message || 'Could not save that audience.'])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
|
||||
<div className="field-label" style={{ marginBottom: 14 }}>
|
||||
{isNew ? 'New saved audience' : `Editing “${segment.name}”`}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 280px' }}>
|
||||
<span className="field-label">Name</span>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Governors" />
|
||||
</label>
|
||||
<label style={{ flex: '0 1 200px' }}>
|
||||
<span className="field-label">Combine with</span>
|
||||
<select className="select" value={group.op} onChange={(e) => changeOp(e.target.value)}>
|
||||
<option value="and">all of these</option>
|
||||
<option value="or">any of these</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 18 }}>
|
||||
{group.nodes.length === 0 && (
|
||||
<p className="sans" style={{ margin: '0 0 10px', fontSize: '0.84rem', color: 'var(--muted)' }}>
|
||||
No audiences yet. A saved audience is built out of the lists installed modules declare.
|
||||
</p>
|
||||
)}
|
||||
{group.nodes.map((node, i) => {
|
||||
const negated = node.op === 'not'
|
||||
const leaf = negated ? node.nodes[0] : node
|
||||
return (
|
||||
<LeafRow
|
||||
key={i}
|
||||
first={i === 0}
|
||||
audiences={audiences}
|
||||
node={leaf}
|
||||
negated={negated}
|
||||
canNegate={canNegate}
|
||||
onToggleNegate={() => toggleNegate(i)}
|
||||
onChange={(next) => replaceAt(i, negated ? { op: 'not', nodes: [next] } : next)}
|
||||
onRemove={() => setNodes(group.nodes.filter((_, j) => j !== i))}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<button type="button" className="btn btn-sq" onClick={addLeaf} disabled={!audiences.length}>
|
||||
Add an audience
|
||||
</button>
|
||||
{!audiences.length && (
|
||||
<span className="sans" style={{ marginLeft: 10, fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
No module currently declares any. Install one, or use a plain audience on the rule itself.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canNegate ? (
|
||||
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
“Exclude” removes people from what the other rows produced. It is only available under “all
|
||||
of”: on its own it would mean “everyone except…”, which is a way to reach the whole
|
||||
deployment from one narrow list.
|
||||
</p>
|
||||
) : (
|
||||
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
“Any of” takes the tightest limit of the audiences in it, not the widest — combining two
|
||||
lists never reaches further than the narrower one allows.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(errors.length > 0 || localError) && (
|
||||
<ul className="sans" style={{ margin: '14px 0 0', paddingLeft: 18, color: '#d98b84', fontSize: '0.84rem' }}>
|
||||
{(errors.length ? errors : [localError]).map((e) => <li key={e}>{e}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
|
||||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
|
||||
{busy ? 'Saving…' : isNew ? 'Create' : 'Save changes'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EngagementAudiences() {
|
||||
const [audiences, setAudiences] = useState([])
|
||||
const [segments, setSegments] = useState(null)
|
||||
const [editing, setEditing] = useState(null) // null | { segment } | { segment: null }
|
||||
const [error, setError] = useState('')
|
||||
const [rowError, setRowError] = useState('')
|
||||
const [reach, setReach] = useState({}) // segment id -> preview
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const [declared, saved] = await Promise.all([
|
||||
api.admin.engagementAudiences(),
|
||||
api.admin.listEngagementSegments(),
|
||||
])
|
||||
setAudiences(declared.audiences || [])
|
||||
setSegments(saved.segments || [])
|
||||
} catch {
|
||||
setError('Could not load audiences.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const audiencesById = useMemo(
|
||||
() => Object.fromEntries(audiences.map((a) => [a.id, a])),
|
||||
[audiences],
|
||||
)
|
||||
|
||||
async function preview(segment) {
|
||||
try {
|
||||
const counted = await api.admin.previewEngagementReach({ audienceSegmentId: segment.id })
|
||||
setReach((r) => ({ ...r, [segment.id]: counted }))
|
||||
} catch (err) {
|
||||
setReach((r) => ({ ...r, [segment.id]: { count: 0, dormant: true, reason: err.message } }))
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(segment) {
|
||||
if (!window.confirm(`Delete “${segment.name}”?`)) return
|
||||
setRowError('')
|
||||
try {
|
||||
await api.admin.deleteEngagementSegment(segment.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
// A 409 here is the interesting case and the message carries the count:
|
||||
// deleting a segment a rule still points at would leave that rule reaching
|
||||
// a different set of people, so it is refused rather than cascaded.
|
||||
setRowError(err.message || 'Could not delete that audience.')
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!segments) return <Loading />
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<section>
|
||||
<SegmentEditor
|
||||
audiences={audiences}
|
||||
segment={editing.segment}
|
||||
onSaved={async () => { setEditing(null); await load() }}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 640 }}>
|
||||
Named sets of people a rule can be pointed at, built out of the lists installed modules
|
||||
declare. A saved audience can only ever narrow — combining two lists never reaches further
|
||||
than the tighter of them allows.
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary btn-sq" onClick={() => setEditing({ segment: null })}>
|
||||
New audience
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rowError && (
|
||||
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
|
||||
)}
|
||||
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Name</th>
|
||||
<th className="adm-th">Made of</th>
|
||||
<th className="adm-th">Reaches at most</th>
|
||||
<th className="adm-th">Right now</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{segments.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
|
||||
No saved audiences yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{segments.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--text)' }}>
|
||||
{s.name}
|
||||
{s.dormant && (
|
||||
<div>
|
||||
<span
|
||||
className="badge"
|
||||
title={`Not declared right now: ${(s.missingAudiences || []).join(', ')}`}
|
||||
style={{ color: 'var(--accent)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
|
||||
>
|
||||
Dormant
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
|
||||
{describeExpression(s.expression, audiencesById)}
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{s.ceiling}</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
|
||||
{reach[s.id] ? (
|
||||
describeReach(reach[s.id])
|
||||
) : (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => preview(s)}>
|
||||
Count
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ fontSize: '0.72rem', marginRight: 6 }}
|
||||
disabled={!isComposable(s.expression)}
|
||||
title={isComposable(s.expression) ? undefined : 'Nested more deeply than this composer renders'}
|
||||
onClick={() => setEditing({ segment: s })}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ ...DANGER, fontSize: '0.72rem' }}
|
||||
onClick={() => remove(s)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="panel" style={{ padding: 18, marginTop: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>What modules currently declare</div>
|
||||
{audiences.length === 0 ? (
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.84rem', color: 'var(--muted)' }}>
|
||||
Nothing. Audiences come from installed modules — core declares none, because core knows no
|
||||
game vocabulary.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="sans" style={{ margin: 0, paddingLeft: 18, fontSize: '0.84rem', color: 'var(--muted)' }}>
|
||||
{audiences.map((a) => (
|
||||
<li key={a.id}>
|
||||
<span style={{ color: 'var(--text)' }}>{a.label}</span> — <code>{a.id}</code>, reaches at
|
||||
most “{a.ceiling}”
|
||||
{(a.params || []).length ? ` (${a.params.map((p) => p.id).join(', ')})` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
716
client/src/routes/admin/views/EngagementRules.jsx
Normal file
716
client/src/routes/admin/views/EngagementRules.jsx
Normal file
@@ -0,0 +1,716 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import {
|
||||
formFromRule,
|
||||
ruleToPayload,
|
||||
audienceChoicesFor,
|
||||
segmentChoicesFor,
|
||||
describeReach,
|
||||
describeRule,
|
||||
audienceWarning,
|
||||
conditionRowsFrom,
|
||||
conditionsFromRows,
|
||||
operatorsForType,
|
||||
} from '../../../lib/engagementRules.js'
|
||||
|
||||
// Admin → Engagement → Rules (ENGAGEMENT.md Phase 4b).
|
||||
//
|
||||
// A rule is trigger → audience → channels → timing, and this is the screen that
|
||||
// writes one. Everything it decides lives in lib/engagementRules.js so it can be
|
||||
// tested; this file renders it and talks to the API.
|
||||
//
|
||||
// Four things about this screen are deliberate and would be wrong the obvious
|
||||
// way round:
|
||||
//
|
||||
// 1. **The on/off switch is not the form.** It is its own request against its
|
||||
// own route, and it does not re-validate the rule. A rule whose module has
|
||||
// been uninstalled is dormant, is the rule an operator most wants stopped,
|
||||
// and is exactly the rule the form would refuse to save.
|
||||
// 2. **A rule's trigger is fixed once it exists.** Its cooldowns, its pending
|
||||
// outbox rows and its send-log history are all about one trigger id.
|
||||
// 3. **Every rule arrives off.** §7.1 Q3 makes rules operator-editable data on
|
||||
// the condition that nothing starts mailing by itself — so a new rule is
|
||||
// created disabled and switched on afterwards, as a separate act.
|
||||
// 4. **The reach preview is a number.** Never a list of people: a
|
||||
// module-declared segment resolves over game data, and this screen is about
|
||||
// mail scheduling.
|
||||
|
||||
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
|
||||
const BLANK = {
|
||||
id: null,
|
||||
triggerId: '',
|
||||
name: '',
|
||||
enabled: false,
|
||||
audience: 'owner',
|
||||
audienceSegmentId: null,
|
||||
channels: [],
|
||||
templateKeys: {},
|
||||
conditions: null,
|
||||
cooldownSeconds: 0,
|
||||
delaySeconds: 0,
|
||||
cancelOn: [],
|
||||
maxSendsPerHour: 100,
|
||||
}
|
||||
|
||||
function Dormant({ reasons }) {
|
||||
return (
|
||||
<span
|
||||
className="badge"
|
||||
title={reasons.join('\n')}
|
||||
style={{ color: 'var(--accent)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
|
||||
>
|
||||
Dormant
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The editor ─────────────────────────────────────────────────────────────
|
||||
|
||||
function RuleEditor({ catalog, segments, rule, onSaved, onCancel }) {
|
||||
const [form, setForm] = useState(() => (rule ? formFromRule(rule) : { ...BLANK }))
|
||||
const [conditionState, setConditionState] = useState(() => conditionRowsFrom(rule?.conditions))
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [errors, setErrors] = useState([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const isNew = !form.id
|
||||
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
|
||||
|
||||
const trigger = useMemo(
|
||||
() => catalog.triggers.find((t) => t.id === form.triggerId) || null,
|
||||
[catalog.triggers, form.triggerId],
|
||||
)
|
||||
const audienceChoices = audienceChoicesFor(trigger, catalog.ceilings)
|
||||
const segmentChoices = segmentChoicesFor(trigger, catalog.ceilings, segments)
|
||||
const variables = trigger?.variables || []
|
||||
|
||||
// Changing the trigger invalidates the audience and every condition, because
|
||||
// both are stated in the old trigger's vocabulary. Clearing them is the honest
|
||||
// move: keeping a condition on a variable the new trigger never carries would
|
||||
// make the rule fire on nothing, silently (an absent variable fails every
|
||||
// comparison, by design).
|
||||
function pickTrigger(id) {
|
||||
const next = catalog.triggers.find((t) => t.id === id)
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
triggerId: id,
|
||||
audience: next?.audience || 'owner',
|
||||
audienceSegmentId: null,
|
||||
}))
|
||||
setConditionState({ op: 'and', rows: [], editable: true })
|
||||
setPreview(null)
|
||||
}
|
||||
|
||||
function toggleChannel(id) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
channels: f.channels.includes(id) ? f.channels.filter((c) => c !== id) : [...f.channels, id],
|
||||
}))
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
setPreviewing(true)
|
||||
try {
|
||||
setPreview(
|
||||
await api.admin.previewEngagementReach({
|
||||
audience: form.audience,
|
||||
audienceSegmentId: form.audienceSegmentId,
|
||||
triggerId: form.triggerId,
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
setPreview({ count: 0, dormant: true, reason: err.message || 'could not be resolved' })
|
||||
} finally {
|
||||
setPreviewing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setErrors([])
|
||||
setBusy(true)
|
||||
const payload = ruleToPayload({
|
||||
...form,
|
||||
conditions: conditionState.editable
|
||||
? conditionsFromRows(conditionState.op, conditionState.rows, variables)
|
||||
: form.conditions,
|
||||
})
|
||||
try {
|
||||
if (isNew) await api.admin.createEngagementRule(payload)
|
||||
else await api.admin.updateEngagementRule(form.id, payload)
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
// The server sends every problem, not just the first. A form that shows one
|
||||
// makes an operator fix four things in four round trips.
|
||||
setErrors(err.body?.errors?.length ? err.body.errors : [err.message || 'Could not save the rule.'])
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
|
||||
<div className="field-label" style={{ marginBottom: 14 }}>
|
||||
{isNew ? 'New rule' : `Editing “${rule.name}”`}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 280px' }}>
|
||||
<span className="field-label">Trigger</span>
|
||||
{isNew ? (
|
||||
<select className="select" value={form.triggerId} onChange={(e) => pickTrigger(e.target.value)}>
|
||||
<option value="">Choose an event…</option>
|
||||
{catalog.triggers.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.label} ({t.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input className="input" value={form.triggerId} readOnly disabled />
|
||||
)}
|
||||
{!isNew && (
|
||||
<span className="sans" style={{ fontSize: '0.78rem', color: 'var(--muted)' }}>
|
||||
A rule keeps its trigger — its cooldowns, queued sends and history are all about this one.
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label style={{ flex: '1 1 280px' }}>
|
||||
<span className="field-label">Name</span>
|
||||
<input
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
placeholder="IDOC warning to the owner"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{trigger?.description && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.82rem', color: 'var(--muted)' }}>
|
||||
{trigger.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Audience ── */}
|
||||
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Who it reaches</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<label style={{ flex: '1 1 220px' }}>
|
||||
<span className="field-label">Audience</span>
|
||||
<select
|
||||
className="select"
|
||||
value={form.audienceSegmentId ? '' : form.audience}
|
||||
disabled={Boolean(form.audienceSegmentId) || !audienceChoices.length}
|
||||
onChange={(e) => { set({ audience: e.target.value, audienceSegmentId: null }); setPreview(null) }}
|
||||
>
|
||||
{/* Without a trigger there is no ceiling, so there is nothing this
|
||||
may legitimately offer — and a select with zero options renders
|
||||
as a control that is broken rather than as one that is waiting. */}
|
||||
{!audienceChoices.length && <option value="">Choose a trigger first…</option>}
|
||||
{Boolean(form.audienceSegmentId) && <option value="">Using the saved audience →</option>}
|
||||
{audienceChoices.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ flex: '1 1 220px' }}>
|
||||
<span className="field-label">…or a saved audience</span>
|
||||
<select
|
||||
className="select"
|
||||
value={form.audienceSegmentId || ''}
|
||||
onChange={(e) => {
|
||||
set({ audienceSegmentId: e.target.value ? Number(e.target.value) : null })
|
||||
setPreview(null)
|
||||
}}
|
||||
>
|
||||
<option value="">None — use the audience on the left</option>
|
||||
{segmentChoices.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="btn btn-sq" disabled={previewing || !form.triggerId} onClick={runPreview}>
|
||||
{previewing ? 'Counting…' : 'Preview reach'}
|
||||
</button>
|
||||
</div>
|
||||
{preview && (
|
||||
<p
|
||||
className="sans"
|
||||
style={{
|
||||
margin: '10px 0 0',
|
||||
fontSize: '0.84rem',
|
||||
color: preview.permitted === false || preview.dormant ? '#d98b84' : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
{describeReach(preview)}
|
||||
</p>
|
||||
)}
|
||||
{/* The `members`-with-no-saved-audience trap, said before the save rather
|
||||
than discovered after it. It is the DEFAULT the moment a
|
||||
members-ceiling trigger is chosen, and the rule it produces saves,
|
||||
switches on and mails nobody. */}
|
||||
{!preview && audienceWarning(form) && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.84rem', color: 'var(--accent)' }}>
|
||||
{audienceWarning(form)}
|
||||
</p>
|
||||
)}
|
||||
{trigger && audienceChoices.length <= 1 && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
This event only permits “{trigger.ceiling}”. The audience a rule may use is capped by the
|
||||
event itself, not by the rule.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Channels ── */}
|
||||
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>How it is delivered</div>
|
||||
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap' }}>
|
||||
{catalog.channels.map((c) => (
|
||||
<div key={c.id} style={{ flex: '0 1 260px' }}>
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={form.channels.includes(c.id)} onChange={() => toggleChannel(c.id)} />
|
||||
{c.label}
|
||||
</label>
|
||||
{form.channels.includes(c.id) && (
|
||||
<input
|
||||
className="input"
|
||||
style={{ marginTop: 6, width: '100%' }}
|
||||
placeholder="template key (optional)"
|
||||
value={form.templateKeys[c.id] || ''}
|
||||
onChange={(e) => set({ templateKeys: { ...form.templateKeys, [c.id]: e.target.value } })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
Every channel is opt-in: a rule reaches only the people who turned that channel on for this
|
||||
event in their own notification settings.
|
||||
</p>
|
||||
|
||||
{/* ── Conditions ── */}
|
||||
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Only when…</div>
|
||||
{!conditionState.editable ? (
|
||||
<div>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--accent)' }}>
|
||||
This rule has a nested condition this editor does not render. It is left exactly as it is
|
||||
unless you clear it — flattening it here would change which events fire the rule.
|
||||
</p>
|
||||
<pre
|
||||
style={{ background: 'var(--panel-flat)', border: '1px solid var(--line)', borderRadius: 6, padding: 10, fontSize: '0.76rem', overflowX: 'auto' }}
|
||||
>
|
||||
{JSON.stringify(form.conditions, null, 2)}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ ...DANGER, fontSize: '0.72rem' }}
|
||||
onClick={() => { set({ conditions: null }); setConditionState({ op: 'and', rows: [], editable: true }) }}
|
||||
>
|
||||
Clear and start again
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{conditionState.rows.length > 1 && (
|
||||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||
<span className="field-label">Match</span>
|
||||
<select
|
||||
className="select"
|
||||
style={{ maxWidth: 220 }}
|
||||
value={conditionState.op}
|
||||
onChange={(e) => setConditionState((s) => ({ ...s, op: e.target.value }))}
|
||||
>
|
||||
<option value="and">all of these</option>
|
||||
<option value="or">any of these</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{conditionState.rows.map((row, i) => {
|
||||
const type = variables.find((v) => v.name === row.variable)?.type
|
||||
const ops = operatorsForType(catalog.operators, type)
|
||||
const takesValue = row.cmp !== 'present' && row.cmp !== 'absent'
|
||||
const patch = (p) =>
|
||||
setConditionState((s) => ({
|
||||
...s,
|
||||
rows: s.rows.map((r, j) => (i === j ? { ...r, ...p } : r)),
|
||||
}))
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
<select
|
||||
className="select"
|
||||
style={{ flex: '1 1 160px' }}
|
||||
value={row.variable}
|
||||
onChange={(e) => patch({ variable: e.target.value })}
|
||||
>
|
||||
<option value="">Variable…</option>
|
||||
{variables.map((v) => (
|
||||
<option key={v.name} value={v.name}>{v.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="select"
|
||||
style={{ flex: '1 1 160px' }}
|
||||
value={row.cmp}
|
||||
onChange={(e) => patch({ cmp: e.target.value })}
|
||||
>
|
||||
<option value="">Is…</option>
|
||||
{ops.map((o) => (
|
||||
<option key={o.cmp} value={o.cmp}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{takesValue && (
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: '2 1 200px' }}
|
||||
value={row.value}
|
||||
placeholder={row.cmp === 'in' || row.cmp === 'nin' ? 'comma, separated, values' : 'value'}
|
||||
onChange={(e) => patch({ value: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ ...DANGER, fontSize: '0.72rem' }}
|
||||
onClick={() => setConditionState((s) => ({ ...s, rows: s.rows.filter((_, j) => j !== i) }))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={!variables.length}
|
||||
onClick={() =>
|
||||
setConditionState((s) => ({ ...s, rows: [...s.rows, { variable: '', cmp: '', value: '' }] }))
|
||||
}
|
||||
>
|
||||
Add a condition
|
||||
</button>
|
||||
{!variables.length && (
|
||||
<span className="sans" style={{ marginLeft: 10, fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
Choose a trigger first — its declared variables are what a condition can talk about.
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Timing and the ceiling ── */}
|
||||
<div className="field-label" style={{ marginTop: 20, marginBottom: 8 }}>Timing</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 160px' }}>
|
||||
<span className="field-label">Wait before sending (seconds)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.delaySeconds}
|
||||
onChange={(e) => set({ delaySeconds: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ flex: '1 1 160px' }}>
|
||||
<span className="field-label">At most once per (seconds)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.cooldownSeconds}
|
||||
onChange={(e) => set({ cooldownSeconds: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ flex: '1 1 160px' }}>
|
||||
<span className="field-label">Hard cap (sends per hour)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="1"
|
||||
value={form.maxSendsPerHour}
|
||||
onChange={(e) => set({ maxSendsPerHour: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
The cooldown is per recipient and per subject
|
||||
{trigger?.subjectKey ? ` (“${trigger.subjectKey}”)` : ''} — a player whose four houses are all
|
||||
decaying hears about all four, once each. The hourly cap is per rule and is the hard stop that
|
||||
keeps a misconfiguration to a bad hour.
|
||||
</p>
|
||||
|
||||
{form.delaySeconds > 0 && (
|
||||
<label style={{ display: 'block', marginTop: 14 }}>
|
||||
<span className="field-label">Cancel the wait if any of these happen</span>
|
||||
<select
|
||||
className="select"
|
||||
multiple
|
||||
size={Math.min(5, Math.max(2, catalog.triggers.length))}
|
||||
value={form.cancelOn}
|
||||
onChange={(e) => set({ cancelOn: [...e.target.selectedOptions].map((o) => o.value) })}
|
||||
>
|
||||
{catalog.triggers.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="sans" style={{ fontSize: '0.78rem', color: 'var(--muted)' }}>
|
||||
Only meaningful with a wait — there is no window to cancel otherwise, and the save says so.
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{errors.length > 0 && (
|
||||
<ul className="sans" style={{ margin: '14px 0 0', paddingLeft: 18, color: '#d98b84', fontSize: '0.84rem' }}>
|
||||
{errors.map((e) => <li key={e}>{e}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
|
||||
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
|
||||
{busy ? 'Saving…' : isNew ? 'Create rule (off)' : 'Save changes'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
|
||||
{isNew && (
|
||||
<span className="sans" style={{ alignSelf: 'center', fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
A new rule is created switched off. Turn it on from the list when you are happy with it.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ── The Phase 6 migration notice ───────────────────────────────────────────
|
||||
//
|
||||
// Team notifications used to be sent with no operator configuration at all;
|
||||
// ENGAGEMENT.md Phase 6 moved them onto rules, and the org lead's decision was to
|
||||
// seed those rules DISABLED rather than carve an exception into "nothing is on by
|
||||
// default". The consequence is a deployment whose Team email has stopped and
|
||||
// nobody has been told — which is G22's failure mode with a different cause — so
|
||||
// the screen that can fix it says so.
|
||||
//
|
||||
// It reads the RULES rather than a flag, so it disappears the moment one is
|
||||
// switched on and comes back if every one is switched off again. A deployment
|
||||
// that deleted them all sees nothing, which is right: they made that choice.
|
||||
//
|
||||
// **Phase 11 added a second notice of exactly the same shape, for news**
|
||||
// (ENGAGEMENT.md §7.1 Q9). Publishing a news post used to tickle every subscriber
|
||||
// directly, and that call is now an emit through the engine, so news push stops
|
||||
// on upgrade until the seeded `news.post` rule is switched on. Two notices rather
|
||||
// than one generalised "some rules are off" banner, deliberately: each names a
|
||||
// capability that USED to work without configuration and now does not, which is
|
||||
// a different statement from "you have a disabled rule" — and a rule an operator
|
||||
// created and disabled themselves must never produce a warning.
|
||||
const TEAM_TRIGGERS = [
|
||||
'team.forum.post',
|
||||
'team.announcement',
|
||||
'team.member.joined',
|
||||
'team.leadership.changed',
|
||||
]
|
||||
|
||||
const NEWS_TRIGGERS = ['news.post']
|
||||
|
||||
// One style for both notices, so the pair reads as one kind of message rather
|
||||
// than two that happen to look alike.
|
||||
const NOTICE_STYLE = {
|
||||
fontSize: '0.85rem',
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
marginBottom: 16,
|
||||
border: '1px solid #7a6440',
|
||||
color: '#e0b070',
|
||||
}
|
||||
|
||||
const triggerOf = (rule) => rule.triggerId || rule.trigger_id
|
||||
|
||||
// True only when rules for these triggers EXIST and every one of them is off.
|
||||
// Zero matching rules means the operator deleted them, which is a choice, not a
|
||||
// regression to warn about.
|
||||
function allOff(rules, triggers) {
|
||||
const group = rules.filter((r) => triggers.includes(triggerOf(r)))
|
||||
return group.length > 0 && group.every((r) => !r.enabled)
|
||||
}
|
||||
|
||||
const teamRulesAllOff = (rules) => allOff(rules, TEAM_TRIGGERS)
|
||||
const newsRulesAllOff = (rules) => allOff(rules, NEWS_TRIGGERS)
|
||||
|
||||
export default function EngagementRules() {
|
||||
const [catalog, setCatalog] = useState(null)
|
||||
const [segments, setSegments] = useState([])
|
||||
const [rules, setRules] = useState(null)
|
||||
const [editing, setEditing] = useState(null) // null | { rule } | { rule: null } for new
|
||||
const [error, setError] = useState('')
|
||||
const [rowError, setRowError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const [triggers, channels, segs, list] = await Promise.all([
|
||||
api.admin.engagementTriggers(),
|
||||
api.admin.engagementChannels(),
|
||||
api.admin.listEngagementSegments(),
|
||||
api.admin.listEngagementRules(),
|
||||
])
|
||||
setCatalog({
|
||||
triggers: triggers.triggers || [],
|
||||
ceilings: triggers.ceilings || [],
|
||||
operators: triggers.operators || [],
|
||||
channels: channels.channels || [],
|
||||
})
|
||||
setSegments(segs.segments || [])
|
||||
setRules(list.rules || [])
|
||||
} catch {
|
||||
setError('Could not load the engagement rules.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const segmentsById = useMemo(
|
||||
() => Object.fromEntries(segments.map((s) => [s.id, s])),
|
||||
[segments],
|
||||
)
|
||||
|
||||
async function toggle(rule) {
|
||||
setRowError('')
|
||||
try {
|
||||
await api.admin.setEngagementRuleEnabled(rule.id, !rule.enabled)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setRowError(err.message || 'Could not change that rule.')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(rule) {
|
||||
if (!window.confirm(`Delete “${rule.name}”? Its queued sends go with it; the send log does not.`)) return
|
||||
setRowError('')
|
||||
try {
|
||||
await api.admin.deleteEngagementRule(rule.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setRowError(err.message || 'Could not delete that rule.')
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!catalog || !rules) return <Loading />
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<section>
|
||||
<RuleEditor
|
||||
catalog={catalog}
|
||||
segments={segments}
|
||||
rule={editing.rule}
|
||||
onSaved={async () => { setEditing(null); await load() }}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
{teamRulesAllOff(rules) && (
|
||||
<div className="sans" style={NOTICE_STYLE}>
|
||||
<strong>Team notification emails are off.</strong> They used to be sent automatically; they
|
||||
are now rules, and the four below arrived switched off so that nothing starts mailing on its
|
||||
own. Switch on the ones this deployment wants — per-member preferences and per-Team mutes
|
||||
still apply above them, and unsubscribe links in mail already sent still work.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newsRulesAllOff(rules) && (
|
||||
<div className="sans" style={NOTICE_STYLE}>
|
||||
<strong>News notifications are off.</strong> Publishing a news post used to send a push
|
||||
notification to everyone subscribed to it. That is now the “News posts” rule below, and it
|
||||
arrived switched off for the same reason the Team rules did. Switch it on to resume news
|
||||
push — it also carries email and the in-app inbox, each still subject to each person’s own
|
||||
preferences. The in-game town crier and the Discord announcement are unaffected either way.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 640 }}>
|
||||
A rule turns an event into mail: which event, who hears about it, on which channels, and how
|
||||
often at most. Nothing sends until a rule is switched on.
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary btn-sq" onClick={() => setEditing({ rule: null })}>
|
||||
New rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rowError && (
|
||||
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
|
||||
)}
|
||||
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Rule</th>
|
||||
<th className="adm-th">Trigger</th>
|
||||
<th className="adm-th">What it does</th>
|
||||
<th className="adm-th">State</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.length === 0 && (
|
||||
<tr>
|
||||
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
|
||||
No rules yet. Nothing is being sent.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rules.map((rule) => (
|
||||
<tr key={rule.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--text)' }}>{rule.name}</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{rule.trigger_id}</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
|
||||
{describeRule(rule, { segmentsById })}
|
||||
</td>
|
||||
<td className="adm-td">
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={Boolean(rule.enabled)} onChange={() => toggle(rule)} />
|
||||
{rule.enabled ? 'On' : 'Off'}
|
||||
</label>
|
||||
{rule.dormant && (
|
||||
<div style={{ marginTop: 4 }}><Dormant reasons={rule.dormantReasons || []} /></div>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ fontSize: '0.72rem', marginRight: 6 }}
|
||||
onClick={() => setEditing({ rule })}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ ...DANGER, fontSize: '0.72rem' }}
|
||||
onClick={() => remove(rule)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{rules.some((r) => r.dormant) && (
|
||||
<p className="sans" style={{ marginTop: 12, fontSize: '0.8rem', color: 'var(--muted)' }}>
|
||||
A dormant rule names something that is not registered right now — usually a module that has
|
||||
been uninstalled. It is kept exactly as it is, it never fires, and it starts working again
|
||||
when the module comes back. It can still be switched off.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
172
client/src/routes/admin/views/EngagementSendLog.jsx
Normal file
172
client/src/routes/admin/views/EngagementSendLog.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Engagement → Send Log (ENGAGEMENT.md §4.5, gap G15, Phase 5b).
|
||||
//
|
||||
// G15 was stated as: "no per-message record — no send log, no delivery status, no
|
||||
// audit". The table has been filling since Phase 4a; this is the screen that reads
|
||||
// it, and the question it exists to answer is the operator's, not the engine's:
|
||||
// **did that person get that mail, and if not, why not?**
|
||||
//
|
||||
// Two things it deliberately does not show.
|
||||
//
|
||||
// • **The address.** The log stores a sha256 so a bounce can be correlated back
|
||||
// to a recipient (Phase 9) without becoming a second address book. The route
|
||||
// strips the column; this screen could not render it if it wanted to.
|
||||
// • **A name for the user.** The `user_id` is what the log holds, and joining
|
||||
// users in would make a delivery screen into a directory. The id is enough to
|
||||
// paste into Moderation, which is where a person's record belongs.
|
||||
//
|
||||
// `failed` rows are the point of the screen, so the reason is a column and not a
|
||||
// tooltip: a delivery log whose failures need a hover is a log nobody reads.
|
||||
|
||||
const STATUS_LABEL = {
|
||||
sent: 'Sent',
|
||||
failed: 'Failed',
|
||||
suppressed: 'Not sent',
|
||||
bounced: 'Bounced',
|
||||
complained: 'Marked as spam',
|
||||
}
|
||||
|
||||
const STATUS_COLOR = {
|
||||
failed: '#d98b84',
|
||||
bounced: '#d98b84',
|
||||
complained: '#d98b84',
|
||||
}
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
export default function EngagementSendLog() {
|
||||
const [rows, setRows] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [status, setStatus] = useState('')
|
||||
const [testTrigger, setTestTrigger] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = useCallback(async (nextOffset, nextStatus) => {
|
||||
const result = await api.admin.listEngagementSends({
|
||||
limit: PAGE,
|
||||
offset: nextOffset,
|
||||
status: nextStatus || undefined,
|
||||
})
|
||||
setRows(result.sends || [])
|
||||
setTotal(result.total || 0)
|
||||
setTestTrigger(result.testSendTrigger || '')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load(offset, status)
|
||||
if (alive) setError(null)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [load, offset, status])
|
||||
|
||||
if (loading && rows.length === 0) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const to = Math.min(offset + PAGE, total)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 560 }}>
|
||||
Every message this deployment tried to deliver, successful or not. Addresses are not kept
|
||||
here — only a one-way hash, so a bounce can be matched back without the log becoming a
|
||||
second address book.
|
||||
</p>
|
||||
<label>
|
||||
<span className="field-label">Show</span>
|
||||
<select className="select" value={status} onChange={(e) => { setOffset(0); setStatus(e.target.value) }}>
|
||||
<option value="">Everything</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="suppressed">Not sent</option>
|
||||
<option value="bounced">Bounced</option>
|
||||
<option value="complained">Marked as spam</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{total === 0 ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
{status ? 'Nothing matches that filter.' : 'Nothing has been sent yet.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">When</th>
|
||||
<th className="adm-th">What</th>
|
||||
<th className="adm-th">To</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">Result</th>
|
||||
<th className="adm-th">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
|
||||
{new Date(r.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{/* The synthetic test-send id is rendered by name: it is not a
|
||||
registered trigger and will never appear in the catalog,
|
||||
so showing the raw id would send someone looking for it. */}
|
||||
{r.trigger_id === testTrigger
|
||||
? <span>Test send <span className="dim">from the template editor</span></span>
|
||||
: <code style={{ fontSize: '0.8rem' }}>{r.trigger_id}</code>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.user_id ? <span className="dim">user #{r.user_id}</span> : <span className="dim">—</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.channel}
|
||||
{r.transport && <span className="dim"> · {r.transport}</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: STATUS_COLOR[r.status] || undefined }}>
|
||||
{STATUS_LABEL[r.status] || r.status}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
|
||||
{r.detail || ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
|
||||
{offset + 1}–{to} of {total}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
|
||||
Newer
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
disabled={to >= total} onClick={() => setOffset(offset + PAGE)}>
|
||||
Older
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
259
client/src/routes/admin/views/EngagementSuppressions.jsx
Normal file
259
client/src/routes/admin/views/EngagementSuppressions.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Engagement → Suppressions (ENGAGEMENT.md §4.5 gap G16, Phase 9).
|
||||
//
|
||||
// **This screen is the only way out of the suppression list**, which is the whole
|
||||
// reason it exists rather than the list living as a filter on the Send Log. A
|
||||
// hard bounce is written by a background worker with no human in the loop, so
|
||||
// without a lift button a mistyped-then-corrected mailbox is silenced for good
|
||||
// and nobody ever finds out why that person stopped hearing from the deployment.
|
||||
//
|
||||
// **Addresses are shown masked, and the mask is deliberate on both ends.** The
|
||||
// table holds a sha256 and an `address_masked` — `d***@example.com` — and the
|
||||
// route never returns the hash, for the same reason the Send Log strips it: a
|
||||
// digest of every address on the deployment, handed to a browser, is an offline
|
||||
// dictionary attack waiting to be run. The domain survives because the signal an
|
||||
// operator is actually hunting is domain-shaped ("everything to this company is
|
||||
// bouncing" is a different problem from three people mistyping their own
|
||||
// address), and the local part is destroyed rather than shortened so the list can
|
||||
// never be read back as an address book.
|
||||
//
|
||||
// The consequence to keep in mind while reading this file: **lifting a
|
||||
// suppression needs the WHOLE address typed in**, because the screen genuinely
|
||||
// does not have it. That is not a rough edge to be smoothed later — it is the
|
||||
// privacy design working, and the confirm dialog says so.
|
||||
|
||||
const REASON_LABEL = {
|
||||
bounce: 'Hard bounce',
|
||||
complaint: 'Marked as spam',
|
||||
manual: 'Added by an admin',
|
||||
unverified: 'Unverified',
|
||||
}
|
||||
|
||||
const REASON_HELP = {
|
||||
bounce: 'The receiving server said this mailbox does not exist.',
|
||||
complaint: 'The recipient reported a message as spam.',
|
||||
manual: 'Somebody here added it — usually a bounce reported another way.',
|
||||
unverified: 'Reserved: the verification gate excludes these before a send is queued.',
|
||||
}
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
export default function EngagementSuppressions() {
|
||||
const [rows, setRows] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [byReason, setByReason] = useState({})
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [reason, setReason] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
// Debounced separately from `search` so typing a domain does not fire a request
|
||||
// per keystroke; `search` is what the input shows, `applied` is what was asked.
|
||||
const [applied, setApplied] = useState('')
|
||||
const [adding, setAdding] = useState('')
|
||||
const [note, setNote] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = useCallback(async (nextOffset, nextReason, nextSearch) => {
|
||||
const result = await api.admin.listEngagementSuppressions({
|
||||
limit: PAGE,
|
||||
offset: nextOffset,
|
||||
reason: nextReason || undefined,
|
||||
search: nextSearch || undefined,
|
||||
})
|
||||
setRows(result.suppressions || [])
|
||||
setTotal(result.total || 0)
|
||||
setByReason(result.byReason || {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { setOffset(0); setApplied(search.trim()) }, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load(offset, reason, applied)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [load, offset, reason, applied])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
async function addByHand(e) {
|
||||
e.preventDefault()
|
||||
const address = adding.trim()
|
||||
if (!address) return
|
||||
setNote(null)
|
||||
try {
|
||||
const result = await api.admin.suppressAddress(address)
|
||||
// `created: false` is not a failure — the operator asked for the address to
|
||||
// be suppressed and it is. Saying so plainly beats an error dialog for an
|
||||
// outcome that is exactly what was wanted.
|
||||
setNote(result.created
|
||||
? `${result.address} will no longer be mailed.`
|
||||
: `${result.address} was already suppressed.`)
|
||||
setAdding('')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setNote(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function lift() {
|
||||
// The address cannot come from the row — the screen has only the mask. Asking
|
||||
// for it in full is the cost of not storing it, and the prompt says why so it
|
||||
// does not read as a missing feature.
|
||||
const address = window.prompt(
|
||||
'Type the full address to let it be mailed again.\n\n'
|
||||
+ 'Suppressed addresses are stored one-way, so this screen never has the address itself.',
|
||||
)
|
||||
if (!address || !address.trim()) return
|
||||
setNote(null)
|
||||
try {
|
||||
await api.admin.unsuppressAddress(address.trim())
|
||||
setNote(`${address.trim()} can be mailed again.`)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setNote(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && rows.length === 0 && !applied && !reason) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const to = Math.min(offset + PAGE, total)
|
||||
const summary = Object.entries(byReason).filter(([, n]) => n > 0)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 620 }}>
|
||||
Addresses this deployment has stopped mailing. Engagement rules skip them; password resets,
|
||||
invites and verification mails still go out, because those are asked for by the person
|
||||
themselves. Addresses are stored one-way and shown masked.
|
||||
</p>
|
||||
|
||||
{summary.length > 0 && (
|
||||
<div className="panel-flat" style={{ display: 'flex', gap: 24, flexWrap: 'wrap', padding: '12px 16px', marginBottom: 16 }}>
|
||||
{summary.map(([r, n]) => (
|
||||
<div key={r}>
|
||||
<div className="sans" style={{ fontSize: '1.1rem', fontWeight: 600 }}>{n}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem' }} title={REASON_HELP[r] || ''}>
|
||||
{REASON_LABEL[r] || r}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<label style={{ flex: '1 1 220px' }}>
|
||||
<span className="field-label">Search</span>
|
||||
<input
|
||||
className="input"
|
||||
value={search}
|
||||
placeholder="a domain, or part of one"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Reason</span>
|
||||
<select className="select" value={reason} onChange={(e) => { setOffset(0); setReason(e.target.value) }}>
|
||||
<option value="">Any</option>
|
||||
{Object.keys(REASON_LABEL).map((r) => (
|
||||
<option key={r} value={r}>{REASON_LABEL[r]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<form onSubmit={addByHand} style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flex: '1 1 280px' }}>
|
||||
<label style={{ flex: 1 }}>
|
||||
<span className="field-label">Suppress an address</span>
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
value={adding}
|
||||
placeholder="someone@example.com"
|
||||
onChange={(e) => setAdding(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="pill" style={{ fontSize: '0.74rem' }} disabled={!adding.trim()}>
|
||||
Suppress
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} onClick={lift}>
|
||||
Lift a suppression
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{note && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', margin: '0 0 14px' }}>{note}</p>
|
||||
)}
|
||||
|
||||
{total === 0 ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
{reason || applied ? 'Nothing matches that filter.' : 'No addresses are suppressed.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Address</th>
|
||||
<th className="adm-th">Reason</th>
|
||||
<th className="adm-th">Detail</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">Since</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={`${r.channel}:${r.address_masked}:${r.created_at}`}>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.address_masked
|
||||
? <code style={{ fontSize: '0.8rem' }}>{r.address_masked}</code>
|
||||
: <span className="dim">not recorded</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }} title={REASON_HELP[r.reason] || ''}>
|
||||
{REASON_LABEL[r.reason] || r.reason}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
|
||||
{r.detail || ''}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{r.channel}</td>
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
|
||||
{new Date(r.created_at).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
|
||||
{offset + 1}–{to} of {total}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
|
||||
Newer
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
disabled={to >= total} onClick={() => setOffset(offset + PAGE)}>
|
||||
Older
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
649
client/src/routes/admin/views/EngagementTemplates.jsx
Normal file
649
client/src/routes/admin/views/EngagementTemplates.jsx
Normal file
@@ -0,0 +1,649 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { getEmailBlock, listEmailBlocks, newEmailBlock } from '../../../emailBlocks/index.js'
|
||||
|
||||
// Admin → Engagement → Templates (ENGAGEMENT.md §4.6.2, Phase 5b).
|
||||
//
|
||||
// Phase 5a moved every subject and body out of `mailer.js` into rows. This is the
|
||||
// screen that lets someone change one, and its whole shape follows from a single
|
||||
// fact about email:
|
||||
//
|
||||
// **the server renders the mail, so the server renders the preview.**
|
||||
//
|
||||
// There is no React renderer for an `email.*` block anywhere in this client. The
|
||||
// preview is HTML the server produced with the same call the send path uses,
|
||||
// dropped into a sandboxed iframe. That costs a round trip per edit — debounced
|
||||
// below — and buys the only property that matters on a screen like this: what is
|
||||
// on screen is what will arrive, not a second implementation's opinion of it.
|
||||
//
|
||||
// **The sandbox is a security boundary, not a nicety.** The preview is
|
||||
// operator-authored HTML. It renders with `sandbox` and no `allow-scripts`, from
|
||||
// `srcdoc` (an opaque origin), so it can neither run script nor reach this page's
|
||||
// cookies even if someone stores markup that gets past `sanitizeHtml`. The
|
||||
// attributes are asserted in `client/test/emailTemplates.test.js` for the same
|
||||
// reason the server's checks are asserted: this is the kind of attribute someone
|
||||
// removes while debugging and does not put back.
|
||||
//
|
||||
// What the operator can do here is deliberately bounded (settled with the org
|
||||
// lead at the start of the phase):
|
||||
//
|
||||
// • **A shipped default is edited in place.** `protected` blocks deletion and
|
||||
// nothing else; saving sets `customized = 1`, which is what stops the next
|
||||
// seed bump from taking the edit back.
|
||||
// • **Duplicate is the only way to a new template**, so every template on a
|
||||
// deployment descends from one that renders.
|
||||
|
||||
const DANGER = { color: '#d98b84', borderColor: '#5b2020' }
|
||||
|
||||
// Three widths, because a mail body has to survive all of them and the failures
|
||||
// are different: 640 is a desktop client's reading pane, 360 is a phone, and the
|
||||
// plain-text part is what a text-only client and every screen reader gets.
|
||||
const WIDTHS = [
|
||||
['desktop', 'Desktop', 640],
|
||||
['mobile', 'Mobile', 360],
|
||||
]
|
||||
|
||||
/** Short, human label for a template's channel. */
|
||||
const CHANNEL_LABEL = { email: 'Email', inapp: 'On the site', push: 'Push' }
|
||||
|
||||
// ── The preview frame ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The rendered HTML, in a sandboxed frame.
|
||||
*
|
||||
* `dark` applies a CSS inversion to the FRAME, not to the mail: it approximates
|
||||
* what Apple Mail and Outlook do to a light-only message, which is the failure
|
||||
* §4.6.2 asks this control to expose ("a light-only template renders as unreadable
|
||||
* dark-on-dark in about a third of inboxes"). It is an approximation and says so
|
||||
* on screen — the alternative, rendering a second dark palette server-side, would
|
||||
* be a preview of a mail this system does not send.
|
||||
*/
|
||||
function PreviewFrame({ html, width, dark }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: dark ? '#1b1b1b' : '#f4f4f5',
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
// No allow-scripts, and no allow-same-origin. Both omissions are load
|
||||
// bearing; see this file's header.
|
||||
sandbox=""
|
||||
srcDoc={html || ''}
|
||||
title="Message preview"
|
||||
style={{
|
||||
width,
|
||||
maxWidth: '100%',
|
||||
height: 520,
|
||||
border: '1px solid var(--rule)',
|
||||
borderRadius: 4,
|
||||
background: '#fff',
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
filter: dark ? 'invert(1) hue-rotate(180deg)' : 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The editor ─────────────────────────────────────────────────────────────
|
||||
|
||||
function TemplateEditor({ template, triggers, onDone, onCancel }) {
|
||||
const [name, setName] = useState(template.name)
|
||||
const [subject, setSubject] = useState(template.subject || '')
|
||||
const [blocks, setBlocks] = useState(template.blocks || [])
|
||||
const [textBody, setTextBody] = useState(template.text_body || '')
|
||||
const [status, setStatus] = useState(template.status)
|
||||
const [triggerId, setTriggerId] = useState(template.trigger_id || '')
|
||||
const [selected, setSelected] = useState(template.blocks?.[0]?.id || null)
|
||||
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [previewError, setPreviewError] = useState(null)
|
||||
const [tab, setTab] = useState('html')
|
||||
const [width, setWidth] = useState('desktop')
|
||||
const [dark, setDark] = useState(false)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [errors, setErrors] = useState([])
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [testTo, setTestTo] = useState('')
|
||||
const [testState, setTestState] = useState(null)
|
||||
|
||||
// The variable palette. It comes from the server with the row and is refreshed
|
||||
// by every preview, because re-pointing the template at another trigger changes
|
||||
// it and the server is the one that knows what that trigger declares.
|
||||
const [variables, setVariables] = useState(template.variables || [])
|
||||
|
||||
const draft = useMemo(
|
||||
() => ({ name, subject, blocks, textBody: textBody || null, status, triggerId: triggerId || null }),
|
||||
[name, subject, blocks, textBody, status, triggerId],
|
||||
)
|
||||
|
||||
// Debounced preview. The delay is not about server load — it is one small
|
||||
// render — but about the frame: re-mounting an iframe on every keystroke makes
|
||||
// the preview flicker and steals nothing back.
|
||||
const timer = useRef(null)
|
||||
useEffect(() => {
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(async () => {
|
||||
try {
|
||||
const body = { subject: draft.subject, blocks: draft.blocks, textBody: draft.textBody, triggerId: draft.triggerId }
|
||||
const result = await api.admin.previewEngagementTemplate(template.id, body)
|
||||
setPreview(result)
|
||||
setPreviewError(null)
|
||||
if (Array.isArray(result.variables)) setVariables(result.variables)
|
||||
} catch (err) {
|
||||
// A preview failure is expected while a block is half-edited, so it is
|
||||
// shown where the preview would be rather than as a page-level error.
|
||||
setPreviewError(err.body?.errors?.join(' · ') || err.message)
|
||||
}
|
||||
}, 400)
|
||||
return () => timer.current && clearTimeout(timer.current)
|
||||
}, [draft, template.id])
|
||||
|
||||
const selectedBlock = blocks.find((b) => b.id === selected) || null
|
||||
const selectedDef = selectedBlock ? getEmailBlock(selectedBlock.type) : null
|
||||
|
||||
const updateBlock = (id, props) =>
|
||||
setBlocks((bs) => bs.map((b) => (b.id === id ? { ...b, props } : b)))
|
||||
|
||||
const addBlock = (type) => {
|
||||
const block = newEmailBlock(type)
|
||||
if (!block) return
|
||||
setBlocks((bs) => [...bs, block])
|
||||
setSelected(block.id)
|
||||
}
|
||||
|
||||
const move = (id, delta) =>
|
||||
setBlocks((bs) => {
|
||||
const i = bs.findIndex((b) => b.id === id)
|
||||
const j = i + delta
|
||||
if (i < 0 || j < 0 || j >= bs.length) return bs
|
||||
const next = [...bs]
|
||||
;[next[i], next[j]] = [next[j], next[i]]
|
||||
return next
|
||||
})
|
||||
|
||||
const removeBlock = (id) =>
|
||||
setBlocks((bs) => {
|
||||
const next = bs.filter((b) => b.id !== id)
|
||||
if (selected === id) setSelected(next[0]?.id || null)
|
||||
return next
|
||||
})
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
setErrors([])
|
||||
setSaved(false)
|
||||
try {
|
||||
await api.admin.updateEngagementTemplate(template.id, draft)
|
||||
setSaved(true)
|
||||
onDone()
|
||||
} catch (err) {
|
||||
setErrors(err.body?.errors?.length ? err.body.errors : [err.message])
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
setTestState({ busy: true })
|
||||
try {
|
||||
const body = { ...draft, to: testTo }
|
||||
const result = await api.admin.testSendEngagementTemplate(template.id, body)
|
||||
setTestState({ ok: true, message: `Sent to ${result.to}.` })
|
||||
} catch (err) {
|
||||
setTestState({ ok: false, message: err.body?.errors?.join(' · ') || err.message })
|
||||
}
|
||||
}
|
||||
|
||||
const widthPx = WIDTHS.find(([id]) => id === width)?.[2] || 640
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 16 }}>
|
||||
<div>
|
||||
<h2 className="sans" style={{ margin: '0 0 4px', fontSize: '1.05rem' }}>{template.name}</h2>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>
|
||||
<code>{template.key}</code> · {CHANNEL_LABEL[template.channel] || template.channel}
|
||||
{template.protected && ' · part of the system'}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="btn btn-sq" onClick={onCancel}>Back</button>
|
||||
<button type="button" className="btn btn-primary btn-sq" onClick={save} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errors.length > 0 && (
|
||||
<div className="panel" style={{ padding: 14, marginBottom: 16, borderColor: '#5b2020' }}>
|
||||
{errors.map((e) => (
|
||||
<p key={e} className="sans" style={{ margin: '0 0 4px', color: '#d98b84', fontSize: '0.85rem' }}>{e}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{saved && errors.length === 0 && (
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.85rem', color: 'var(--muted)' }}>Saved.</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 1fr) minmax(320px, 1.2fr)', gap: 22, alignItems: 'start' }}>
|
||||
{/* ── Authoring ── */}
|
||||
<div>
|
||||
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Name</span>
|
||||
<input className="input" value={name} maxLength={160} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
{template.channel === 'email' && (
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Subject</span>
|
||||
<input className="input" value={subject} maxLength={300} onChange={(e) => setSubject(e.target.value)} />
|
||||
<VariableButtons variables={variables} onInsert={(t) => setSubject((s) => s + t)} />
|
||||
</label>
|
||||
)}
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Trigger</span>
|
||||
<select className="select" value={triggerId} onChange={(e) => setTriggerId(e.target.value)}>
|
||||
{/* "None" is the right default and not a missing value: every
|
||||
transactional template is tied to no trigger — mailer renders
|
||||
it by key with no rule involved. */}
|
||||
<option value="">None — used by key, not by a rule</option>
|
||||
{triggers.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.label} ({t.id})</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
The trigger decides which variables this template may use.
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Status</span>
|
||||
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="draft">Draft — the shipped default is sent instead</option>
|
||||
<option value="published">Published — this is what goes out</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Body</div>
|
||||
{blocks.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>No blocks yet. Add one below.</p>
|
||||
)}
|
||||
{blocks.map((b, i) => {
|
||||
const def = getEmailBlock(b.type)
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', marginBottom: 4,
|
||||
borderRadius: 4, cursor: 'pointer',
|
||||
background: b.id === selected ? 'var(--panel-2, rgba(255,255,255,0.05))' : 'transparent',
|
||||
border: `1px solid ${b.id === selected ? 'var(--accent)' : 'transparent'}`,
|
||||
}}
|
||||
onClick={() => setSelected(b.id)}
|
||||
>
|
||||
<span style={{ width: 18, textAlign: 'center' }}>{def?.icon || '?'}</span>
|
||||
<span className="sans" style={{ flex: 1, fontSize: '0.86rem' }}>
|
||||
{/* An unknown type is a client/server version skew, and saying
|
||||
so beats rendering a blank row the operator cannot act on. */}
|
||||
{def ? def.label : `${b.type} (not known to this client)`}
|
||||
</span>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem' }} disabled={i === 0}
|
||||
onClick={(e) => { e.stopPropagation(); move(b.id, -1) }}>↑</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem' }} disabled={i === blocks.length - 1}
|
||||
onClick={(e) => { e.stopPropagation(); move(b.id, 1) }}>↓</button>
|
||||
<button type="button" className="pill" style={{ ...DANGER, fontSize: '0.7rem' }}
|
||||
onClick={(e) => { e.stopPropagation(); removeBlock(b.id) }}>×</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 12 }}>
|
||||
{listEmailBlocks().map((def) => (
|
||||
<button key={def.type} type="button" className="pill" title={def.hint}
|
||||
style={{ fontSize: '0.74rem' }} onClick={() => addBlock(def.type)}>
|
||||
+ {def.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedBlock && selectedDef?.editor && (
|
||||
<div className="panel" style={{ padding: 18, marginBottom: 18 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>{selectedDef.label}</div>
|
||||
<selectedDef.editor
|
||||
props={selectedBlock.props || {}}
|
||||
variables={variables}
|
||||
onChange={(props) => updateBlock(selectedBlock.id, props)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="panel" style={{ padding: 18 }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Plain-text part (optional override)</span>
|
||||
<textarea
|
||||
className="input" rows={5} value={textBody}
|
||||
placeholder="Leave blank to generate it from the blocks above."
|
||||
onChange={(e) => setTextBody(e.target.value)}
|
||||
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '0.82rem' }}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
Every message has both parts. Writing one here REPLACES the generated text entirely.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Preview ── */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: tab === 'html' ? 1 : 0.6 }}
|
||||
onClick={() => setTab('html')}>HTML</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: tab === 'text' ? 1 : 0.6 }}
|
||||
onClick={() => setTab('text')}>Plain text</button>
|
||||
{tab === 'html' && (
|
||||
<>
|
||||
<span style={{ width: 10 }} />
|
||||
{WIDTHS.map(([id, label]) => (
|
||||
<button key={id} type="button" className="pill"
|
||||
style={{ fontSize: '0.74rem', opacity: width === id ? 1 : 0.6 }}
|
||||
onClick={() => setWidth(id)}>{label}</button>
|
||||
))}
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem', opacity: dark ? 1 : 0.6 }}
|
||||
onClick={() => setDark((d) => !d)}>Dark mode</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewError ? (
|
||||
<div className="panel" style={{ padding: 16, borderColor: '#5b2020' }}>
|
||||
<p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{previewError}</p>
|
||||
</div>
|
||||
) : !preview ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Rendering…</p>
|
||||
) : tab === 'html' ? (
|
||||
<>
|
||||
{template.channel === 'email' && (
|
||||
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.85rem' }}>
|
||||
<span className="dim">Subject: </span>{preview.subject || <em className="dim">none</em>}
|
||||
</p>
|
||||
)}
|
||||
<PreviewFrame html={preview.html} width={widthPx} dark={dark} />
|
||||
{dark && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 6 }}>
|
||||
An approximation of how a client that inverts a light-only message will show it.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<pre className="panel" style={{ padding: 16, fontSize: '0.82rem', whiteSpace: 'pre-wrap', margin: 0 }}>
|
||||
{preview.text || '(empty — a published template is refused with no text part)'}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{preview?.missing?.length > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
|
||||
No example value for: {preview.missing.join(', ')} — these render as nothing here and
|
||||
will carry real values when the message is actually sent.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="panel" style={{ padding: 18, marginTop: 18 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Send a test</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||
Sends what is on screen, saved or not, through the configured transport.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input className="input" type="email" placeholder="you@example.com" value={testTo}
|
||||
onChange={(e) => setTestTo(e.target.value)} style={{ flex: 1 }} />
|
||||
<button type="button" className="btn btn-sq" onClick={sendTest} disabled={testState?.busy}>
|
||||
{testState?.busy ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
{testState && !testState.busy && (
|
||||
<p className="sans" style={{ margin: '8px 0 0', fontSize: '0.82rem', color: testState.ok ? 'var(--muted)' : '#d98b84' }}>
|
||||
{testState.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** The variable tokens, for the two fields that are not block props. */
|
||||
function VariableButtons({ variables, onInsert }) {
|
||||
if (!variables?.length) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
|
||||
{variables.map((v) => (
|
||||
<button key={v.name} type="button" className="btn btn-ghost btn-xs"
|
||||
title={`${v.type || 'string'}${v.description ? ` — ${v.description}` : ''}`}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.72rem', padding: '2px 6px' }}
|
||||
onClick={() => onInsert(`{{${v.name}}}`)}>
|
||||
{v.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Duplicate ──────────────────────────────────────────────────────────────
|
||||
|
||||
function DuplicateForm({ source, triggers, onDone, onCancel }) {
|
||||
const [key, setKey] = useState('')
|
||||
const [name, setName] = useState(`${source.name} (copy)`)
|
||||
const [triggerId, setTriggerId] = useState(source.trigger_id || '')
|
||||
const [errors, setErrors] = useState([])
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setErrors([])
|
||||
try {
|
||||
const { template } = await api.admin.duplicateEngagementTemplate(source.id, { key, name, triggerId: triggerId || null })
|
||||
onDone(template)
|
||||
} catch (err) {
|
||||
setErrors(err.body?.errors?.length ? err.body.errors : [err.message])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="panel" style={{ padding: 22, marginBottom: 22 }} onSubmit={submit}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.98rem' }}>Duplicate “{source.name}”</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 16px', fontSize: '0.82rem' }}>
|
||||
The copy starts as a draft, so nothing sends it until you publish it.
|
||||
</p>
|
||||
{errors.map((e) => (
|
||||
<p key={e} className="sans" style={{ margin: '0 0 8px', color: '#d98b84', fontSize: '0.85rem' }}>{e}</p>
|
||||
))}
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Key</span>
|
||||
<input className="input" value={key} maxLength={96} placeholder="notify.my-event"
|
||||
onChange={(e) => setKey(e.target.value)} />
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||
How a rule points at this template. Lowercase letters, digits, dots and dashes; it cannot be
|
||||
changed afterwards.
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">Name</span>
|
||||
<input className="input" value={name} maxLength={160} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Trigger</span>
|
||||
<select className="select" value={triggerId} onChange={(e) => setTriggerId(e.target.value)}>
|
||||
<option value="">None — used by key, not by a rule</option>
|
||||
{triggers.map((t) => <option key={t.id} value={t.id}>{t.label} ({t.id})</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn btn-primary btn-sq">Duplicate</button>
|
||||
<button type="button" className="btn btn-sq" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The list ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EngagementTemplates() {
|
||||
const [templates, setTemplates] = useState([])
|
||||
const [triggers, setTriggers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [rowError, setRowError] = useState(null)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [duplicating, setDuplicating] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [t, tr] = await Promise.all([api.admin.listEngagementTemplates(), api.admin.engagementTriggers()])
|
||||
setTemplates(t.templates || [])
|
||||
setTriggers(tr.triggers || [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
await load()
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [load])
|
||||
|
||||
async function open(row) {
|
||||
setRowError(null)
|
||||
try {
|
||||
const { template } = await api.admin.getEngagementTemplate(row.id)
|
||||
setEditing(template)
|
||||
} catch (err) {
|
||||
setRowError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row) {
|
||||
if (!window.confirm(`Delete “${row.name}”?`)) return
|
||||
setRowError(null)
|
||||
try {
|
||||
await api.admin.deleteEngagementTemplate(row.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setRowError(err.body?.errors?.join(' · ') || err.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<TemplateEditor
|
||||
template={editing}
|
||||
triggers={triggers}
|
||||
onDone={load}
|
||||
onCancel={async () => { setEditing(null); await load() }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
{duplicating && (
|
||||
<DuplicateForm
|
||||
source={duplicating}
|
||||
triggers={triggers}
|
||||
onCancel={() => setDuplicating(null)}
|
||||
onDone={async (template) => { setDuplicating(null); await load(); setEditing(template) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 680 }}>
|
||||
Every message this deployment sends. The shipped ones are editable — your edits survive
|
||||
upgrades — and cannot be deleted, because the system breaks without them. To make a new
|
||||
template, duplicate one that already works.
|
||||
</p>
|
||||
|
||||
{rowError && (
|
||||
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{rowError}</p>
|
||||
)}
|
||||
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Name</th>
|
||||
<th className="adm-th">Key</th>
|
||||
<th className="adm-th">Channel</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td className="adm-td">
|
||||
{t.name}
|
||||
{t.protected && (
|
||||
<span className="pill" style={{ marginLeft: 8, fontSize: '0.68rem' }}>system</span>
|
||||
)}
|
||||
<Flags template={t} />
|
||||
</td>
|
||||
<td className="adm-td"><code style={{ fontSize: '0.8rem' }}>{t.key}</code></td>
|
||||
<td className="adm-td">{CHANNEL_LABEL[t.channel] || t.channel}</td>
|
||||
<td className="adm-td">{t.status === 'published' ? 'Published' : 'Draft'}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginRight: 6 }}
|
||||
onClick={() => open(t)}>Edit</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginRight: 6 }}
|
||||
onClick={() => setDuplicating(t)}>Duplicate</button>
|
||||
<button type="button" className="pill"
|
||||
style={{ ...DANGER, fontSize: '0.72rem', opacity: t.protected ? 0.4 : 1 }}
|
||||
disabled={t.protected}
|
||||
title={t.protected ? 'Part of the system — edit it or duplicate it' : undefined}
|
||||
onClick={() => remove(t)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The three warnings a row can carry. Each is a different fact and they are worded
|
||||
* as what an operator should DO, not as the flag name: "dormant" and "behind" mean
|
||||
* nothing to someone who has not read the design document.
|
||||
*/
|
||||
function Flags({ template }) {
|
||||
const notes = []
|
||||
if (template.dormant) {
|
||||
notes.push(`No installed module declares ${template.trigger_id} — nothing will send this.`)
|
||||
}
|
||||
if (template.triggerBehind) {
|
||||
notes.push('Its trigger has changed since this was written; check the variables still exist.')
|
||||
}
|
||||
if (template.seedBehind) {
|
||||
notes.push('A newer version of the shipped default exists. Your edits were kept, so it was not applied.')
|
||||
}
|
||||
if (!notes.length) return null
|
||||
return (
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{notes.map((n) => <div key={n}>{n}</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
client/src/routes/admin/views/EngagementTriggers.jsx
Normal file
130
client/src/routes/admin/views/EngagementTriggers.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Engagement → Triggers (ENGAGEMENT.md §4.3, Phase 5b).
|
||||
//
|
||||
// Read-only, and structurally so: **there is no table behind this screen.** A
|
||||
// trigger is DECLARED in code by core or by an installed module, so this is
|
||||
// whatever registered on the current boot. Uninstall a module and its triggers
|
||||
// stop appearing here; nothing was deleted and nothing needs to be.
|
||||
//
|
||||
// It exists because the two things it shows are otherwise invisible and both are
|
||||
// load-bearing elsewhere:
|
||||
//
|
||||
// • **The variables** are the contract a template may reference. When a rule
|
||||
// mails nothing sensible, "which variables does this event actually carry"
|
||||
// is the first question, and the answer used to live only in a module's source.
|
||||
// • **The ceiling** is the security boundary from G24 — the widest audience a
|
||||
// rule may ever give this trigger. A rule editor that offers a narrower set
|
||||
// than an operator expects is obeying a number declared here.
|
||||
|
||||
const CEILING_NOTE = {
|
||||
owner: 'only the person the event is about',
|
||||
members: 'only members of the thing it is about',
|
||||
subscribers: 'only people who opted in',
|
||||
staff: 'only staff',
|
||||
admin: 'only administrators',
|
||||
authenticated: 'any signed-in account',
|
||||
everyone: 'anyone',
|
||||
}
|
||||
|
||||
export default function EngagementTriggers() {
|
||||
const [triggers, setTriggers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
const { triggers: list } = await api.admin.engagementTriggers()
|
||||
if (alive) setTriggers(list || [])
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 680 }}>
|
||||
The events a rule can be built on, declared in code by core and by installed modules. This
|
||||
list is whatever is registered right now — it is not stored anywhere, so a module that is
|
||||
uninstalled simply stops appearing.
|
||||
</p>
|
||||
|
||||
{triggers.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Nothing is registered.</p>
|
||||
)}
|
||||
|
||||
{triggers.map((t) => (
|
||||
<div className="panel" key={t.id} style={{ padding: 18, marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h3 className="sans" style={{ margin: '0 0 2px', fontSize: '0.98rem' }}>{t.label}</h3>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||
<code>{t.id}</code> · from {t.owner} · v{t.version}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="field-label" style={{ marginBottom: 2 }}>Can reach at most</div>
|
||||
<div className="sans" style={{ fontSize: '0.84rem' }}>
|
||||
{t.ceiling}
|
||||
<span className="dim"> — {CEILING_NOTE[t.ceiling] || 'see the design document'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{t.description && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.84rem', color: 'var(--muted)' }}>
|
||||
{t.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(t.variables || []).length > 0 && (
|
||||
<table className="adm-table" style={{ marginTop: 14 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Variable</th>
|
||||
<th className="adm-th">Type</th>
|
||||
<th className="adm-th">Example</th>
|
||||
<th className="adm-th">What it is</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{t.variables.map((v) => (
|
||||
<tr key={v.name}>
|
||||
{/* `nowrap`: without it the "always set" pill wraps between its
|
||||
two words on a longer variable name, orphaning "set" on a
|
||||
line of its own and making the row read as two facts. */}
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap' }}>
|
||||
<code style={{ fontSize: '0.8rem' }}>{`{{${v.name}}}`}</code>
|
||||
{v.required && <span className="pill" style={{ marginLeft: 6, fontSize: '0.66rem' }}>always set</span>}
|
||||
</td>
|
||||
<td className="adm-td">{v.type}</td>
|
||||
<td className="adm-td" style={{ maxWidth: 260, overflowWrap: 'anywhere' }}>
|
||||
<span className="dim" style={{ fontSize: '0.8rem' }}>
|
||||
{/* A list variable's example is an array of objects; showing
|
||||
it as JSON is honest and short, and it is the shape an
|
||||
item list repeats over. */}
|
||||
{typeof v.example === 'string' ? v.example : JSON.stringify(v.example)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{v.description || ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay.jsx'
|
||||
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
|
||||
import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
@@ -21,7 +22,7 @@ function ChangeUsername({ account, onChanged }) {
|
||||
if (username.trim().length < 3) return setError('Username must be at least 3 characters.')
|
||||
setBusy(true)
|
||||
try {
|
||||
const { username: next } = await api.player.changeUsername(username.trim())
|
||||
const { username: next } = await api.changeUsername(username.trim())
|
||||
setMsg('Username updated.')
|
||||
await onChanged(next)
|
||||
} catch (err) {
|
||||
@@ -67,7 +68,7 @@ function ChangePassword({ account }) {
|
||||
if (hasPassword && !current) return setError('Enter your current password.')
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.player.changePassword(next, hasPassword ? current : undefined)
|
||||
await api.changePassword(next, hasPassword ? current : undefined)
|
||||
setMsg(hasPassword ? 'Password changed.' : 'Password set. You can now sign in with it.')
|
||||
setCurrent('')
|
||||
setNext('')
|
||||
@@ -124,7 +125,7 @@ function TwoFactor({ account, reload }) {
|
||||
async function begin() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
setSetup(await api.player.totpSetup())
|
||||
setSetup(await api.totpSetup())
|
||||
setCode('')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not start setup.')
|
||||
@@ -135,7 +136,7 @@ function TwoFactor({ account, reload }) {
|
||||
async function confirm() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
const res = await api.player.totpEnable(code.trim())
|
||||
const res = await api.totpEnable(code.trim())
|
||||
setSetup(null); setCode(''); setNewCodes(res?.recoveryCodes || null); setMsg('Two-factor is now enabled.')
|
||||
await reload()
|
||||
} catch (err) {
|
||||
@@ -147,7 +148,7 @@ function TwoFactor({ account, reload }) {
|
||||
async function disable() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
await api.player.totpDisable(code.trim())
|
||||
await api.totpDisable(code.trim())
|
||||
setCode(''); setMsg('Two-factor has been disabled.')
|
||||
await reload()
|
||||
} catch (err) {
|
||||
@@ -234,7 +235,7 @@ function LinkedAccounts() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [ids, avail] = await Promise.all([
|
||||
api.player.linkedIdentities(),
|
||||
api.myIdentities(),
|
||||
api.authProviders().catch(() => []),
|
||||
])
|
||||
setLinked(ids)
|
||||
@@ -251,7 +252,7 @@ function LinkedAccounts() {
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
try {
|
||||
await api.player.unlinkIdentity(provider)
|
||||
await api.unlinkIdentity(provider)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
@@ -397,7 +398,7 @@ export default function PlayerAccount() {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setAccount(await api.player.getAccount())
|
||||
setAccount(await api.myAccount())
|
||||
} catch {
|
||||
setError('Could not load your account.')
|
||||
} finally {
|
||||
@@ -423,6 +424,7 @@ export default function PlayerAccount() {
|
||||
{account.email ? ` · ${account.email}` : ''}
|
||||
</p>
|
||||
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
||||
<EmailAddressPanel account={account} reload={load} />
|
||||
<ChangePassword account={account} />
|
||||
<TwoFactor account={account} reload={load} />
|
||||
{account.totp_enabled && (
|
||||
|
||||
264
client/src/routes/player/PlayerInbox.jsx
Normal file
264
client/src/routes/player/PlayerInbox.jsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { notificationSettingsPath, inboxPath } from '../../lib/notificationPaths.js'
|
||||
|
||||
// The in-app inbox (ENGAGEMENT.md Phase 7), at `/account/notifications`.
|
||||
//
|
||||
// **It took that path from the preferences screen, which moved to
|
||||
// `/account/notifications/settings`.** The two are different kinds of thing —
|
||||
// one is content addressed to this person, the other is how they would like to
|
||||
// be reached — and the word "notifications" belongs to the first: it is what a
|
||||
// person means when they say it, and what the bell in the header opens. The
|
||||
// server's routes make the same split at the same place.
|
||||
//
|
||||
// Everything a row can carry is TEXT. `body` is stored as the text part of the
|
||||
// in-app template's blocks and rendered with `white-space: pre-line`, never as
|
||||
// markup; `url` is site-relative by the time it is stored, checked against the
|
||||
// same character class `pageUrlTemplate` uses. So there is no sanitizing to do
|
||||
// here — there is nothing on this screen that could be markup.
|
||||
|
||||
const PAGE = 30
|
||||
|
||||
function ago(iso) {
|
||||
const then = new Date(iso).getTime()
|
||||
if (!Number.isFinite(then)) return ''
|
||||
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
|
||||
if (secs < 60) return 'just now'
|
||||
if (secs < 3600) return `${Math.floor(secs / 60)} min ago`
|
||||
if (secs < 86400) return `${Math.floor(secs / 3600)} h ago`
|
||||
if (secs < 30 * 86400) return `${Math.floor(secs / 86400)} d ago`
|
||||
return new Date(iso).toLocaleDateString()
|
||||
}
|
||||
|
||||
function Item({ item, onOpen, onMark }) {
|
||||
const body = (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
|
||||
<strong
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.95rem',
|
||||
color: item.read ? 'var(--muted)' : 'var(--head)',
|
||||
fontWeight: item.read ? 500 : 700,
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</strong>
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>{ago(item.createdAt)}</span>
|
||||
</div>
|
||||
{item.body && (
|
||||
<p
|
||||
className="sans dim"
|
||||
style={{ margin: '6px 0 0', fontSize: '0.86rem', whiteSpace: 'pre-line' }}
|
||||
>
|
||||
{item.body}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<li
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
padding: '14px 16px',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
border: '1px solid var(--line-soft)',
|
||||
// The one visual difference between read and unread, plus the weight
|
||||
// above. A dot alone is easy to miss on a long list.
|
||||
background: item.read ? 'transparent' : 'var(--panel)',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{item.url ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(item)}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
) : (
|
||||
body
|
||||
)}
|
||||
</div>
|
||||
{!item.read && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMark(item)}
|
||||
className="sans"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.78rem',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Mark read
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PlayerInbox() {
|
||||
const [items, setItems] = useState([])
|
||||
const [unread, setUnread] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [unreadOnly, setUnreadOnly] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
|
||||
const load = useCallback(async (only) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.notifications({ limit: PAGE, unread: only })
|
||||
setItems(res.items || [])
|
||||
setHasMore(!!res.hasMore)
|
||||
setUnread(res.unread || 0)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load your notifications')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load(unreadOnly) }, [load, unreadOnly])
|
||||
|
||||
// The cursor is the last item's id, not a page number: the list gains rows at
|
||||
// the top while it is being read, and an offset under those conditions repeats
|
||||
// or skips items.
|
||||
const more = async () => {
|
||||
if (!items.length) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await api.notifications({
|
||||
limit: PAGE,
|
||||
before: items[items.length - 1].id,
|
||||
unread: unreadOnly,
|
||||
})
|
||||
setItems((list) => [...list, ...(res.items || [])])
|
||||
setHasMore(!!res.hasMore)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load more')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const mark = async (item) => {
|
||||
try {
|
||||
const res = await api.markNotificationRead(item.id)
|
||||
setUnread(res.unread ?? Math.max(0, unread - 1))
|
||||
// Filtered to unread, a marked item leaves the list; unfiltered it stays
|
||||
// and goes quiet. Either way the list matches what it says it is showing.
|
||||
setItems((list) =>
|
||||
unreadOnly
|
||||
? list.filter((i) => i.id !== item.id)
|
||||
: list.map((i) => (i.id === item.id ? { ...i, read: true } : i)),
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not mark it read')
|
||||
}
|
||||
}
|
||||
|
||||
const open = async (item) => {
|
||||
if (!item.read) await mark(item)
|
||||
if (item.url) navigate(item.url)
|
||||
}
|
||||
|
||||
const markAll = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.markAllNotificationsRead()
|
||||
setUnread(0)
|
||||
setItems((list) => (unreadOnly ? [] : list.map((i) => ({ ...i, read: true }))))
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not mark them read')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading label="Loading your notifications…" />
|
||||
if (error && !items.length) return <ErrorState message={error} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||
{unread > 0 ? `${unread} unread` : 'Everything is read.'}{' '}
|
||||
<Link to={notificationSettingsPath(user)} className="dim">
|
||||
Notification settings
|
||||
</Link>
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
onClick={() => setUnreadOnly((v) => !v)}
|
||||
style={unreadOnly ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : {}}
|
||||
>
|
||||
{unreadOnly ? 'Showing unread' : 'Show unread only'}
|
||||
</button>
|
||||
<button type="button" className="pill" onClick={markAll} disabled={busy || unread === 0}>
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem' }}>
|
||||
{unreadOnly
|
||||
? 'Nothing unread.'
|
||||
: 'Nothing here yet. Anything the shard or your guilds want to tell you will show up on this page.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{items.map((item) => (
|
||||
<Item key={item.id} item={item} onOpen={open} onMark={mark} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<button type="button" className="pill" onClick={more} disabled={busy} style={{ marginTop: 16 }}>
|
||||
{busy ? 'Loading…' : 'Load older'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { inboxPath } from '../../lib/notificationPaths.js'
|
||||
|
||||
// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6).
|
||||
// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6; the
|
||||
// per-channel matrix is ENGAGEMENT.md Phase 3, surfaced in Phase 7).
|
||||
//
|
||||
// **It moved to `/account/notifications/settings` in Phase 7**, because the
|
||||
// inbox took the plain path. See `PlayerInbox.jsx`.
|
||||
//
|
||||
// **This screen did not exist before phase 6, and that was the phase's first
|
||||
// finding.** §6.3 says the per-Team mute list is "surfaced under the existing
|
||||
@@ -18,6 +25,14 @@ import { api } from '../../api/client.js'
|
||||
// thing to be told about, then which Teams, then whether any of it should reach a
|
||||
// mailbox.
|
||||
|
||||
// The three modes a per-channel preference can take, labelled for a person. The
|
||||
// set a given channel actually offers comes from its `supportsDigest` flag.
|
||||
const MODES = [
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'instant', label: 'As it happens' },
|
||||
{ value: 'digest', label: 'Daily digest' },
|
||||
]
|
||||
|
||||
const EMAIL_MODES = [
|
||||
{ value: 'off', label: 'No email' },
|
||||
{ value: 'digest', label: 'Daily digest' },
|
||||
@@ -48,48 +63,125 @@ function Note({ msg, error }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── What to be told about ──────────────────────────────────────────────────
|
||||
// ── What to be told about, and how ─────────────────────────────────────────
|
||||
//
|
||||
// **This replaced the push-only checkbox list, and it is a strict superset of
|
||||
// it.** `GET /auth/me/notifications/channels` returns every subscribable id —
|
||||
// every push stream and every event trigger, one namespace (§7.2) — with the
|
||||
// EFFECTIVE mode on each channel that applies. A trigger with nothing
|
||||
// registered to push it simply has no push cell; core does not have to explain
|
||||
// which kind of id a row is, and neither does a reader.
|
||||
//
|
||||
// The old whole-set endpoints are untouched and are now this surface's push
|
||||
// projection: the shipped Android app keeps its wire shape, and a `push` entry
|
||||
// written here is mirrored back into `notification_subscriptions` server-side.
|
||||
//
|
||||
// The update is SPARSE: only the cells that changed are sent. That is what lets
|
||||
// this screen manage three channels without a whole-set PUT that could clobber
|
||||
// a preference a newer client set.
|
||||
|
||||
function Streams({ streams, subscribed, onSave, busy, msg, error }) {
|
||||
const [set, setSet] = useState(() => new Set(subscribed))
|
||||
useEffect(() => { setSet(new Set(subscribed)) }, [subscribed])
|
||||
function Channels({ channels, items, onSave, busy, msg, error }) {
|
||||
const [edits, setEdits] = useState({})
|
||||
useEffect(() => setEdits({}), [items])
|
||||
|
||||
const toggle = (id) => {
|
||||
const next = new Set(set)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
setSet(next)
|
||||
const key = (id, channel) => `${id}|${channel}`
|
||||
const modeOf = (item, channel) => edits[key(item.id, channel)] ?? item.modes[channel]
|
||||
const set = (id, channel, mode) => setEdits((e) => ({ ...e, [key(id, channel)]: mode }))
|
||||
|
||||
// A channel that supports digest offers three modes; one that does not offers
|
||||
// two. Read off the registry rather than hardcoded, so a channel added later
|
||||
// shows the right options without touching this file.
|
||||
const modesFor = (c) => (c.supportsDigest ? MODES : MODES.filter((m) => m.value !== 'digest'))
|
||||
|
||||
const changed = Object.entries(edits).filter(([k, mode]) => {
|
||||
const [id, channel] = k.split('|')
|
||||
const item = items.find((i) => i.id === id)
|
||||
return item && item.modes[channel] !== mode
|
||||
})
|
||||
|
||||
const save = () =>
|
||||
onSave(
|
||||
changed.map(([k, mode]) => {
|
||||
const [id, channel] = k.split('|')
|
||||
return { id, channel, mode }
|
||||
}),
|
||||
)
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Section title="What to notify me about">
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem', margin: 0 }}>
|
||||
There is nothing to configure yet.
|
||||
</p>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
const team = streams.filter((s) => isTeamStream(s.id))
|
||||
const rest = streams.filter((s) => !isTeamStream(s.id))
|
||||
const team = items.filter((i) => isTeamStream(i.id))
|
||||
const rest = items.filter((i) => !isTeamStream(i.id))
|
||||
|
||||
const row = (s) => (
|
||||
<label key={s.id} className="sans" style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: '0.92rem' }}>
|
||||
<input type="checkbox" checked={set.has(s.id)} onChange={() => toggle(s.id)} style={{ marginTop: 3 }} />
|
||||
<span>
|
||||
<span style={{ color: 'var(--ink)' }}>{s.label}</span>
|
||||
{s.description && <span className="dim" style={{ display: 'block', fontSize: '0.82rem' }}>{s.description}</span>}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
const rows = (list) =>
|
||||
list.map((item) => (
|
||||
<tr key={item.id} style={{ borderTop: '1px solid var(--line-soft)' }}>
|
||||
<td className="sans" style={{ padding: '10px', color: 'var(--ink)' }}>
|
||||
{item.label}
|
||||
{item.description && (
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.8rem' }}>{item.description}</span>
|
||||
)}
|
||||
</td>
|
||||
{channels.map((c) => (
|
||||
<td key={c.id} style={{ padding: '10px' }}>
|
||||
{item.channels.includes(c.id) ? (
|
||||
<select
|
||||
className="input"
|
||||
aria-label={`${item.label} — ${c.label}`}
|
||||
value={modeOf(item, c.id)}
|
||||
onChange={(e) => set(item.id, c.id, e.target.value)}
|
||||
style={{ fontSize: '0.86rem' }}
|
||||
>
|
||||
{modesFor(c).map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
// Not "off" — a dash. Nothing is registered to push this id, so
|
||||
// there is no preference to hold, and an `off` select would invite
|
||||
// somebody to switch on a channel that has no sender behind it.
|
||||
<span className="dim" style={{ fontSize: '0.86rem' }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="What to notify me about"
|
||||
hint="Applies to every device you have signed in on. Notifications are delivered to the app; the website itself does not pop anything up."
|
||||
hint="Applies to every device you have signed in on. On the site means an item in your notification inbox; push wakes the app, which then fetches the content."
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 12 }}>{rest.map(row)}</div>
|
||||
{team.length > 0 && (
|
||||
<>
|
||||
<h3 className="sans dim" style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '20px 0 10px' }}>
|
||||
Teams
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gap: 12 }}>{team.map(row)}</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
<th style={{ padding: '8px 10px' }}>Notification</th>
|
||||
{channels.map((c) => (
|
||||
<th key={c.id} style={{ padding: '8px 10px' }} title={c.description || undefined}>{c.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows(rest)}
|
||||
{team.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={channels.length + 1} className="sans dim" style={{ padding: '18px 10px 6px', fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
Teams — set site-wide here, then per team below
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows(team)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave([...set])}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy || changed.length === 0} onClick={save}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -176,27 +268,29 @@ function Teams({ teams, onSave, busy, msg, error }) {
|
||||
// ── Page ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PlayerNotifications() {
|
||||
const { user } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [streams, setStreams] = useState([])
|
||||
const [subscribed, setSubscribed] = useState([])
|
||||
const [channels, setChannels] = useState([])
|
||||
const [items, setItems] = useState([])
|
||||
const [teams, setTeams] = useState([])
|
||||
const [saving, setSaving] = useState({ streams: false, teams: false })
|
||||
const [notes, setNotes] = useState({ streams: '', teams: '', streamsError: '', teamsError: '' })
|
||||
const [saving, setSaving] = useState({ channels: false, teams: false })
|
||||
const [notes, setNotes] = useState({ channels: '', teams: '', channelsError: '', teamsError: '' })
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Three reads in parallel: the catalog is boot-fixed, the subscriptions and
|
||||
// the Team list are this user's. None depends on another.
|
||||
const [cat, subs, prefs] = await Promise.all([
|
||||
api.notificationStreams(),
|
||||
api.notificationSubscriptions(),
|
||||
// Two reads in parallel, where there used to be three: the per-channel
|
||||
// surface already carries the catalog and this user's effective modes, so
|
||||
// the streams+subscriptions pair it replaced is one request fewer as well
|
||||
// as one concept fewer.
|
||||
const [prefs, teamPrefs] = await Promise.all([
|
||||
api.notificationChannelPrefs(),
|
||||
api.teamNotificationPrefs(),
|
||||
])
|
||||
setStreams(cat.streams || [])
|
||||
setSubscribed(subs.streams || [])
|
||||
setTeams(prefs.teams || [])
|
||||
setChannels(prefs.channels || [])
|
||||
setItems(prefs.items || [])
|
||||
setTeams(teamPrefs.teams || [])
|
||||
setError('')
|
||||
} catch {
|
||||
setError('Could not load your notification settings.')
|
||||
@@ -207,17 +301,23 @@ export default function PlayerNotifications() {
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const saveStreams = useCallback(async (ids) => {
|
||||
setSaving((s) => ({ ...s, streams: true }))
|
||||
setNotes((n) => ({ ...n, streams: '', streamsError: '' }))
|
||||
const saveChannels = useCallback(async (prefs) => {
|
||||
if (prefs.length === 0) return
|
||||
setSaving((s) => ({ ...s, channels: true }))
|
||||
setNotes((n) => ({ ...n, channels: '', channelsError: '' }))
|
||||
try {
|
||||
const { streams: stored } = await api.setNotificationSubscriptions(ids)
|
||||
setSubscribed(stored || [])
|
||||
setNotes((n) => ({ ...n, streams: 'Saved.' }))
|
||||
// The endpoint echoes the FULL stored state back, not just what was sent —
|
||||
// so an entry it dropped (an unknown id, a channel that does not apply, a
|
||||
// mode that channel will not take) is visible here as a cell that did not
|
||||
// move, rather than as a screen that claims a save it did not make.
|
||||
const stored = await api.setNotificationChannelPrefs(prefs)
|
||||
setChannels(stored.channels || [])
|
||||
setItems(stored.items || [])
|
||||
setNotes((n) => ({ ...n, channels: 'Saved.' }))
|
||||
} catch {
|
||||
setNotes((n) => ({ ...n, streamsError: 'Could not save that.' }))
|
||||
setNotes((n) => ({ ...n, channelsError: 'Could not save that.' }))
|
||||
} finally {
|
||||
setSaving((s) => ({ ...s, streams: false }))
|
||||
setSaving((s) => ({ ...s, channels: false }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -245,16 +345,17 @@ export default function PlayerNotifications() {
|
||||
return (
|
||||
<div>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
||||
Choose what you are told about, and how. Nothing here is on by default except team
|
||||
notifications to the app, which you can mute per team below.
|
||||
Choose what you are told about, and how. Email and push are off until you switch them on;
|
||||
items on the site go to your <Link to={inboxPath(user)}>notification inbox</Link>,
|
||||
which you can turn off here per notification.
|
||||
</p>
|
||||
<Streams
|
||||
streams={streams}
|
||||
subscribed={subscribed}
|
||||
onSave={saveStreams}
|
||||
busy={saving.streams}
|
||||
msg={notes.streams}
|
||||
error={notes.streamsError}
|
||||
<Channels
|
||||
channels={channels}
|
||||
items={items}
|
||||
onSave={saveChannels}
|
||||
busy={saving.channels}
|
||||
msg={notes.channels}
|
||||
error={notes.channelsError}
|
||||
/>
|
||||
<Teams
|
||||
teams={teams}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
|
||||
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import NotificationBell from '../../components/NotificationBell.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
||||
@@ -36,6 +37,9 @@ function Icon({ children, size = 16 }) {
|
||||
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
|
||||
const 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>
|
||||
// The settings row's own icon: a bell would make the two rows read as the same
|
||||
// destination twice, which is exactly the confusion the split was meant to end.
|
||||
const IconBellGear = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h11" /><circle cx="18" cy="18" r="3" /><path d="M18 14v1M18 21v1M14 18h1M21 18h1" /></Icon>
|
||||
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here;
|
||||
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
|
||||
@@ -48,7 +52,8 @@ const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-
|
||||
// with `order: 0`.
|
||||
export const NAV = [
|
||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||
{ to: '/account/notifications', label: 'Notifications', icon: IconBell },
|
||||
{ to: '/account/notifications', label: 'Notifications', end: true, icon: IconBell },
|
||||
{ to: '/account/notifications/settings', label: 'Notification settings', icon: IconBellGear },
|
||||
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
||||
]
|
||||
|
||||
@@ -59,6 +64,7 @@ const TITLES = {
|
||||
'/account': 'Account',
|
||||
'/account/appeals': 'Appeals',
|
||||
'/account/notifications': 'Notifications',
|
||||
'/account/notifications/settings': 'Notification settings',
|
||||
}
|
||||
|
||||
function moduleTitle(baseNav, pathname) {
|
||||
@@ -186,9 +192,12 @@ export default function PlayerPortalLayout() {
|
||||
<h1 className="display" style={{ margin: 0, fontSize: '1.5rem', color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h1>
|
||||
<a href="/" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.84rem', fontFamily: 'var(--sans)' }}>
|
||||
← Site
|
||||
</a>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<NotificationBell />
|
||||
<a href="/" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.84rem', fontFamily: 'var(--sans)' }}>
|
||||
← Site
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: 900, width: '100%' }}>
|
||||
|
||||
@@ -54,14 +54,14 @@ export default function Unsubscribe() {
|
||||
<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>.
|
||||
<Link to="/account/notifications/settings">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>.
|
||||
setting yourself under <Link to="/account/notifications/settings">notification settings</Link>.
|
||||
</p>
|
||||
)}
|
||||
</PublicLayout>
|
||||
|
||||
166
client/src/routes/player/VerifyEmail.jsx
Normal file
166
client/src/routes/player/VerifyEmail.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { api } from '../../api/client.js'
|
||||
import PlayerShell from './PlayerShell.jsx'
|
||||
|
||||
// Public, token-gated confirmation page (/account/verify-email/:token).
|
||||
//
|
||||
// Unauthenticated on purpose: the link arrives in a mailbox and is routinely
|
||||
// opened on a device with no session. That is safe because the token IS the
|
||||
// proof — opening it installs an address on the account it was minted for and
|
||||
// does nothing else. No session is issued here, deliberately: proving control of
|
||||
// a mailbox is not proving control of an account.
|
||||
//
|
||||
// Every failure the server can have — expired, already used, superseded by a
|
||||
// later request, or an address another account confirmed first — comes back as
|
||||
// the same 404. That is not laziness on the server's part; distinguishing them
|
||||
// would let anyone test which addresses have accounts. So this page says the same
|
||||
// thing for all of them, and must keep doing so.
|
||||
export default function VerifyEmail() {
|
||||
const { token } = useParams()
|
||||
|
||||
const [link, setLink] = useState(null) // { username, email } once validated
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api
|
||||
.lookupEmailVerification(token)
|
||||
.then((r) => active && setLink(r || {}))
|
||||
.catch(
|
||||
(err) =>
|
||||
active &&
|
||||
setLoadErr(
|
||||
err.status === 404
|
||||
? 'This confirmation link is invalid or has expired.'
|
||||
: 'Could not load this confirmation link.',
|
||||
),
|
||||
)
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [token])
|
||||
|
||||
async function onConfirm() {
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.confirmEmailVerification(token)
|
||||
setDone(true)
|
||||
} catch (err) {
|
||||
if (err.status === 404) setError('This confirmation link is no longer usable. Request a new one from your account page.')
|
||||
else if (err.status === 429) setError('Too many attempts. Please try again in a little while.')
|
||||
else setError('Could not confirm your address right now. Please try again later.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invalid link ───────────────────────────────────────────────────────────
|
||||
if (loadErr) {
|
||||
return (
|
||||
<PlayerShell subtitle="Confirm your email">
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>
|
||||
{loadErr}
|
||||
</p>
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
|
||||
<Link to="/account" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Go to your account
|
||||
</Link>
|
||||
</p>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
if (link === null) {
|
||||
return (
|
||||
<PlayerShell subtitle="Confirm your email">
|
||||
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}>
|
||||
<span className="spin" />
|
||||
</div>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Done ───────────────────────────────────────────────────────────────────
|
||||
if (done) {
|
||||
return (
|
||||
<PlayerShell subtitle="Email confirmed">
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>
|
||||
{link.email ? (
|
||||
<>
|
||||
<strong style={{ color: 'var(--head)' }}>{link.email}</strong> is now the address for
|
||||
{link.username ? (
|
||||
<>
|
||||
{' '}
|
||||
<strong style={{ color: 'var(--head)' }}>{link.username}</strong>
|
||||
</>
|
||||
) : (
|
||||
' your account'
|
||||
)}
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
'Your email address has been confirmed.'
|
||||
)}
|
||||
</p>
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', fontSize: '0.85rem', color: 'var(--dim)' }}>
|
||||
You have not been signed in — confirming an address does not sign you in.
|
||||
</p>
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
|
||||
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Confirm ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A button rather than confirming on load. A mail client or scanner that
|
||||
// pre-fetches links would otherwise spend the token before the person ever saw
|
||||
// it, and this token is single-use.
|
||||
return (
|
||||
<PlayerShell subtitle="Confirm your email">
|
||||
<p
|
||||
className="sans"
|
||||
style={{ marginTop: 0, marginBottom: 20, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}
|
||||
>
|
||||
Confirm that{' '}
|
||||
{link.email ? <strong style={{ color: 'var(--head)' }}>{link.email}</strong> : 'this address'} should be
|
||||
the contact and account-recovery address for
|
||||
{link.username ? (
|
||||
<>
|
||||
{' '}
|
||||
<strong style={{ color: 'var(--head)' }}>{link.username}</strong>
|
||||
</>
|
||||
) : (
|
||||
' this account'
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
className="btn btn-primary"
|
||||
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
|
||||
>
|
||||
{busy ? 'Confirming…' : 'Confirm this address'}
|
||||
</button>
|
||||
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', fontSize: '0.82rem', color: 'var(--dim)' }}>
|
||||
If you did not ask for this, close this page. Nothing changes and no account of yours is affected.
|
||||
</p>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
154
client/test/emailTemplates.test.js
Normal file
154
client/test/emailTemplates.test.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import { test, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
RESERVED_KEYS,
|
||||
registerEmailBlock,
|
||||
getEmailBlock,
|
||||
listEmailBlocks,
|
||||
newEmailBlock,
|
||||
} from '../src/emailBlocks/registry.js'
|
||||
|
||||
// Engagement Phase 5b — the client half of the template editor.
|
||||
//
|
||||
// Two kinds of test, and the second kind is the one worth explaining.
|
||||
//
|
||||
// `registry.js` is plain `.js` and imports nothing, so it is exercised directly.
|
||||
// `types.jsx` and `EngagementTemplates.jsx` cannot be: this runner has no JSX
|
||||
// transform and no DOM, the same limit `moduleRegistry.test.js` documents. So the
|
||||
// properties that live in those files are asserted **against their source text**.
|
||||
//
|
||||
// That is a weaker test than executing them, and it is used for exactly two things
|
||||
// where a weak test still beats none:
|
||||
//
|
||||
// • **The preview sandbox.** `sandbox=""` with no `allow-scripts` is the reason
|
||||
// operator-authored HTML cannot run under this site's origin. It is one
|
||||
// attribute, on one element, and it is precisely the sort of thing someone
|
||||
// removes to debug a rendering problem and does not put back. A source
|
||||
// assertion catches that in review; nothing else here would.
|
||||
// • **Registry drift.** Every `email.*` type this client offers must exist in
|
||||
// the server registry with the same version, because the server validates
|
||||
// against its own and a drifted client produces a refused save with no
|
||||
// explanation on screen. Reading both trees is the only way to check a
|
||||
// pairing that spans a process boundary.
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const read = (rel) => fs.readFileSync(path.join(here, '..', rel), 'utf8')
|
||||
|
||||
// The registry is module state; each test starts from a known entry.
|
||||
beforeEach(() => {
|
||||
if (!getEmailBlock('email.test')) {
|
||||
registerEmailBlock({
|
||||
type: 'email.test',
|
||||
version: 2,
|
||||
label: 'Test block',
|
||||
defaults: () => ({ text: 'hi' }),
|
||||
editor: () => null,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── The registry ───────────────────────────────────────────────────────────
|
||||
|
||||
test('a definition must be namespaced "email."', () => {
|
||||
assert.throws(() => registerEmailBlock({ type: 'heading' }), /namespaced/)
|
||||
assert.throws(() => registerEmailBlock({}), /namespaced/)
|
||||
})
|
||||
|
||||
test('a duplicate type is a programmer error, caught at import', () => {
|
||||
assert.throws(() => registerEmailBlock({ type: 'email.test' }), /already registered/)
|
||||
})
|
||||
|
||||
test('a new block carries the envelope the server expects, and a unique id', () => {
|
||||
const a = newEmailBlock('email.test')
|
||||
const b = newEmailBlock('email.test')
|
||||
assert.deepEqual(Object.keys(a).sort(), [...RESERVED_KEYS].sort())
|
||||
assert.equal(a.type, 'email.test')
|
||||
assert.equal(a.version, 2)
|
||||
assert.deepEqual(a.props, { text: 'hi' })
|
||||
// Ids are unique across a whole document. A counter would re-issue an id after
|
||||
// a delete and the save would be refused for a reason nothing on screen explains.
|
||||
assert.notEqual(a.id, b.id)
|
||||
})
|
||||
|
||||
test('an unknown type yields nothing rather than a half-built block', () => {
|
||||
assert.equal(newEmailBlock('email.nope'), null)
|
||||
assert.equal(getEmailBlock('email.nope'), null)
|
||||
})
|
||||
|
||||
// ── The sandbox: §4.6.2's security posture, as an attribute ────────────────
|
||||
|
||||
test('the preview frame is sandboxed with no allow-scripts', () => {
|
||||
const source = read('src/routes/admin/views/EngagementTemplates.jsx')
|
||||
|
||||
// It renders in an iframe at all — not into the page.
|
||||
assert.match(source, /<iframe/)
|
||||
|
||||
// Read the ATTRIBUTE, not the file. The first version of this test searched the
|
||||
// whole source for "allow-scripts" and failed on the comment above the iframe
|
||||
// explaining that there is no allow-scripts — a check that a correct file fails
|
||||
// is worse than no check, because the fix is to delete the explanation.
|
||||
const sandboxes = [...source.matchAll(/sandbox=(?:"([^"]*)"|\{([^}]*)\})/g)].map((m) => m[1] ?? m[2])
|
||||
assert.equal(sandboxes.length, 1, 'expected exactly one sandboxed frame')
|
||||
// Empty: every restriction on, nothing granted back.
|
||||
assert.equal(sandboxes[0], '')
|
||||
// The two grants that would undo it, whatever else were listed.
|
||||
assert.doesNotMatch(sandboxes[0], /allow-scripts/)
|
||||
assert.doesNotMatch(sandboxes[0], /allow-same-origin/)
|
||||
|
||||
// And no iframe without one at all.
|
||||
assert.equal((source.match(/<iframe/g) || []).length, sandboxes.length)
|
||||
|
||||
// From srcDoc — an opaque origin — rather than a src pointing at this site.
|
||||
assert.match(source, /srcDoc=/)
|
||||
})
|
||||
|
||||
test('the preview HTML is never injected into this document', () => {
|
||||
const source = read('src/routes/admin/views/EngagementTemplates.jsx')
|
||||
// The one API that would undo all of the above in a single line.
|
||||
assert.doesNotMatch(source, /dangerouslySetInnerHTML/)
|
||||
})
|
||||
|
||||
// ── Drift between the two registries ───────────────────────────────────────
|
||||
|
||||
test('every client email block pairs with a server definition at the same version', () => {
|
||||
const clientSource = read('src/emailBlocks/types.jsx')
|
||||
const clientTypes = [...clientSource.matchAll(/type:\s*'(email\.[A-Za-z]+)',\s*\n\s*version:\s*(\d+)/g)].map(
|
||||
(m) => [m[1], Number(m[2])],
|
||||
)
|
||||
assert.ok(clientTypes.length >= 6, 'expected the six block definitions to be found')
|
||||
|
||||
const serverDir = path.join(here, '..', '..', 'server', 'src', 'emailBlocks', 'types')
|
||||
const serverTypes = new Map()
|
||||
for (const file of fs.readdirSync(serverDir)) {
|
||||
const src = fs.readFileSync(path.join(serverDir, file), 'utf8')
|
||||
const type = src.match(/type:\s*'(email\.[A-Za-z]+)'/)
|
||||
const version = src.match(/\n\s*version:\s*(\d+)/)
|
||||
if (type) serverTypes.set(type[1], version ? Number(version[1]) : 1)
|
||||
}
|
||||
|
||||
for (const [type, version] of clientTypes) {
|
||||
assert.ok(serverTypes.has(type), `${type} has no server definition`)
|
||||
assert.equal(serverTypes.get(type), version, `${type} version differs between client and server`)
|
||||
}
|
||||
// And the other direction: a server block with no authoring form is a block an
|
||||
// operator can be sent a template containing and cannot edit.
|
||||
for (const type of serverTypes.keys()) {
|
||||
assert.ok(
|
||||
clientTypes.some(([t]) => t === type),
|
||||
`${type} exists on the server but has no editor in this client`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('no client email block declares a React renderer', () => {
|
||||
// The structural claim in registry.js's header. A `component` here would be a
|
||||
// second renderer for a body the server produces, and the two would agree only
|
||||
// until the first Outlook fix.
|
||||
const clientSource = read('src/emailBlocks/types.jsx')
|
||||
assert.doesNotMatch(clientSource, /\n\s*component:/)
|
||||
assert.ok(listEmailBlocks().every((d) => !('component' in d)))
|
||||
})
|
||||
307
client/test/engagementRules.test.js
Normal file
307
client/test/engagementRules.test.js
Normal file
@@ -0,0 +1,307 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
formFromRule,
|
||||
ruleToPayload,
|
||||
audienceChoicesFor,
|
||||
segmentChoicesFor,
|
||||
describeReach,
|
||||
describeRule,
|
||||
describeExpression,
|
||||
notPlacementError,
|
||||
audienceWarning,
|
||||
operatorWords,
|
||||
conditionRowsFrom,
|
||||
conditionsFromRows,
|
||||
operatorsForType,
|
||||
coerceLiteral,
|
||||
humanSeconds,
|
||||
} from '../src/lib/engagementRules.js'
|
||||
|
||||
// lib/engagementRules.js — what the two Engagement screens say and what they let
|
||||
// an operator pick (ENGAGEMENT.md Phase 4b).
|
||||
//
|
||||
// None of this is a boundary: the server's `engagementRules.model` decides what
|
||||
// may be saved and the engine re-checks the audience ceiling at send time. What
|
||||
// is tested here is the part that would be wrong SILENTLY — a form that sends a
|
||||
// string where the trigger declared an int, a composer that flattens a nested
|
||||
// condition into one that fires on different events, an editor that offers an
|
||||
// audience the save is going to refuse.
|
||||
|
||||
const CEILINGS = [
|
||||
{ id: 'everyone', label: 'Everyone', permits: ['everyone', 'authenticated', 'subscribers', 'members', 'staff', 'owner'] },
|
||||
{ id: 'authenticated', label: 'Signed-in users', permits: ['authenticated', 'subscribers', 'members', 'staff', 'owner'] },
|
||||
{ id: 'subscribers', label: 'Subscribers', permits: ['subscribers'] },
|
||||
{ id: 'members', label: 'A module list', permits: ['members'] },
|
||||
{ id: 'staff', label: 'Staff', permits: ['staff'] },
|
||||
{ id: 'owner', label: 'The person it is about', permits: ['owner'] },
|
||||
]
|
||||
|
||||
const TRIGGER = {
|
||||
id: 'uo.house.idoc_warning',
|
||||
label: 'House approaching collapse',
|
||||
ceiling: 'owner',
|
||||
audience: 'owner',
|
||||
subjectKey: 'house',
|
||||
variables: [
|
||||
{ name: 'house', type: 'string', required: true },
|
||||
{ name: 'daysLeft', type: 'int', required: false },
|
||||
{ name: 'insured', type: 'boolean', required: false },
|
||||
],
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
{ cmp: 'eq', label: 'is', types: ['string', 'int', 'boolean'], arity: 1 },
|
||||
{ cmp: 'gt', label: 'is greater than', types: ['int'], arity: 1 },
|
||||
{ cmp: 'in', label: 'is one of', types: ['string', 'int'], arity: 'list' },
|
||||
{ cmp: 'present', label: 'is present', types: ['string', 'int', 'boolean'], arity: 0 },
|
||||
]
|
||||
|
||||
const row = (over = {}) => ({
|
||||
id: 3,
|
||||
trigger_id: 'uo.house.idoc_warning',
|
||||
name: 'IDOC warning',
|
||||
enabled: 1,
|
||||
audience: 'owner',
|
||||
audience_segment_id: null,
|
||||
channels: ['email'],
|
||||
template_keys: { email: 'idoc-warning' },
|
||||
conditions: null,
|
||||
cooldown_seconds: 86400,
|
||||
delay_seconds: 0,
|
||||
cancel_on: [],
|
||||
max_sends_per_hour: 100,
|
||||
...over,
|
||||
})
|
||||
|
||||
// ── The form round trip ────────────────────────────────────────────────────
|
||||
|
||||
test('a rule row round-trips through the form without changing what it means', () => {
|
||||
const payload = ruleToPayload(formFromRule(row()))
|
||||
|
||||
assert.equal(payload.triggerId, 'uo.house.idoc_warning')
|
||||
assert.equal(payload.enabled, true)
|
||||
assert.deepEqual(payload.channels, ['email'])
|
||||
assert.deepEqual(payload.templateKeys, { email: 'idoc-warning' })
|
||||
assert.equal(payload.cooldownSeconds, 86400)
|
||||
assert.equal(payload.maxSendsPerHour, 100)
|
||||
})
|
||||
|
||||
test('unticking a channel drops its template key, rather than sending one the server refuses', () => {
|
||||
const form = formFromRule(row({ channels: ['email', 'push'], template_keys: { email: 'a', push: 'b' } }))
|
||||
form.channels = ['email']
|
||||
|
||||
const payload = ruleToPayload(form)
|
||||
|
||||
// The server refuses `templateKeys` naming a channel the rule does not have.
|
||||
// Leaving it in would produce an error about a field the operator cannot see.
|
||||
assert.deepEqual(payload.templateKeys, { email: 'a' })
|
||||
})
|
||||
|
||||
// ── The audience the editor may offer ──────────────────────────────────────
|
||||
|
||||
test('the editor offers only what the trigger ceiling permits', () => {
|
||||
const choices = audienceChoicesFor(TRIGGER, CEILINGS).map((c) => c.id)
|
||||
assert.deepEqual(choices, ['owner'])
|
||||
})
|
||||
|
||||
test('a wider trigger offers more, in lattice order', () => {
|
||||
const choices = audienceChoicesFor({ ...TRIGGER, ceiling: 'authenticated' }, CEILINGS).map((c) => c.id)
|
||||
assert.deepEqual(choices, ['authenticated', 'subscribers', 'members', 'staff', 'owner'])
|
||||
})
|
||||
|
||||
test('an unknown trigger offers nothing — failing closed, like the server', () => {
|
||||
// This is a dormant rule, whose module has been uninstalled. Offering the full
|
||||
// vocabulary would be the widening the whole ceiling design exists to prevent.
|
||||
assert.deepEqual(audienceChoicesFor({ ...TRIGGER, ceiling: 'nonsense' }, CEILINGS), [])
|
||||
assert.deepEqual(audienceChoicesFor(null, CEILINGS), [])
|
||||
})
|
||||
|
||||
test('segments are filtered by their STORED ceiling, not re-derived', () => {
|
||||
const segments = [
|
||||
{ id: 1, name: 'Governors', ceiling: 'members' },
|
||||
{ id: 2, name: 'Watchers', ceiling: 'authenticated' },
|
||||
]
|
||||
const wide = segmentChoicesFor({ ...TRIGGER, ceiling: 'authenticated' }, CEILINGS, segments)
|
||||
assert.deepEqual(wide.map((s) => s.id), [1, 2])
|
||||
|
||||
const narrow = segmentChoicesFor({ ...TRIGGER, ceiling: 'members' }, CEILINGS, segments)
|
||||
assert.deepEqual(narrow.map((s) => s.id), [1])
|
||||
})
|
||||
|
||||
// ── The reach preview ──────────────────────────────────────────────────────
|
||||
|
||||
test('a capped count reads as a floor, never as a total', () => {
|
||||
const said = describeReach({ count: 5000, capped: true, dormant: false, reason: null, permitted: true })
|
||||
assert.match(said, /At least 5000/)
|
||||
})
|
||||
|
||||
test('a count the trigger would refuse says so, instead of looking healthy', () => {
|
||||
const said = describeReach({ count: 12, capped: false, dormant: false, reason: null, permitted: false })
|
||||
assert.match(said, /will be refused/)
|
||||
})
|
||||
|
||||
test('a dormant segment says why, rather than reading as "nobody"', () => {
|
||||
const said = describeReach({ count: 0, dormant: true, reason: 'audience segment is dormant' })
|
||||
assert.match(said, /dormant/)
|
||||
})
|
||||
|
||||
test('an owner audience carries its reason forward', () => {
|
||||
const said = describeReach({ count: 0, dormant: false, reason: 'event carries no ownerUserId', permitted: true })
|
||||
assert.match(said, /ownerUserId/)
|
||||
})
|
||||
|
||||
// ── Conditions ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('operators narrow to the variable type that was picked', () => {
|
||||
assert.deepEqual(operatorsForType(OPERATORS, 'boolean').map((o) => o.cmp), ['eq', 'present'])
|
||||
assert.deepEqual(operatorsForType(OPERATORS, 'int').map((o) => o.cmp), ['eq', 'gt', 'in', 'present'])
|
||||
})
|
||||
|
||||
test('a literal is coerced to the type the trigger DECLARED', () => {
|
||||
// Every value in an HTML input is a string, and `{ cmp: 'gt', value: "5" }`
|
||||
// against an int variable is refused by the server — rightly, because a
|
||||
// comparison between a number and a string quietly never matches.
|
||||
const built = conditionsFromRows('and', [{ variable: 'daysLeft', cmp: 'gt', value: '5' }], TRIGGER.variables)
|
||||
assert.deepEqual(built, { variable: 'daysLeft', cmp: 'gt', value: 5 })
|
||||
})
|
||||
|
||||
test('a value that does not parse is passed through, so the server names the field', () => {
|
||||
// NOT NaN, and not 0: a rule that saves cleanly having silently compared
|
||||
// against a number nobody typed is worse than a refusal that says which
|
||||
// variable it was.
|
||||
assert.equal(coerceLiteral('int', 'soon'), 'soon')
|
||||
assert.equal(coerceLiteral('boolean', 'yes'), 'yes')
|
||||
assert.equal(coerceLiteral('boolean', 'true'), true)
|
||||
assert.equal(coerceLiteral('float', '1.5'), 1.5)
|
||||
})
|
||||
|
||||
test('a list operator splits on commas and types each item', () => {
|
||||
const built = conditionsFromRows('and', [{ variable: 'daysLeft', cmp: 'in', value: '1, 2, 3' }], TRIGGER.variables)
|
||||
assert.deepEqual(built.value, [1, 2, 3])
|
||||
})
|
||||
|
||||
test('present and absent carry no value at all', () => {
|
||||
const built = conditionsFromRows('and', [{ variable: 'house', cmp: 'present', value: 'ignored' }], TRIGGER.variables)
|
||||
assert.deepEqual(built, { variable: 'house', cmp: 'present' })
|
||||
})
|
||||
|
||||
test('no rows means no conditions — not an empty group that matches nothing', () => {
|
||||
assert.equal(conditionsFromRows('and', [], TRIGGER.variables), null)
|
||||
assert.equal(conditionsFromRows('and', [{ variable: '', cmp: '' }], TRIGGER.variables), null)
|
||||
})
|
||||
|
||||
test('a flat stored tree opens editable; a nested one opens read-only', () => {
|
||||
const flat = conditionRowsFrom({
|
||||
op: 'and',
|
||||
nodes: [{ variable: 'house', cmp: 'eq', value: 'x' }, { variable: 'daysLeft', cmp: 'gt', value: 5 }],
|
||||
})
|
||||
assert.equal(flat.editable, true)
|
||||
assert.equal(flat.rows.length, 2)
|
||||
|
||||
// `A AND (B OR C)` flattened to `A AND B AND C` fires on different events, and
|
||||
// the operator would have no way to know the save had done it.
|
||||
const nested = conditionRowsFrom({
|
||||
op: 'and',
|
||||
nodes: [
|
||||
{ variable: 'house', cmp: 'eq', value: 'x' },
|
||||
{ op: 'or', nodes: [{ variable: 'daysLeft', cmp: 'gt', value: 5 }] },
|
||||
],
|
||||
})
|
||||
assert.equal(nested.editable, false)
|
||||
assert.deepEqual(nested.rows, [])
|
||||
})
|
||||
|
||||
test('a single stored comparison is one editable row', () => {
|
||||
const one = conditionRowsFrom({ variable: 'house', cmp: 'eq', value: 'x' })
|
||||
assert.equal(one.editable, true)
|
||||
assert.deepEqual(one.rows, [{ variable: 'house', cmp: 'eq', value: 'x' }])
|
||||
})
|
||||
|
||||
// ── Segment composition ────────────────────────────────────────────────────
|
||||
|
||||
test('a members audience with no saved audience is warned about BEFORE the save', () => {
|
||||
// The trap the browser walk found: it is the default the moment a
|
||||
// members-ceiling trigger is chosen, and the rule it produces saves, switches
|
||||
// on and mails nobody. Nothing on the screen said so unless you pressed
|
||||
// Preview.
|
||||
assert.match(audienceWarning({ audience: 'members', audienceSegmentId: null }), /reaches nobody/)
|
||||
assert.equal(audienceWarning({ audience: 'members', audienceSegmentId: 4 }), null)
|
||||
assert.equal(audienceWarning({ audience: 'owner', audienceSegmentId: null }), null)
|
||||
})
|
||||
|
||||
test('the server says "segment"; the screens say "saved audience"', () => {
|
||||
// One word for one table in the API, the schema and the docs. But an operator
|
||||
// meets the concept under a heading that says "Audiences", and a sentence that
|
||||
// switches vocabulary mid-screen reads as being about something else.
|
||||
assert.equal(operatorWords('audience segment is dormant'), 'audience saved audience is dormant')
|
||||
assert.match(describeReach({ count: 0, dormant: true, reason: 'audience segment is dormant' }), /saved audience/)
|
||||
// and it does not maul a word that merely contains it
|
||||
assert.equal(operatorWords('segmented data'), 'segmented data')
|
||||
})
|
||||
|
||||
test('a list of nothing but exclusions is refused before the round trip', () => {
|
||||
// One checkbox away at all times, because the composer offers "exclude" on
|
||||
// every row including the only one. The server refuses it correctly — but
|
||||
// only after a save.
|
||||
const err = notPlacementError({ op: 'and', nodes: [{ op: 'not', nodes: [{ audienceId: 'a' }] }] })
|
||||
assert.match(err, /at least one audience/i)
|
||||
})
|
||||
|
||||
test('a bare not is refused before it reaches the server', () => {
|
||||
assert.ok(notPlacementError({ op: 'not', nodes: [{ audienceId: 'uo.governors' }] }))
|
||||
assert.ok(notPlacementError({ op: 'or', nodes: [{ audienceId: 'a' }, { op: 'not', nodes: [{ audienceId: 'b' }] }] }))
|
||||
})
|
||||
|
||||
test('a not under an "all of" is fine — that is the only universe that does not widen', () => {
|
||||
assert.equal(
|
||||
notPlacementError({
|
||||
op: 'and',
|
||||
nodes: [{ audienceId: 'uo.governors' }, { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }],
|
||||
}),
|
||||
null,
|
||||
)
|
||||
})
|
||||
|
||||
test('an expression describes itself with module labels where it has them', () => {
|
||||
const byId = { 'uo.governors': { label: 'Governors' } }
|
||||
const said = describeExpression(
|
||||
{ op: 'and', nodes: [{ audienceId: 'uo.governors' }, { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }] },
|
||||
byId,
|
||||
)
|
||||
assert.equal(said, 'Governors and not uo.flagged')
|
||||
})
|
||||
|
||||
test('a leaf renders its parameters, so two rows built on the same audience are distinguishable', () => {
|
||||
const said = describeExpression({ audienceId: 'uo.team.members', params: { teamId: 4 } }, {})
|
||||
assert.equal(said, 'uo.team.members (teamId: 4)')
|
||||
})
|
||||
|
||||
// ── The list summary ───────────────────────────────────────────────────────
|
||||
|
||||
test('a rule summarises to what it will do, and always names its hourly cap', () => {
|
||||
const said = describeRule(row({ delay_seconds: 3600 }), { segmentsById: {} })
|
||||
assert.match(said, /to owner/)
|
||||
assert.match(said, /via email/)
|
||||
assert.match(said, /after 1 hour/)
|
||||
assert.match(said, /once per 1 day/)
|
||||
assert.match(said, /100\/hour/)
|
||||
})
|
||||
|
||||
test('a rule on a segment names the segment, not the ceiling column', () => {
|
||||
// The `audience` column on such a rule holds the segment's ceiling, which is a
|
||||
// fact about what it MAY reach and not about who it does.
|
||||
const said = describeRule(row({ audience: 'members', audience_segment_id: 7 }), {
|
||||
segmentsById: { 7: { name: 'Governors' } },
|
||||
})
|
||||
assert.match(said, /to Governors/)
|
||||
})
|
||||
|
||||
test('humanSeconds picks the coarsest EXACT unit, and never rounds', () => {
|
||||
assert.equal(humanSeconds(0), 'none')
|
||||
assert.equal(humanSeconds(3600), '1 hour')
|
||||
assert.equal(humanSeconds(86400), '1 day')
|
||||
assert.equal(humanSeconds(7200), '2 hours')
|
||||
assert.equal(humanSeconds(3660), '61 minutes')
|
||||
assert.equal(humanSeconds(90), '90 seconds')
|
||||
})
|
||||
42
client/test/notificationPaths.test.js
Normal file
42
client/test/notificationPaths.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// ── Where each account's notification screens live ─────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 7. Three assertions for a nine-line module, because the
|
||||
// defect they pin was invisible to every other check: `/auth/me/notifications`
|
||||
// is role-agnostic (behind `requireAuth` only, like the rest of `/auth/me`), so
|
||||
// the server, the tests and the API all agreed a staff member had an inbox —
|
||||
// and on the web they could not reach it, because `RequirePlayer` sends anyone
|
||||
// who is not a player back out of `/account`. The bell pointed at a redirect.
|
||||
//
|
||||
// Found in the Phase 7 rig, signed in as an admin. What stops it coming back is
|
||||
// this file plus the two admin routes it maps onto.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { isStaff, inboxPath, notificationSettingsPath } from '../src/lib/notificationPaths.js'
|
||||
|
||||
test('a player gets the portal paths', () => {
|
||||
const user = { role: 'player' }
|
||||
assert.equal(isStaff(user), false)
|
||||
assert.equal(inboxPath(user), '/account/notifications')
|
||||
assert.equal(notificationSettingsPath(user), '/account/notifications/settings')
|
||||
})
|
||||
|
||||
test('every non-player role gets the admin paths, not just admin', () => {
|
||||
for (const role of ['admin', 'editor', 'moderator']) {
|
||||
const user = { role }
|
||||
assert.equal(isStaff(user), true, role)
|
||||
assert.equal(inboxPath(user), '/admin/notifications', role)
|
||||
assert.equal(notificationSettingsPath(user), '/admin/notifications/settings', role)
|
||||
}
|
||||
})
|
||||
|
||||
// The bell renders nothing when signed out, so these are never asked for a null
|
||||
// user in practice — but a default that guessed "staff" would send a signed-out
|
||||
// visitor at the admin area the moment that changed.
|
||||
test('no user, or a user with no role, falls back to the player paths', () => {
|
||||
for (const user of [null, undefined, {}, { role: '' }]) {
|
||||
assert.equal(isStaff(user), false)
|
||||
assert.equal(inboxPath(user), '/account/notifications')
|
||||
}
|
||||
})
|
||||
@@ -14,7 +14,8 @@
|
||||
"seed": "npm run seed --prefix server",
|
||||
"build": "npm run build --prefix client",
|
||||
"start": "npm start --prefix server",
|
||||
"check:modules": "node scripts/checkModuleIdentifiers.js"
|
||||
"check:modules": "node scripts/checkModuleIdentifiers.js",
|
||||
"check:hosts": "node scripts/checkNoExternalHosts.js"
|
||||
},
|
||||
"keywords": ["express", "mariadb", "react", "vite", "jwt"],
|
||||
"author": "whitlocktech",
|
||||
|
||||
192
scripts/checkNoExternalHosts.js
Normal file
192
scripts/checkNoExternalHosts.js
Normal file
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §3.2 rule 4 — no phone-home in the engagement subsystem ────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §3.2 records a posture the codebase already has and this check
|
||||
// exists to keep: **no transport may ship a default host, endpoint, API base or
|
||||
// sender.** A transport with no operator configuration is `unconfigured` and its
|
||||
// channel is off — it never quietly falls back to a destination we chose.
|
||||
//
|
||||
// The rule is easy to hold and easy to break by accident, and the removed Gmail
|
||||
// transport is the proof of both: `smtp.gmail.com` and port 465 were literals in
|
||||
// `mailer.buildTransport()`, which made "which provider" a code edit and made the
|
||||
// deployment's mail depend on a host nobody configured. Deleting that literal is
|
||||
// what this check was written against, and it is the first thing it would have
|
||||
// caught.
|
||||
//
|
||||
// **It reads code, not prose.** A comment naming `smtp.gmail.com` as the
|
||||
// migration path for existing operators is exactly the documentation this phase
|
||||
// owes, and a check that forbade it would teach people to phrase around it. So
|
||||
// comments and the insides of ordinary strings are masked out; what is checked is
|
||||
// a HOSTNAME OR URL appearing as a string literal in the engagement trees. Same
|
||||
// design, and the same reasoning, as `checkModuleIdentifiers.js` — including
|
||||
// having its own test suite, because a check that silently stops checking is
|
||||
// worse than no check.
|
||||
//
|
||||
// Scope is the engagement subsystem plus the mail path it owns, not the whole
|
||||
// server: core legitimately talks to hosts an operator configured elsewhere
|
||||
// (ntfy, Discord, the sidecar), and those are not this rule's business.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
|
||||
// The trees the rule covers. `server/src/engagement/` is where transports and,
|
||||
// later, the rules engine live; `utils/mailer.js` is the one file outside it that
|
||||
// composes and sends mail.
|
||||
const TREES = [path.join(ROOT, 'server', 'src', 'engagement')]
|
||||
const FILES = [path.join(ROOT, 'server', 'src', 'utils', 'mailer.js')]
|
||||
|
||||
const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', '.git'])
|
||||
const CODE = new Set(['.js', '.jsx', '.mjs', '.cjs'])
|
||||
|
||||
// A URL, or a bare dotted hostname with a real TLD. The TLD length floor is what
|
||||
// keeps `emailConfig.model` and `foo.js` out of it — a two-plus-letter final
|
||||
// label after at least one dot, with no path characters, is a host.
|
||||
// The `(?![-\w])` after the TLD is not redundant with `\b`: `\b` matches between
|
||||
// `l` and `-`, so `auth.email-verify` — an engagement TEMPLATE KEY, and one the
|
||||
// plan names (§4.6.1) — was read as the host `auth.email` with a stray suffix.
|
||||
// A real hostname's TLD is the last label, so a `-` or a word character following
|
||||
// it means the match is a truncation of a longer identifier rather than a
|
||||
// destination. Everything a host IS followed by (a quote, `/`, `:`, `?`) still
|
||||
// matches.
|
||||
const URL_LITERAL = /\b(?:https?|smtps?):\/\/[^\s'"`]+/
|
||||
const HOSTNAME_LITERAL = /\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:com|net|org|io|dev|co|email|mail|cloud|app|us|eu)(?![-\w])/i
|
||||
|
||||
// Hosts that are not destinations: the loopback family, and the RFC 2606 names
|
||||
// reserved for documentation. A placeholder in an admin form's help text is the
|
||||
// opposite of a phone-home — it shows the operator the SHAPE of a value they
|
||||
// must supply, and blanking it would make the form worse to hold the rule.
|
||||
const ALLOWED = [
|
||||
/^(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)$/i,
|
||||
/(?:^|\.)example\.(?:com|net|org)$/i,
|
||||
/(?:^|\.)(?:invalid|test|localhost)$/i,
|
||||
]
|
||||
|
||||
const isAllowed = (host) => ALLOWED.some((re) => re.test(host))
|
||||
|
||||
const hostOf = (literal) => {
|
||||
const withoutScheme = literal.replace(/^[a-z]+:\/\//i, '')
|
||||
return withoutScheme.split(/[/?#:]/)[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank comments and mask string bodies in one left-to-right pass, keeping every
|
||||
* offset aligned so reported line numbers stay honest.
|
||||
*
|
||||
* Lifted from `checkModuleIdentifiers.maskCode` deliberately rather than
|
||||
* imported: that file's masking is tuned to ITS four checks (it keeps quotes so a
|
||||
* route-path check can re-read the original at the same offsets), and coupling
|
||||
* two checks through a shared helper means a change made for one silently
|
||||
* re-scopes the other. Both are ~40 lines and both are tested.
|
||||
*/
|
||||
function maskComments(src) {
|
||||
const out = Array.from(src)
|
||||
const blank = (from, to) => {
|
||||
for (let i = from; i < to && i < out.length; i++) if (out[i] !== '\n') out[i] = ' '
|
||||
}
|
||||
let i = 0
|
||||
while (i < src.length) {
|
||||
const c = src[i]
|
||||
const next = src[i + 1]
|
||||
if (c === '/' && next === '/') {
|
||||
let j = i
|
||||
while (j < src.length && src[j] !== '\n') j++
|
||||
blank(i, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if (c === '/' && next === '*') {
|
||||
const end = src.indexOf('*/', i + 2)
|
||||
const j = end === -1 ? src.length : end + 2
|
||||
blank(i, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if (c === '"' || c === "'" || c === '`') {
|
||||
let j = i + 1
|
||||
while (j < src.length) {
|
||||
if (src[j] === '\\') { j += 2; continue }
|
||||
if (src[j] === c) break
|
||||
j++
|
||||
}
|
||||
// Keep the string body: it is what this check reads. Only the delimiters
|
||||
// matter for finding it, and comments are what has to go.
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
return out.join('')
|
||||
}
|
||||
|
||||
// Every string literal in the (comment-free) source, with its line number.
|
||||
const STRING = /(['"`])((?:\\.|(?!\1)[^\\])*)\1/g
|
||||
|
||||
function lineOf(src, index) {
|
||||
return src.slice(0, index).split('\n').length
|
||||
}
|
||||
|
||||
/** Check one file's contents. Returns [{ file, line, literal, host }]. */
|
||||
function checkFile(rel, src) {
|
||||
const hits = []
|
||||
const code = maskComments(src)
|
||||
for (const m of code.matchAll(STRING)) {
|
||||
const value = m[2]
|
||||
if (!value) continue
|
||||
const urlMatch = value.match(URL_LITERAL)
|
||||
const hostMatch = urlMatch ? null : value.match(HOSTNAME_LITERAL)
|
||||
const literal = urlMatch ? urlMatch[0] : hostMatch ? hostMatch[0] : null
|
||||
if (!literal) continue
|
||||
const host = hostOf(literal)
|
||||
if (isAllowed(host)) continue
|
||||
hits.push({ file: rel, line: lineOf(src, m.index), literal, host })
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
function walk(dir, out = []) {
|
||||
if (!fs.existsSync(dir)) return out
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) walk(full, out)
|
||||
else out.push(full)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function run() {
|
||||
const files = [...TREES.flatMap((t) => walk(t)), ...FILES.filter((f) => fs.existsSync(f))]
|
||||
const hits = []
|
||||
for (const file of files) {
|
||||
if (!CODE.has(path.extname(file))) continue
|
||||
const rel = path.relative(ROOT, file).split(path.sep).join('/')
|
||||
hits.push(...checkFile(rel, fs.readFileSync(file, 'utf8')))
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
module.exports = { run, checkFile, maskComments, isAllowed, hostOf }
|
||||
|
||||
if (require.main === module) {
|
||||
const hits = run()
|
||||
if (hits.length === 0) {
|
||||
console.log('OK — the engagement subsystem names no external host (ENGAGEMENT.md §3.2 rule 4).')
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(
|
||||
`\nThe engagement subsystem names ${hits.length} external host${hits.length === 1 ? '' : 's'} ` +
|
||||
'in code (ENGAGEMENT.md §3.2 rule 4). A destination belongs in operator-supplied ' +
|
||||
'configuration, never in a literal:\n',
|
||||
)
|
||||
for (const h of hits) {
|
||||
console.error(` ${h.file}:${h.line} "${h.literal}"`)
|
||||
}
|
||||
console.error(
|
||||
'\nIf this is help text or documentation rather than a destination, put it in a comment or ' +
|
||||
'use an example.com placeholder — the check masks comments and allows the reserved ' +
|
||||
'documentation names on purpose.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -80,10 +80,12 @@ TOTP_CHALLENGE_TTL=5m
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-admin-password
|
||||
|
||||
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not here.
|
||||
# It reuses the Google auth provider's OAuth client and stores an encrypted
|
||||
# refresh token in the DB. The contact recipient is the `contact_email` site
|
||||
# Email is configured in Admin → Settings → Email, not here: pick a mail
|
||||
# transport (SMTP) and enter its host, port and credentials, stored encrypted in
|
||||
# the DB. A relay is the recommended posture; smtp.gmail.com:587 with an app
|
||||
# password is the simplest. The contact recipient is the `contact_email` site
|
||||
# setting; while email is unconfigured the contact form falls back to a mailto: link.
|
||||
# Upgrading from the removed Gmail connect flow: see docs/website/UPGRADE_NOTES.md.
|
||||
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
|
||||
|
||||
@@ -21,11 +21,30 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
-- (validatePassword returns false).
|
||||
password_hash VARCHAR(72) NULL,
|
||||
role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin',
|
||||
-- Optional contact email (players). Not unique — SSO emails may repeat. Used
|
||||
-- only for display + a future self-serve reset. email_verified is wired now so
|
||||
-- an eventual SMTP verification flow needs no schema change.
|
||||
-- The account's ONE contact address, and the destination for password-reset
|
||||
-- mail. Unique since engagement Phase 1b — but the index is on email_norm
|
||||
-- below, never on this column, and the reason is not stylistic:
|
||||
--
|
||||
-- Every case-insensitive (_ci) collation this server offers is ALSO
|
||||
-- accent-insensitive, so a UNIQUE index on `email` would refuse
|
||||
-- jose@x.com once josé@x.com exists. Those are two different mailboxes.
|
||||
--
|
||||
-- LOWER() under a _bin collation folds case WITHOUT folding accents, which is
|
||||
-- exactly the equivalence a mail system uses. Keeping the fold in a generated
|
||||
-- column rather than in application code means it cannot be bypassed by a
|
||||
-- caller that forgets to normalize.
|
||||
email VARCHAR(255) NULL,
|
||||
-- The uniqueness key. STORED (not VIRTUAL) because a UNIQUE index over it must
|
||||
-- be materialized. Multiple NULLs are legal under a UNIQUE index, which is what
|
||||
-- lets the Phase 1b de-duplication null the losers without deleting an account.
|
||||
email_norm VARCHAR(255) COLLATE utf8mb4_bin AS (LOWER(email)) STORED,
|
||||
email_verified TINYINT(1) NOT NULL DEFAULT 0,
|
||||
-- An address the user has asked for but not yet proved. It does NOT displace
|
||||
-- `email` until the verification link is used, so a typo cannot silently
|
||||
-- redirect this account's password-reset mail. Deliberately NOT unique: a
|
||||
-- pending address reserves nothing, and two users may both be pending on one
|
||||
-- address — the second to verify loses, with the same generic failure.
|
||||
email_pending VARCHAR(255) NULL,
|
||||
-- Account lifecycle, independent of role: staff can disable/ban a player
|
||||
-- without changing their role. active = normal; disabled = admin-locked;
|
||||
-- banned = moderation ban; pending = reserved for future email-verify gating.
|
||||
@@ -38,7 +57,12 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
tokens_valid_after DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at DATETIME NULL,
|
||||
last_login_ip VARCHAR(45) NULL -- IPv6-capable, set on each login
|
||||
last_login_ip VARCHAR(45) NULL, -- IPv6-capable, set on each login
|
||||
-- One account per mailbox (engagement Phase 1b). On the generated column, not
|
||||
-- on `email` — see the note there. Upgraded databases get this in the migration
|
||||
-- block at the foot of this file, AFTER the de-duplication that makes it
|
||||
-- addable; adding it here too is what gives a FRESH install the same shape.
|
||||
UNIQUE KEY uq_users_email_norm (email_norm)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
@@ -327,19 +351,33 @@ CREATE TABLE IF NOT EXISTS bot_config (
|
||||
CONSTRAINT chk_bot_config_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Outbound email configuration (Gmail over OAuth2 / SMTP XOAUTH2). Singleton row
|
||||
-- (id = 1), mirroring bot_config: the DB only ever holds the AES-256-GCM-encrypted
|
||||
-- refresh token, never plaintext, and the client id/secret are NOT stored here —
|
||||
-- they are read live from the `google` auth_providers row. The refresh token is
|
||||
-- captured by the in-app "Connect Gmail" consent flow and is write-only over the
|
||||
-- admin API (never returned; responses expose only hasRefreshToken).
|
||||
-- Outbound email configuration. Singleton row (id = 1), mirroring bot_config: the
|
||||
-- DB only ever holds the AES-256-GCM-encrypted credential, never plaintext, and it
|
||||
-- is write-only over the admin API (never returned; responses expose only
|
||||
-- hasCredential and the non-secret fields the transport declares).
|
||||
--
|
||||
-- `transport` names a registered mail transport (server/src/engagement/transports).
|
||||
-- `credential_enc` is that transport's whole credential set as one encrypted JSON
|
||||
-- blob rather than a column per field, because the field list is the transport's to
|
||||
-- declare — SMTP wants host/port/secure/user/password, an API relay wants a domain
|
||||
-- and a key, and a column per union member would make adding a transport a schema
|
||||
-- change. ENGAGEMENT.md §3.1.
|
||||
--
|
||||
-- `provider` and `refresh_token_enc` are DEPRECATED and no longer read: they held
|
||||
-- the removed Gmail OAuth2 connection (ENGAGEMENT.md §1.2a). They are kept rather
|
||||
-- than dropped under the additive-only discipline, and `refresh_token_enc` earns
|
||||
-- its keep in the meantime as the marker for "this deployment had working mail
|
||||
-- before the upgrade" — which is what the admin dashboard warning reads.
|
||||
CREATE TABLE IF NOT EXISTS email_config (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2',
|
||||
provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2', -- DEPRECATED, unread
|
||||
transport VARCHAR(32) NOT NULL DEFAULT 'smtp',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
sender_email VARCHAR(255) NULL, -- connected Gmail address (from userinfo)
|
||||
sender_email VARCHAR(255) NULL, -- envelope From, operator-typed
|
||||
sender_name VARCHAR(120) NULL, -- optional From display name
|
||||
refresh_token_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed
|
||||
reply_to VARCHAR(255) NULL, -- optional Reply-To for sent mail
|
||||
credential_enc TEXT NULL, -- AES-256-GCM ciphertext (JSON), never exposed
|
||||
refresh_token_enc TEXT NULL, -- DEPRECATED, unread; see above
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'unconfigured',
|
||||
status_detail VARCHAR(500) NULL,
|
||||
last_verified_at DATETIME NULL,
|
||||
@@ -401,6 +439,54 @@ CREATE TABLE IF NOT EXISTS password_resets (
|
||||
INDEX idx_password_resets_status (status, expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Self-service email verification (engagement Phase 1b). The same shape as
|
||||
-- password_resets, deliberately: an opaque random token whose sha256 is all that
|
||||
-- is stored, single-use, short-lived. The design of record calls this link
|
||||
-- "signed"; every comparable flow in this codebase (user_invites,
|
||||
-- password_resets, mobile_refresh_tokens) uses a hashed random token instead, and
|
||||
-- matching them beats introducing a second token mechanism for one caller.
|
||||
--
|
||||
-- The address lives on the ROW, not just on the user: a token proves control of
|
||||
-- the address it was mailed to, so if the user changes their mind and requests a
|
||||
-- different address, the older token must not be able to confirm the newer one.
|
||||
CREATE TABLE IF NOT EXISTS email_verifications (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token
|
||||
user_id INT NOT NULL,
|
||||
email VARCHAR(255) NOT NULL, -- the address THIS token proves
|
||||
status ENUM('pending','used') NOT NULL DEFAULT 'pending',
|
||||
requested_ip VARCHAR(64) NULL, -- who asked (audit only)
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
used_at DATETIME NULL,
|
||||
CONSTRAINT fk_email_verifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_email_verifications_user (user_id),
|
||||
INDEX idx_email_verifications_status (status, expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Who lost an address to the Phase 1b de-duplication, and what they lost.
|
||||
--
|
||||
-- These accounts are exactly the ones an operator must contact: they can no
|
||||
-- longer receive password-reset or engagement mail until they set a new address.
|
||||
-- Written by the migration below in pure SQL (ensureSchema() reads this file
|
||||
-- statement-by-statement and there is no JS migration hook), surfaced as a
|
||||
-- dashboard warning until acknowledged.
|
||||
--
|
||||
-- No foreign key to users, on purpose: the same reasoning as posts.announce_job_id
|
||||
-- — a constraint re-added on every boot is a constraint that can fail a boot, and
|
||||
-- this table is a historical record rather than a live relation.
|
||||
CREATE TABLE IF NOT EXISTS email_dedupe_report (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
username VARCHAR(32) NOT NULL, -- captured at clear time
|
||||
lost_address VARCHAR(255) NOT NULL,
|
||||
cleared_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
acknowledged_at DATETIME NULL, -- set when an admin dismisses the warning
|
||||
-- Makes the migration's INSERT strictly idempotent: an account cleared once is
|
||||
-- never reported twice, however many times ensureSchema() runs.
|
||||
UNIQUE KEY uq_edr_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Push notifications (opt-in) ─────────────────────────────────────────────
|
||||
-- One row per registered push endpoint (Android/UnifiedPush v1; FCM later). The
|
||||
-- `endpoint` is the UnifiedPush distributor URL the app's ntfy topic was handed —
|
||||
@@ -1464,3 +1550,521 @@ ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME
|
||||
-- trust token. A boolean only — the token is returned over that app→server call
|
||||
-- and never persisted here (only its sha256 lands in trusted_devices).
|
||||
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;
|
||||
|
||||
-- ── Engagement Phase 1: Gmail OAuth2 removed, SMTP is the baseline ──────────
|
||||
-- (ENGAGEMENT.md §1.2a). Additive on an upgraded database: `transport` backfills
|
||||
-- to 'smtp' for every existing row, and `credential_enc` starts NULL — so an
|
||||
-- upgraded deployment is deliberately CREDENTIAL-LESS until its operator supplies
|
||||
-- SMTP settings. That is the whole point of the G22 warning below: nothing about
|
||||
-- this fails loudly, so something has to say it out loud.
|
||||
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS transport VARCHAR(32) NOT NULL DEFAULT 'smtp';
|
||||
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS credential_enc TEXT NULL;
|
||||
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS reply_to VARCHAR(255) NULL;
|
||||
|
||||
-- ── Engagement Phase 1b: one account per mailbox ───────────────────────────
|
||||
-- (ENGAGEMENT.md Phase 1b / §0.6.) ORDER IS LOAD-BEARING and every statement here
|
||||
-- is idempotent — after the first successful boot each one matches zero rows.
|
||||
--
|
||||
-- Why the generated column is added BEFORE the de-duplication rather than after:
|
||||
-- the de-dupe must group addresses exactly the way the index will, and it cannot
|
||||
-- do that with LOWER(email) = LOWER(email) in SQL, because that comparison uses
|
||||
-- the COLUMN's collation, which is accent-insensitive. Grouping on email_norm —
|
||||
-- the very column the UNIQUE index goes on — makes the two agree by construction
|
||||
-- instead of by a hand-matched COLLATE clause someone can get wrong later.
|
||||
-- (Tested: with the LOWER()=LOWER() form, jose@x.com was nulled as a "duplicate"
|
||||
-- of josé@x.com. They are different mailboxes.)
|
||||
|
||||
-- 1. An empty string is a value, not an absence, so two accounts holding '' would
|
||||
-- collide under the index and stop the boot. Unreachable through the current
|
||||
-- routes (isEmail() rejects ''), but this runs against databases whose history
|
||||
-- we do not control.
|
||||
UPDATE users SET email = NULL WHERE email = '';
|
||||
|
||||
-- 2. The pending-address column and the uniqueness key. No index yet — a UNIQUE
|
||||
-- index here, before step 3, is precisely the ALTER that fails and takes the
|
||||
-- site down with it (§0.6 finding 1).
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_pending VARCHAR(255) NULL;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_norm VARCHAR(255) COLLATE utf8mb4_bin AS (LOWER(email)) STORED;
|
||||
|
||||
-- 3. Record every account about to lose its address, BEFORE nulling it — the
|
||||
-- report is the only place the lost value survives. Oldest-wins (§7.1 Q1):
|
||||
-- the earliest-created account keeps the address, ties broken by id so the
|
||||
-- outcome is deterministic. Verified status deliberately does NOT arbitrate —
|
||||
-- SSO set email_verified from the mere presence of an address, so it is too
|
||||
-- weak a signal to decide who keeps a mailbox (§0.6 finding 3).
|
||||
INSERT IGNORE INTO email_dedupe_report (user_id, username, lost_address)
|
||||
SELECT l.id, l.username, l.email FROM (
|
||||
SELECT u.id, u.username, u.email FROM users u
|
||||
WHERE u.email_norm IS NOT NULL
|
||||
AND u.id <> (SELECT u2.id FROM users u2
|
||||
WHERE u2.email_norm = u.email_norm
|
||||
ORDER BY u2.created_at ASC, u2.id ASC LIMIT 1)
|
||||
) AS l;
|
||||
|
||||
-- 4. Clear the losers. NEVER deletes a row: multiple NULLs are legal under a
|
||||
-- UNIQUE index, so every account survives with its login intact and simply has
|
||||
-- no contact address until its owner sets one. The extra derived table is not
|
||||
-- decoration — MariaDB refuses a subquery on the table being updated (error
|
||||
-- 1093) without it.
|
||||
UPDATE users SET email = NULL, email_verified = 0
|
||||
WHERE id IN (SELECT id FROM (
|
||||
SELECT u.id FROM users u
|
||||
WHERE u.email_norm IS NOT NULL
|
||||
AND u.id <> (SELECT u2.id FROM users u2
|
||||
WHERE u2.email_norm = u.email_norm
|
||||
ORDER BY u2.created_at ASC, u2.id ASC LIMIT 1)
|
||||
) AS losers);
|
||||
|
||||
-- 5. Now the table can hold it.
|
||||
ALTER TABLE users ADD UNIQUE INDEX IF NOT EXISTS uq_users_email_norm (email_norm);
|
||||
|
||||
-- 6. The verification gate: may an UNVERIFIED address receive opt-in engagement
|
||||
-- mail? ON for a fresh install, OFF for an upgrade — the asymmetry is the G22
|
||||
-- lesson, not an oversight. Turning it on retroactively would silently stop
|
||||
-- mailing every existing opted-in user on upgrade day, which is exactly the
|
||||
-- kind of quiet breakage Phase 1 had to write a dashboard warning to undo.
|
||||
-- "Fresh" is read off the users table: a database with no users has no one to
|
||||
-- surprise. Both statements are INSERT IGNORE, so an operator who has since
|
||||
-- changed the value keeps theirs.
|
||||
INSERT IGNORE INTO settings (`key`, value)
|
||||
SELECT 'email_verification_required', 'on' FROM DUAL WHERE (SELECT COUNT(*) FROM users) = 0;
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('email_verification_required', 'off');
|
||||
|
||||
-- The status a Gmail-connected deployment carries is 'connected', and after the
|
||||
-- upgrade that is a lie: nothing can send. Correct it once, narrowly. The WHERE
|
||||
-- makes this idempotent and self-limiting — it matches only a row that still holds
|
||||
-- a Gmail refresh token AND has no replacement credential, so re-running it after
|
||||
-- the operator configures SMTP touches nothing, and it can never overwrite a real
|
||||
-- status recorded by a later send.
|
||||
UPDATE email_config
|
||||
SET status = 'unconfigured',
|
||||
status_detail = 'Gmail OAuth2 was removed. Configure SMTP credentials in Admin - Settings - Email.'
|
||||
WHERE refresh_token_enc IS NOT NULL
|
||||
AND credential_enc IS NULL
|
||||
AND status <> 'unconfigured';
|
||||
|
||||
-- ── Per-channel notification preferences (ENGAGEMENT.md §4.5, Phase 3) ──────
|
||||
--
|
||||
-- G8: `notification_subscriptions` above has no channel dimension. It answers
|
||||
-- "which streams does this user want pushed", and the shipped Android client's
|
||||
-- wire shape (`{ streams: [...] }`) is frozen around exactly that question. This
|
||||
-- table answers the general one — which streams AND triggers, on which channel,
|
||||
-- in which mode — and the old table becomes its push projection: every write to
|
||||
-- one fans out to the other (`notificationChannelPrefs.model`).
|
||||
--
|
||||
-- `stream_id` names a stream OR a trigger id, ONE namespace (§7.2, settled in
|
||||
-- Phase 2). That decision is what keeps this primary key single-keyed: under two
|
||||
-- namespaces it would have needed a `kind` discriminator, and `news.post` would
|
||||
-- have meant two different rows forever.
|
||||
--
|
||||
-- **A row exists only where a user has expressed something.** Absence is not
|
||||
-- "off" — it is "the channel's `defaultMode`", which lives in
|
||||
-- `src/engagement/channels.js` and nowhere else (§3.1, G9: per-channel defaults
|
||||
-- differ). All three of core's channels default 'off' today, so absence and off
|
||||
-- coincide; that is a fact about the current declarations, not about this table,
|
||||
-- and code must not assume it. The column DEFAULT below is the value a write with
|
||||
-- no mode takes, not the value a missing row means.
|
||||
CREATE TABLE IF NOT EXISTS notification_channel_prefs (
|
||||
user_id INT NOT NULL,
|
||||
stream_id VARCHAR(64) NOT NULL,
|
||||
channel VARCHAR(32) NOT NULL,
|
||||
mode ENUM('off','instant','digest') NOT NULL DEFAULT 'off',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, stream_id, channel),
|
||||
CONSTRAINT fk_ncp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_ncp_channel (channel, mode)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Carry the existing push subscriptions across, once. Same shape as the
|
||||
-- announce_jobs -> announce_job_legs backfill above: an INSERT IGNORE ... SELECT,
|
||||
-- so replaying this file on every boot is a no-op after the first, and a user who
|
||||
-- has since turned a stream OFF is not resurrected by the next boot (their row
|
||||
-- exists with mode 'off', and INSERT IGNORE leaves it alone).
|
||||
--
|
||||
-- 'instant' rather than the column default, because a row in
|
||||
-- notification_subscriptions IS an opt-in: the user asked to be pushed, and push
|
||||
-- has no digest mode to be asked into instead.
|
||||
INSERT IGNORE INTO notification_channel_prefs (user_id, stream_id, channel, mode)
|
||||
SELECT user_id, stream_id, 'push', 'instant' FROM notification_subscriptions;
|
||||
|
||||
-- ── The engagement engine (ENGAGEMENT.md §4.1, §4.2a, §4.5 — Phase 4a) ──────
|
||||
--
|
||||
-- Five tables and no delivery. A rule says "when this trigger fires, for these
|
||||
-- people, on these channels, no more often than this"; the outbox is the queue
|
||||
-- the grace window needs; the cooldown table is what makes "once per house" mean
|
||||
-- once per house; and the send log is the first answer this deployment has ever
|
||||
-- had to "did user X get the mail?".
|
||||
--
|
||||
-- Nothing here sends anything. Core seeds no rules and `enabled` defaults to 0,
|
||||
-- so on a real deployment these five tables stay empty until an operator turns a
|
||||
-- rule on from the screen Phase 4b builds.
|
||||
|
||||
-- What an operator actually configures: trigger -> audience -> template -> timing.
|
||||
--
|
||||
-- `trigger_id` deliberately has NO foreign key and no existence check: a trigger
|
||||
-- is DECLARED IN CODE (§4.3), so the set of them is whatever registered on this
|
||||
-- boot. A rule naming a trigger no module currently registers is DORMANT — it is
|
||||
-- listed, it never fires, and it starts working again when the module comes back
|
||||
-- (§7.3). Deleting it on uninstall would silently destroy an operator's
|
||||
-- configuration on the strength of a module being temporarily absent.
|
||||
CREATE TABLE IF NOT EXISTS engagement_rules (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
trigger_id VARCHAR(96) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
-- OFF by default (§7.1 Q3). A rule arrives inert and an operator turns it on,
|
||||
-- so no import, seed or restore can start mailing on its own.
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
audience VARCHAR(32) NOT NULL DEFAULT 'owner',
|
||||
audience_segment_id INT NULL,
|
||||
-- §7.1 Q3: the hard stop that makes operator-editable rules safe to choose over
|
||||
-- code-registered ones. Counted in engagement_sends, enforced before the outbox
|
||||
-- row is written, never overridable from the rule editor beyond this column.
|
||||
max_sends_per_hour INT NOT NULL DEFAULT 100,
|
||||
channels JSON NOT NULL,
|
||||
template_keys JSON NOT NULL,
|
||||
conditions JSON NULL,
|
||||
cooldown_seconds INT NOT NULL DEFAULT 0,
|
||||
delay_seconds INT NOT NULL DEFAULT 0,
|
||||
cancel_on JSON NULL,
|
||||
updated_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_engr_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_engr_trigger (trigger_id, enabled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- §5.1a: an operator-composed segment over module-declared audiences. Stored as a
|
||||
-- boolean tree of audience ids + params; `ceiling` is DERIVED at save time as the
|
||||
-- NARROWEST ceiling in the tree (ceilings.meetAll) and re-checked against the
|
||||
-- trigger's own ceiling, so composition can never widen. It is a column rather
|
||||
-- than a runtime computation so an audit can read what a rule was allowed to
|
||||
-- reach without re-resolving it — and so a module that has since changed its
|
||||
-- audience's ceiling cannot retroactively widen a saved segment.
|
||||
--
|
||||
-- `engagement_rules.audience_segment_id` above points here with NO foreign key,
|
||||
-- on purpose and for the same reason `trigger_id` has none: a rule whose segment
|
||||
-- has been deleted must go DORMANT, not silently fall back to its plain
|
||||
-- `audience` column. ON DELETE SET NULL would be exactly that silent fallback,
|
||||
-- and the fallback reaches a DIFFERENT set of people (§5.1a rule 4).
|
||||
CREATE TABLE IF NOT EXISTS engagement_audience_segments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
expression JSON NOT NULL,
|
||||
ceiling VARCHAR(32) NOT NULL,
|
||||
updated_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_engseg_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- §4.1. NOT `settings`: cooldown state is high-cardinality (recipients x rules x
|
||||
-- subjects), written on every fire, and asked "is this one pair still cooling?".
|
||||
-- A JSON blob under one settings key would be a read-modify-write of the whole
|
||||
-- deployment's cooldown state per event, with a lost-update race between two
|
||||
-- concurrent triggers.
|
||||
--
|
||||
-- `subject_key` is what makes "one IDOC mail per player per day" the right rule
|
||||
-- instead of the wrong one: a player with four houses decaying should hear about
|
||||
-- all four, once each. Cooling per (rule, user) alone silently drops three.
|
||||
CREATE TABLE IF NOT EXISTS engagement_cooldowns (
|
||||
rule_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
-- The SUBJECT the cooldown is about, opaque to core: a house serial, a vendor
|
||||
-- id, ''. NOT NULL with a '' default, because this is a PRIMARY KEY column and
|
||||
-- MariaDB would coerce a NULL one anyway. '' is "this rule cools per user, not
|
||||
-- per subject".
|
||||
subject_key VARCHAR(190) NOT NULL DEFAULT '',
|
||||
-- The CHANNEL the cooldown is about, added in Phase 11b after the live walk.
|
||||
-- Without it a rule naming two channels delivers on exactly ONE of them: the
|
||||
-- claim runs inside the engine's per-channel loop, `inapp` is ranked first on
|
||||
-- purpose (so push can reference its inbox row), and every later channel is
|
||||
-- then reported as cooled. Phase 11b's decision 8 requires the letter and the
|
||||
-- inbox item to fire together, so the cooldown is per delivery, not per
|
||||
-- occasion. VARCHAR like `engagement_outbox.channel`, and for the same reason:
|
||||
-- the channel set is data a module can extend.
|
||||
channel VARCHAR(32) NOT NULL DEFAULT '',
|
||||
last_fired_at DATETIME NOT NULL,
|
||||
fire_count INT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (rule_id, user_id, subject_key, channel),
|
||||
CONSTRAINT fk_engc_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_engc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- So a prune worker can drop rows older than the longest configured cooldown.
|
||||
-- Without it this table grows without bound, which is the failure mode
|
||||
-- teamActivityPrune was written for.
|
||||
INDEX idx_engc_sweep (last_fired_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Widen the key on a deployment that already has the table. Two statements, and
|
||||
-- the second is guarded because MariaDB has no conditional form of a PRIMARY KEY
|
||||
-- change: re-running `DROP PRIMARY KEY, ADD PRIMARY KEY` on a table that already
|
||||
-- carries the new one is an error, not a no-op, so replaying this file on every
|
||||
-- boot would fail the whole schema after the first run. The guard reads the key
|
||||
-- itself out of information_schema rather than the column's existence, because
|
||||
-- `ADD COLUMN IF NOT EXISTS` above can succeed while the key change does not.
|
||||
--
|
||||
-- Existing rows keep `channel = ''`, which is one stale cooldown per (rule, user,
|
||||
-- subject) that expires on its own interval. That is the right trade against
|
||||
-- deleting them: a cooldown that outlives its rewrite costs at most one delayed
|
||||
-- notification, and dropping the table would let a bounce storm through.
|
||||
ALTER TABLE engagement_cooldowns ADD COLUMN IF NOT EXISTS channel VARCHAR(32) NOT NULL DEFAULT '';
|
||||
SET @engc_key_has_channel := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'engagement_cooldowns'
|
||||
AND INDEX_NAME = 'PRIMARY' AND COLUMN_NAME = 'channel'
|
||||
);
|
||||
SET @sql := IF(@engc_key_has_channel = 0,
|
||||
'ALTER TABLE engagement_cooldowns DROP PRIMARY KEY, ADD PRIMARY KEY (rule_id, user_id, subject_key, channel)',
|
||||
'DO 0');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- §4.2a. Modelled on announce_jobs / announce_job_legs. One row per
|
||||
-- (rule, user, channel) occurrence of an event.
|
||||
CREATE TABLE IF NOT EXISTS engagement_outbox (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
rule_id INT NOT NULL,
|
||||
trigger_id VARCHAR(96) NOT NULL, -- denormalized; survives a rule edit
|
||||
user_id INT NOT NULL,
|
||||
channel VARCHAR(32) NOT NULL, -- VARCHAR, never ENUM: the channel set is data
|
||||
subject_key VARCHAR(190) NOT NULL DEFAULT '',
|
||||
payload JSON NOT NULL, -- the declared variables, snapshotted at emit
|
||||
dedupe_key VARCHAR(190) NULL,
|
||||
status ENUM('scheduled','sending','sent','failed','cancelled','suppressed') NOT NULL DEFAULT 'scheduled',
|
||||
due_at DATETIME NOT NULL,
|
||||
attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
last_error TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
sent_at DATETIME NULL,
|
||||
CONSTRAINT fk_engo_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_engo_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- **Scoped to the row's identity, and §4.2a's global `UNIQUE (dedupe_key)` is
|
||||
-- a defect this phase found while building it.** A dedupe key names the EVENT
|
||||
-- ("house 0x4001 entered IDOC"), and one event legitimately becomes many rows:
|
||||
-- an audience of fifty users is fifty rows, a rule spanning email and in-app
|
||||
-- doubles that, and two rules on one trigger double it again. Under a global
|
||||
-- unique index the FIRST of those inserts wins and every other one is silently
|
||||
-- ignored — ninety-nine recipients dropped by the mechanism meant to stop a
|
||||
-- replayed event becoming a second mail. Scoping it to (rule, user, channel)
|
||||
-- keeps exactly that guarantee and nothing more.
|
||||
UNIQUE KEY uq_engo_dedupe (rule_id, user_id, channel, dedupe_key),
|
||||
INDEX idx_engo_due (status, due_at),
|
||||
-- What a RESOLVING event queries: a house repaired back to LikeNew cancels
|
||||
-- every scheduled row for that (rule, user, house).
|
||||
INDEX idx_engo_cancel (rule_id, user_id, subject_key, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- G15: the per-message record. Today "did user X get the mail?" is unanswerable.
|
||||
--
|
||||
-- It is deliberately NOT a second address book: the address is stored as a
|
||||
-- sha256, which is enough to correlate a bounce (Phase 9) and useless as a
|
||||
-- mailing list. `user_id` is SET NULL rather than CASCADE so the log survives an
|
||||
-- account deletion — an audit of what this deployment sent must not be erasable
|
||||
-- by deleting the recipient.
|
||||
CREATE TABLE IF NOT EXISTS engagement_sends (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
outbox_id BIGINT NULL,
|
||||
rule_id INT NULL,
|
||||
trigger_id VARCHAR(96) NOT NULL,
|
||||
user_id INT NULL,
|
||||
channel VARCHAR(32) NOT NULL,
|
||||
transport VARCHAR(32) NULL,
|
||||
address_hash CHAR(64) NULL,
|
||||
status ENUM('sent','failed','suppressed','bounced','complained') NOT NULL,
|
||||
detail VARCHAR(500) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_engs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_engs_trigger (trigger_id, created_at),
|
||||
INDEX idx_engs_user (user_id, created_at),
|
||||
-- The per-rule hourly ceiling (§7.1 Q3) is counted here, so the count has to be
|
||||
-- an index range scan rather than a table scan: it runs once per rule per event.
|
||||
INDEX idx_engs_rule_window (rule_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- §4.4. The mail (and, from Phase 7, in-app) bodies an operator can edit, stored
|
||||
-- as a validated block array rather than as raw HTML: `blocks` goes through the
|
||||
-- same validate-then-sanitize gate the CMS pages do, against the `email.*`
|
||||
-- registry (src/emailBlocks/). Storing operator HTML would hand the renderer an
|
||||
-- injection surface and give up the prop schemas.
|
||||
--
|
||||
-- Three columns carry the whole "ship a better default without stealing an
|
||||
-- operator's work" mechanism (§4.6.1 property 3). `seed_key` says which shipped
|
||||
-- template a row came from, `seed_version` which revision of it, and `customized`
|
||||
-- whether a person has since edited it. The seeder updates a row whose version is
|
||||
-- behind ONLY while `customized = 0`; a customized row is left exactly as it is
|
||||
-- and the newer default is surfaced in the admin list instead. Same posture
|
||||
-- `settingsJson` takes: never overwrite what someone chose.
|
||||
--
|
||||
-- `trigger_id` has no foreign key for the reason `engagement_rules.trigger_id`
|
||||
-- has none -- a trigger is declared in code, so the set of them is whatever
|
||||
-- registered on this boot. NULL means a reusable template not tied to one
|
||||
-- trigger, which is what every transactional seed is: `mailer` renders them by
|
||||
-- key, no rule involved.
|
||||
CREATE TABLE IF NOT EXISTS engagement_templates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`key` VARCHAR(96) NOT NULL UNIQUE,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
trigger_id VARCHAR(96) NULL,
|
||||
trigger_version INT NULL,
|
||||
channel VARCHAR(32) NOT NULL,
|
||||
subject VARCHAR(300) NULL,
|
||||
blocks MEDIUMTEXT NOT NULL,
|
||||
text_body MEDIUMTEXT NULL,
|
||||
status ENUM('draft','published') NOT NULL DEFAULT 'draft',
|
||||
-- Editable, NOT deletable -- the pages.protected flag, for the same reason:
|
||||
-- the system breaks without a password-reset body.
|
||||
protected TINYINT(1) NOT NULL DEFAULT 0,
|
||||
seed_key VARCHAR(96) NULL,
|
||||
seed_version INT NULL,
|
||||
customized TINYINT(1) NOT NULL DEFAULT 0,
|
||||
updated_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_engt_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_engt_trigger (trigger_id, channel, status),
|
||||
INDEX idx_engt_seed (seed_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── The email channel on the engine (ENGAGEMENT.md §4.2b — Phase 6) ─────────
|
||||
|
||||
-- The scope an event is ABOUT, opaque to core and distinct from `subject_key`.
|
||||
--
|
||||
-- They are two different things and Phase 6 is where that stopped being
|
||||
-- theoretical. `subject_key` is what a COOLDOWN is keyed on and comes from the
|
||||
-- trigger's declared `subjectKey` — for the four Team triggers that is `teamName`,
|
||||
-- a display string. `scope_key` is what a PREFERENCE and an UNSUBSCRIBE are keyed
|
||||
-- on, and it has to be a stable identifier: `team:12` survives a rename, and a
|
||||
-- Team renamed between the mail and the click must not orphan the unsubscribe
|
||||
-- link in it. Same vocabulary as engagement_digest_state.scope_key below.
|
||||
ALTER TABLE engagement_outbox ADD COLUMN IF NOT EXISTS scope_key VARCHAR(190) NULL;
|
||||
|
||||
-- §4.2b: digest state, and DELIBERATELY not a digest queue.
|
||||
--
|
||||
-- The generic engine enqueues an outbox row per (rule, user, channel) at emit
|
||||
-- time, carrying a snapshot of the payload. That is right for an instant send and
|
||||
-- wrong for a digest, and `teamDigestWorker`'s header says why in three
|
||||
-- properties: a deployment down for two days sends ONE digest rather than two
|
||||
-- days of replay; a post a moderator hid after it was written is not in the
|
||||
-- query so it is not in the mail; and a user who lost forum access between the
|
||||
-- post and the send is no longer in the recipient set. The third is a security
|
||||
-- property, and all three are properties of RE-DERIVING the content at send time.
|
||||
-- A snapshot taken at emit time has none of them.
|
||||
--
|
||||
-- So a digest-mode recipient gets NO outbox row (see engine.js), and what
|
||||
-- generalizes is this: the state the worker keeps, lifted out of
|
||||
-- team_notification_prefs.last_digest_at so that a second digest — on another
|
||||
-- channel, or over another scope — needs no second column on somebody's
|
||||
-- preferences table.
|
||||
CREATE TABLE IF NOT EXISTS engagement_digest_state (
|
||||
user_id INT NOT NULL,
|
||||
channel VARCHAR(32) NOT NULL,
|
||||
-- '' is deployment-wide; 'team:12' is one Team. NOT NULL with a '' default
|
||||
-- because this is a PRIMARY KEY column and MariaDB coerces a nullable one
|
||||
-- anyway — the same workaround team_integration_config and teams.active_key
|
||||
-- both carry, and the trap Part 4's preamble flags.
|
||||
scope_key VARCHAR(190) NOT NULL DEFAULT '',
|
||||
last_digest_at DATETIME NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, channel, scope_key),
|
||||
CONSTRAINT fk_engd_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- The worker's driving question is "whose email digest is due?", which is a
|
||||
-- range scan of this index rather than of every digest ever sent.
|
||||
INDEX idx_engd_due (channel, last_digest_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Carry the Team digest windows across, once. Replay-safe by construction: an
|
||||
-- INSERT IGNORE against the primary key, so the second and every later boot
|
||||
-- writes nothing, and a window the new worker has since MOVED is not dragged
|
||||
-- backwards by the next restart.
|
||||
--
|
||||
-- Rows with a NULL last_digest_at are copied too, and that is deliberate rather
|
||||
-- than incidental: `clampSince` treats a missing row and a NULL stamp the same
|
||||
-- way (reach back one interval, not to the floor), so the copy is faithful — and
|
||||
-- copying only the stamped rows would make the backfill's own idempotence depend
|
||||
-- on which rows happened to have fired.
|
||||
INSERT IGNORE INTO engagement_digest_state (user_id, channel, scope_key, last_digest_at)
|
||||
SELECT user_id, 'email', CONCAT('team:', team_id), last_digest_at
|
||||
FROM team_notification_prefs;
|
||||
|
||||
-- ── The in-app channel (ENGAGEMENT.md §4.5 G17 — Phase 7) ──────────────────
|
||||
|
||||
-- The inbox. Core, game-agnostic, and the first sink core owns that CARRIES its
|
||||
-- content: a push tickle deliberately holds none and an email leaves the
|
||||
-- building, so this is the one place a message both belongs to this deployment
|
||||
-- and can be read without a mailbox.
|
||||
--
|
||||
-- `dedupe_key` is the acceptance criterion, expressed as an index rather than as
|
||||
-- a check the writer has to remember: a replayed event, a retried outbox row and
|
||||
-- a module calling `ctx.inbox.push` twice all reduce to the same INSERT IGNORE.
|
||||
-- It is scoped to the USER (not to the rule and channel the outbox scopes by),
|
||||
-- because one event may legitimately be two outbox rows for one person — a rule
|
||||
-- spanning channels — and two inbox rows for it is one item shown twice.
|
||||
-- Multiple NULLs are permitted by a UNIQUE index, which is what "this item does
|
||||
-- not dedupe" means.
|
||||
--
|
||||
-- `url` is stored RELATIVE only, validated with the character class
|
||||
-- `pageUrlTemplate` and the engine's `url` variables already use: it ends up in
|
||||
-- an href on a page a signed-in user is looking at, and `//evil.test/x` passes
|
||||
-- every "is it rooted" check anyone writes by hand.
|
||||
CREATE TABLE IF NOT EXISTS user_notifications (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
trigger_id VARCHAR(96) NOT NULL,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
body TEXT NULL, -- rendered by the inapp template, sanitized on write
|
||||
url VARCHAR(500) NULL, -- relative only, validated like pageUrlTemplate
|
||||
dedupe_key VARCHAR(190) NULL,
|
||||
read_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_un_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_un_dedupe (user_id, dedupe_key),
|
||||
-- Both of the two questions this table is asked: "what is in my inbox" (the
|
||||
-- list, newest first) and "how many are unread" (the badge, on every page
|
||||
-- load). A single index answers both because `read_at` is IS NULL in one and
|
||||
-- unconstrained in the other, and `created_at` orders what is left.
|
||||
INDEX idx_un_unread (user_id, read_at, created_at),
|
||||
-- What the prune sweep queries. Without it the sweep is a table scan of every
|
||||
-- notification this deployment has ever written.
|
||||
INDEX idx_un_prune (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Deliverability: suppression and bounces (ENGAGEMENT.md §4.5 G16 — Phase 9) ──
|
||||
|
||||
-- The addresses this deployment has stopped mailing, and why.
|
||||
--
|
||||
-- **Keyed on the ADDRESS, not the user** (§4.5), and after Phase 1b that is a
|
||||
-- deliberate choice rather than a workaround for a missing unique index. Two
|
||||
-- accounts can no longer share an address, but a bounce arrives as an ADDRESS —
|
||||
-- it does not know which account was behind it, and it stays true after the
|
||||
-- account that held it changed its address or was deleted. Keying on the user
|
||||
-- would forget a dead mailbox the moment anybody moved.
|
||||
--
|
||||
-- `address_masked` is Phase 9's one addition to §4.5's DDL, and it exists because
|
||||
-- the hash-only table cannot be operated. An operator looking at a screen of
|
||||
-- sha256 digests cannot tell whether the list is three typos or a whole domain
|
||||
-- refusing mail, and un-suppressing somebody who fixed their mailbox is the one
|
||||
-- action this table has to support. `d***@example.com` is enough to act on and to
|
||||
-- see a domain-wide pattern in, and — the reason it is safe — the local part is
|
||||
-- destroyed rather than shortened, so the column is not an address book and
|
||||
-- cannot be turned back into one. It is NULLable because a row written from a
|
||||
-- correlation that only ever held a hash has nothing to mask.
|
||||
--
|
||||
-- **`reason` is not a synonym for "the send failed".** `mailer.PERMANENT_CODES`
|
||||
-- classifies a failure as not-worth-retrying, and that set contains EAUTH and 554
|
||||
-- — an authentication failure and a relay-wide policy refusal, neither of which
|
||||
-- is a fact about the recipient. Writing a suppression on every terminal failure
|
||||
-- would mean one wrong SMTP password suppresses every address the worker touches
|
||||
-- before anyone notices. Only recipient-scoped evidence reaches this table; see
|
||||
-- `src/engagement/bounceClassify.js`.
|
||||
CREATE TABLE IF NOT EXISTS engagement_suppressions (
|
||||
address_hash CHAR(64) NOT NULL PRIMARY KEY, -- sha256 of the lowercased address
|
||||
address_masked VARCHAR(190) NULL, -- d***@example.com; never the local part
|
||||
channel VARCHAR(32) NOT NULL DEFAULT 'email',
|
||||
reason ENUM('bounce','complaint','manual','unverified') NOT NULL,
|
||||
detail VARCHAR(500) NULL,
|
||||
created_by INT NULL, -- the admin, for a manual row; NULL for automatic
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_engsup_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- The screen's two orderings: newest first, and filtered by reason.
|
||||
INDEX idx_engsup_created (created_at),
|
||||
INDEX idx_engsup_reason (reason, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -4,6 +4,8 @@ const settingsDb = require('../src/model/settings/settings.db')
|
||||
const wikiDb = require('../src/model/wiki/wiki.db')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const { ensureSchema, close } = require('../src/utils/db')
|
||||
const { seedTemplates } = require('../src/engagement/templates')
|
||||
const { seedCoreRules } = require('../src/engagement/coreRules')
|
||||
const brand = require('../src/config/brand')
|
||||
|
||||
const log = require('../src/utils/logger')('seed')
|
||||
@@ -74,6 +76,19 @@ async function seedDefaults() {
|
||||
// migration of pages seeded before the wiki upgrade).
|
||||
await wikiDb.assignCategoryBySlug(slug, categorySlug)
|
||||
}
|
||||
// The shipped mail bodies (ENGAGEMENT.md §4.6.1). Idempotent, and it never
|
||||
// overwrites a row an operator has edited — `customized = 1` is checked in the
|
||||
// UPDATE's own WHERE, not in a read-then-write. Never throws: a template that
|
||||
// failed to seed costs the shipped default, which `renderByKey` falls back to
|
||||
// anyway, and must not stop a boot.
|
||||
await seedTemplates()
|
||||
// Core's five rules — the four Team ones (Phase 6) and news (Phase 11) —
|
||||
// seeded ONCE and all disabled. Each GROUP carries its own settings-key guard
|
||||
// rather than re-ensured, so a rule an operator deleted stays deleted and one
|
||||
// they enabled stays enabled; and so the news rule reaches the deployments that
|
||||
// were already stamped for Teams, which are exactly the ones that lose their
|
||||
// raw news push to the engine (ENGAGEMENT.md §7.1 Q9).
|
||||
await seedCoreRules()
|
||||
log.info('settings and wiki defaults ensured')
|
||||
}
|
||||
|
||||
|
||||
211
server/engagement-triggers.json
Normal file
211
server/engagement-triggers.json
Normal file
@@ -0,0 +1,211 @@
|
||||
{
|
||||
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
|
||||
"moduleApiVersion": "1.9.0",
|
||||
"triggers": [
|
||||
{
|
||||
"id": "news.post",
|
||||
"owner": "core",
|
||||
"label": "News post published",
|
||||
"description": "A news / Five-on-Friday / newsletter post was published.",
|
||||
"kind": "event",
|
||||
"subjectKey": null,
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Five on Friday — the Yew invasion",
|
||||
"description": "The post title."
|
||||
},
|
||||
{
|
||||
"name": "excerpt",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Four new champion spawns, and the fate of the Yew moongate…",
|
||||
"description": "A plain-text summary, already stripped of markup."
|
||||
},
|
||||
{
|
||||
"name": "category",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Five on Friday",
|
||||
"description": "The post category, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "postUrl",
|
||||
"type": "url",
|
||||
"required": true,
|
||||
"example": "/site/news",
|
||||
"description": "Site-relative path to the post. The news list today — the site has no per-post route."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team.announcement",
|
||||
"owner": "core",
|
||||
"label": "Team — announcement",
|
||||
"description": "A leader posted an announcement in a Team.",
|
||||
"kind": "event",
|
||||
"subjectKey": "teamName",
|
||||
"audience": "members",
|
||||
"ceiling": "members",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "teamName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Silver Anvil",
|
||||
"description": "The Team the event is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "authorName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Marisol",
|
||||
"description": "Display name of the leader who posted."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Siege practice moved to Sunday",
|
||||
"description": "The announcement title."
|
||||
},
|
||||
{
|
||||
"name": "excerpt",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "We are moving practice to Sunday 8pm…",
|
||||
"description": "Plain-text excerpt of the announcement body."
|
||||
},
|
||||
{
|
||||
"name": "postUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/guilds/the-silver-anvil/forum/419",
|
||||
"description": "Site-relative path to the announcement."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team.forum.post",
|
||||
"owner": "core",
|
||||
"label": "Team — new forum post",
|
||||
"description": "A new thread or reply in a Team forum.",
|
||||
"kind": "event",
|
||||
"subjectKey": "teamName",
|
||||
"audience": "members",
|
||||
"ceiling": "members",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "teamName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Silver Anvil",
|
||||
"description": "The Team the event is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "authorName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Darrow",
|
||||
"description": "Display name of the poster."
|
||||
},
|
||||
{
|
||||
"name": "threadTitle",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Tuesday champ rotation",
|
||||
"description": "Title of the thread the post belongs to."
|
||||
},
|
||||
{
|
||||
"name": "excerpt",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Moving the Tuesday run an hour later…",
|
||||
"description": "Plain-text excerpt of the post body, already stripped of markup."
|
||||
},
|
||||
{
|
||||
"name": "postUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/guilds/the-silver-anvil/forum/412",
|
||||
"description": "Site-relative path to the post."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team.leadership.changed",
|
||||
"owner": "core",
|
||||
"label": "Team — leadership change",
|
||||
"description": "Leadership changed in a Team.",
|
||||
"kind": "event",
|
||||
"subjectKey": "teamName",
|
||||
"audience": "members",
|
||||
"ceiling": "members",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "teamName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Silver Anvil",
|
||||
"description": "The Team the event is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "leaderName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Marisol",
|
||||
"description": "Display name of the new leader."
|
||||
},
|
||||
{
|
||||
"name": "teamUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/guilds/the-silver-anvil",
|
||||
"description": "Site-relative path to the Team page."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "team.member.joined",
|
||||
"owner": "core",
|
||||
"label": "Team — new member",
|
||||
"description": "Someone joined a Team.",
|
||||
"kind": "event",
|
||||
"subjectKey": "teamName",
|
||||
"audience": "members",
|
||||
"ceiling": "members",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "teamName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Silver Anvil",
|
||||
"description": "The Team the event is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "memberName",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "Darrow",
|
||||
"description": "Display name of the member who joined."
|
||||
},
|
||||
{
|
||||
"name": "teamUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/guilds/the-silver-anvil",
|
||||
"description": "Site-relative path to the Team page. Absent when no module supplies a pageUrlTemplate."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"seed": "node db/seed.js",
|
||||
"swagger": "node swagger/swagger.js",
|
||||
"routes:manifest": "node scripts/routeManifest.js",
|
||||
"engagement:manifest": "node scripts/engagementManifest.js",
|
||||
"test": "node --test --require ./test/_setup.js"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -27,66 +27,6 @@
|
||||
"handlers": 1,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/account",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/account/identities",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/account/identities/:provider",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/disable",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/enable",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/setup",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/activity",
|
||||
@@ -199,7 +139,7 @@
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/email/config",
|
||||
"handlers": 5,
|
||||
"handlers": 9,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
@@ -207,24 +147,6 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/email/connect/callback",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/email/connect/start",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/email/disconnect",
|
||||
@@ -245,6 +167,231 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/audience-preview",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/audiences",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/channels",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/rules",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/rules",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/rules/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/rules/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/rules/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/admin/engagement/rules/:id/enabled",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/segments",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/segments",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/segments/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/segments/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/sends",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/suppressions",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/suppressions",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/suppressions",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/templates/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/templates/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/templates/:id/duplicate",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/templates/:id/preview",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/templates/:id/test-send",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/triggers",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/invites",
|
||||
@@ -1098,6 +1245,24 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/email-dedupe-report",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/users/email-dedupe-report/acknowledge",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki",
|
||||
@@ -1240,6 +1405,24 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/email/verify/:token",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/email/verify/:token",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/invite/:token",
|
||||
@@ -1304,6 +1487,35 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/auth/me/account/email",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/account/email/pending",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/account/email/resend",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account/identities",
|
||||
@@ -1429,6 +1641,57 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/notifications/:id/read",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/channels",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/auth/me/notifications/channels",
|
||||
"handlers": 6,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/notifications/read-all",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/streams",
|
||||
@@ -1478,6 +1741,15 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/unread-count",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/sessions",
|
||||
@@ -1658,88 +1930,6 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/account",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/account/identities",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/account/identities/:provider",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/account/password",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/disable",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/enable",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/setup",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/account/username",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/appeals",
|
||||
@@ -1945,6 +2135,18 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/engagement/unsubscribe/:token",
|
||||
"handlers": 1,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/engagement/unsubscribe/:token",
|
||||
"handlers": 1,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/modules",
|
||||
|
||||
@@ -17,30 +17,6 @@
|
||||
"method": "GET",
|
||||
"path": "/api/health"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/account/identities"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/account/identities/:provider"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/disable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/enable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/account/totp/setup"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/activity"
|
||||
@@ -89,14 +65,6 @@
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/email/config"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/email/connect/callback"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/email/connect/start"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/email/disconnect"
|
||||
@@ -105,6 +73,106 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/email/test"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/audience-preview"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/audiences"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/channels"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/rules"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/rules"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/rules/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/rules/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/rules/:id"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/admin/engagement/rules/:id/enabled"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/segments"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/segments"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/segments/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/segments/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/sends"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/suppressions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/suppressions"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/suppressions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/templates/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/templates/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/templates/:id/duplicate"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/templates/:id/preview"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/engagement/templates/:id/test-send"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/triggers"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/invites"
|
||||
@@ -433,6 +501,14 @@
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/trusted-devices/:deviceId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/email-dedupe-report"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/users/email-dedupe-report/acknowledge"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki"
|
||||
@@ -489,6 +565,14 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/wiki/tags"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/email/verify/:token"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/email/verify/:token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/invite/:token"
|
||||
@@ -517,6 +601,18 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/auth/me/account/email"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/account/email/pending"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/account/email/resend"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/account/identities"
|
||||
@@ -565,6 +661,26 @@
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/devices/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/notifications/:id/read"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/channels"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/auth/me/notifications/channels"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/me/notifications/read-all"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/streams"
|
||||
@@ -585,6 +701,10 @@
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/auth/me/notifications/teams"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/notifications/unread-count"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/auth/me/sessions"
|
||||
@@ -665,38 +785,6 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/sso/totp"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/account/identities"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/account/identities/:provider"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/account/password"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/disable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/enable"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/account/totp/setup"
|
||||
},
|
||||
{
|
||||
"method": "PATCH",
|
||||
"path": "/api/v1/player/account/username"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/appeals"
|
||||
@@ -777,6 +865,14 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/contact"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/engagement/unsubscribe/:token"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/engagement/unsubscribe/:token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/modules"
|
||||
|
||||
132
server/scripts/engagementManifest.js
Normal file
132
server/scripts/engagementManifest.js
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Engagement trigger manifest — the machine-readable freeze of core's event
|
||||
* contract (ENGAGEMENT.md §4.3, property 4).
|
||||
*
|
||||
* Why this exists: a trigger declaration is what a template interpolates and what
|
||||
* a rule is written against. Renaming a variable, changing its type, or widening
|
||||
* a ceiling breaks stored templates and stored rules — and does it silently, at
|
||||
* send time, in an email someone already received. `routes.manifest.json` freezes
|
||||
* the URL surface for exactly this reason and this is its twin: a generated
|
||||
* artifact committed to the repo, whose DIFF is the review signal. Changing a
|
||||
* declaration without regenerating is a red build; changing one deliberately puts
|
||||
* the change in front of a reviewer instead of letting it pass as a comment edit.
|
||||
*
|
||||
* **Core's only.** A module ships its own `engagement-triggers.json` in its
|
||||
* bundle, for the same reason it ships a prebuilt swagger fragment: core never
|
||||
* has its sources to analyse (MODULE_API.md §6.1a). So this loads
|
||||
* `config/coreTriggers.js` through the real `registerCore()` — the declarations
|
||||
* as VALIDATED, not as authored — which means a shape error is a failure here
|
||||
* rather than a surprise at boot.
|
||||
*
|
||||
* The `resolve` half of an audience cannot be frozen (it is a function over a
|
||||
* module's own store), so audiences are deliberately absent: what a manifest can
|
||||
* usefully freeze is the payload contract, and freezing half a declaration would
|
||||
* suggest the other half was checked.
|
||||
*
|
||||
* Usage:
|
||||
* npm run engagement:manifest # write server/engagement-triggers.json
|
||||
* npm run engagement:manifest -- --check # exit 1 if the committed file is stale
|
||||
*/
|
||||
|
||||
// registries.js -> config/coreStreams + utils/discordAnnounce, which reach
|
||||
// utils/db and build a mariadb pool at require time. Point it at a closed port
|
||||
// (the same trick routeManifest.js and the test suite use) so generating a
|
||||
// manifest never opens a connection or hangs on a missing database.
|
||||
process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1'
|
||||
process.env.DB_PORT = process.env.DB_PORT || '59999'
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const registries = require('../src/modules/registries')
|
||||
const db = require('../src/utils/db')
|
||||
const { MODULE_API_VERSION } = require('../src/modules/version')
|
||||
|
||||
const SERVER_ROOT = path.join(__dirname, '..')
|
||||
const MANIFEST_PATH = path.join(SERVER_ROOT, 'engagement-triggers.json')
|
||||
|
||||
const MANIFEST_COMMENT =
|
||||
'Generated event-trigger inventory - the authoritative freeze of CORE\'s engagement ' +
|
||||
'contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` ' +
|
||||
'in website/server. A renamed variable, a changed type or a widened ceiling breaks stored ' +
|
||||
'templates and rules, so the diff here is the review signal. A module ships its own copy ' +
|
||||
'in its bundle; this file never contains one.'
|
||||
|
||||
function build() {
|
||||
// Through registerCore(), not by reading the array: what a reviewer needs
|
||||
// frozen is what the registry ACCEPTED — defaults filled in, audience resolved
|
||||
// against the ceiling, variables normalised — because that is what the editor
|
||||
// will read and the emit path will check against.
|
||||
registries.registerCore()
|
||||
|
||||
const triggers = registries
|
||||
.allTriggers()
|
||||
.filter((t) => t.owner === 'core')
|
||||
// Sorted by id rather than left in registration order, like the route
|
||||
// manifest: reordering a declaration in the source is not a contract change
|
||||
// and must not produce a diff that looks like one.
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
owner: t.owner,
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
kind: t.kind,
|
||||
subjectKey: t.subjectKey,
|
||||
audience: t.audience,
|
||||
ceiling: t.ceiling,
|
||||
version: t.version,
|
||||
// Variables keep their DECLARED order. Here it is contract: it is the
|
||||
// order the template editor lists them in, and an author reading the
|
||||
// manifest should see what the editor will show.
|
||||
variables: t.variables.map((v) => ({
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
required: v.required,
|
||||
example: v.example,
|
||||
description: v.description,
|
||||
})),
|
||||
}))
|
||||
|
||||
return {
|
||||
_comment: MANIFEST_COMMENT,
|
||||
// The contract version these declarations are shaped by. A reader looking at
|
||||
// a stale manifest needs to know which API's rules produced it.
|
||||
moduleApiVersion: MODULE_API_VERSION,
|
||||
triggers,
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const check = process.argv.includes('--check')
|
||||
const next = `${JSON.stringify(build(), null, 2)}\n`
|
||||
|
||||
if (!check) {
|
||||
fs.writeFileSync(MANIFEST_PATH, next)
|
||||
process.stdout.write(`wrote ${path.relative(SERVER_ROOT, MANIFEST_PATH)}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
const current = fs.existsSync(MANIFEST_PATH) ? fs.readFileSync(MANIFEST_PATH, 'utf8') : ''
|
||||
if (current === next) {
|
||||
process.stdout.write('engagement-triggers.json is current\n')
|
||||
return
|
||||
}
|
||||
process.stderr.write(
|
||||
'engagement-triggers.json is stale.\n' +
|
||||
'A trigger declaration changed without the manifest being regenerated.\n' +
|
||||
'Run `npm run engagement:manifest` in website/server and commit the result —\n' +
|
||||
'the diff is what a reviewer reads to see the contract change.\n',
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main()
|
||||
// The mariadb pool never connects here, but it keeps the loop alive even
|
||||
// pointed at a dead port — the same exit routeManifest.js takes.
|
||||
db.close().finally(() => process.exit(process.exitCode || 0))
|
||||
}
|
||||
|
||||
module.exports = { build }
|
||||
@@ -192,6 +192,15 @@ app.use('/api', apiRouter)
|
||||
// module's collision checks are asked against what is ALREADY registered, so
|
||||
// core's streams, its announce leg and its extension-slot fill have to be there
|
||||
// before the first module registers anything (MODULE_SYSTEM.md §1.8).
|
||||
// The engagement subsystem's own door, which is what brings core's mail
|
||||
// transports and its three delivery channels into existence (ENGAGEMENT.md
|
||||
// §3.1). Requiring `engagement/channels` or `engagement/transports` directly gets
|
||||
// the empty registry — populating it is deliberately a side effect of this one
|
||||
// require, so there is exactly one place either can be registered from. It runs
|
||||
// beside registerCore() and before the loader for the same reason: a preference
|
||||
// read or a mail send must never find a half-populated registry.
|
||||
require('./engagement')
|
||||
|
||||
registries.registerCore()
|
||||
modules.load({
|
||||
public: require('./router/v1/public'),
|
||||
|
||||
@@ -37,7 +37,7 @@ class BaseProvider {
|
||||
}
|
||||
|
||||
// Complete an SSO redirect flow: exchange the callback code for a normalized
|
||||
// user profile ({ subject, email, name }).
|
||||
// user profile ({ subject, email, emailVerified, name }).
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async handleCallback(params) {
|
||||
throw new Error(`handleCallback() not implemented for provider '${this.id}'`)
|
||||
@@ -49,7 +49,7 @@ class BaseProvider {
|
||||
throw new Error(`getUserProfile() not implemented for provider '${this.id}'`)
|
||||
}
|
||||
|
||||
// Normalize a raw external profile to { subject, email, name }.
|
||||
// Normalize a raw external profile to { subject, email, emailVerified, name }.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
mapUser(profile) {
|
||||
throw new Error(`mapUser() not implemented for provider '${this.id}'`)
|
||||
|
||||
@@ -23,7 +23,14 @@ class DiscordProvider extends OAuth2Provider {
|
||||
}
|
||||
normalizeProfile(p = {}) {
|
||||
// global_name is the new display name; fall back to the legacy username.
|
||||
return { subject: p.id, email: p.email || null, name: p.global_name || p.username || null }
|
||||
return {
|
||||
subject: p.id,
|
||||
email: p.email || null,
|
||||
// Discord spells the claim `verified` rather than `email_verified`, and it
|
||||
// means exactly this: the user confirmed the address with Discord.
|
||||
emailVerified: p.verified === true,
|
||||
name: p.global_name || p.username || null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ class GenericOidcProvider extends OAuth2Provider {
|
||||
return {
|
||||
subject: p.sub || p.id || p.user_id || p.uid || null,
|
||||
email: p.email || null,
|
||||
// The standard OIDC claim. An IdP that omits it has not asserted anything,
|
||||
// so the address stays unverified and the user proves it the ordinary way —
|
||||
// absent is treated as false, never as true.
|
||||
emailVerified: p.email_verified === true || p.email_verified === 'true',
|
||||
name: p.name || p.preferred_username || p.username || p.email || null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,15 @@ class GoogleProvider extends OAuth2Provider {
|
||||
return { access_type: 'online', prompt: 'select_account' }
|
||||
}
|
||||
normalizeProfile(p = {}) {
|
||||
return { subject: p.sub, email: p.email || null, name: p.name || p.email || null }
|
||||
return {
|
||||
subject: p.sub,
|
||||
email: p.email || null,
|
||||
// Google's OIDC userinfo carries the standard `email_verified` claim. Read
|
||||
// it rather than inferring verification from the mere presence of an
|
||||
// address, which is what this code used to do (ENGAGEMENT.md §0.6/1b).
|
||||
emailVerified: p.email_verified === true || p.email_verified === 'true',
|
||||
name: p.name || p.email || null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,44 +4,58 @@
|
||||
// normalizer (e.g. rich_text runs its html through the allowlist), stamping the
|
||||
// registry `version`, defaulting `visible` to true, and recursing one level into
|
||||
// container slots. Returns a new array; never mutates the input.
|
||||
//
|
||||
// Parameterized by a registry lookup for the same reason validateBlocks is
|
||||
// (engagement Phase 5a): the `email.*` family is a separate registry and must get
|
||||
// the same validate-then-sanitize order, not a second implementation of it.
|
||||
|
||||
const { getBlock } = require('./registry')
|
||||
|
||||
function sanitizeBlocks(blocks) {
|
||||
if (!Array.isArray(blocks)) return []
|
||||
return blocks.map(sanitizeOne)
|
||||
}
|
||||
/**
|
||||
* Build a blocks sanitizer bound to one registry.
|
||||
* @param {(type: string) => object|null} lookup registry `getBlock`
|
||||
* @returns {(blocks: unknown) => object[]}
|
||||
*/
|
||||
function makeSanitizeBlocks(lookup) {
|
||||
function sanitizeOne(block) {
|
||||
const def = lookup(block.type)
|
||||
if (!def) return block // unreachable after validation, but stay defensive
|
||||
|
||||
function sanitizeOne(block) {
|
||||
const def = getBlock(block.type)
|
||||
if (!def) return block // unreachable after validation, but stay defensive
|
||||
let props = block.props && typeof block.props === 'object' ? { ...block.props } : {}
|
||||
|
||||
let props = block.props && typeof block.props === 'object' ? { ...block.props } : {}
|
||||
// Recurse into container slots first (leaf sub-blocks get sanitized too).
|
||||
if (def.container) {
|
||||
for (const slot of def.containerSlots) {
|
||||
if (Array.isArray(props[slot])) props[slot] = props[slot].map(sanitizeOne)
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into container slots first (leaf sub-blocks get sanitized too).
|
||||
if (def.container) {
|
||||
for (const slot of def.containerSlots) {
|
||||
if (Array.isArray(props[slot])) props[slot] = props[slot].map(sanitizeOne)
|
||||
// Apply the block's own normalizer last (operates on its scalar props).
|
||||
if (def.sanitize) {
|
||||
try {
|
||||
props = def.sanitize(props)
|
||||
} catch {
|
||||
// Leave props as-is; validation already passed, a sanitize throw shouldn't
|
||||
// block the save.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: block.id,
|
||||
type: block.type,
|
||||
version: Number.isInteger(block.version) ? block.version : def.version,
|
||||
visible: block.visible !== false,
|
||||
props,
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the block's own normalizer last (operates on its scalar props).
|
||||
if (def.sanitize) {
|
||||
try {
|
||||
props = def.sanitize(props)
|
||||
} catch {
|
||||
// Leave props as-is; validation already passed, a sanitize throw shouldn't
|
||||
// block the save.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: block.id,
|
||||
type: block.type,
|
||||
version: Number.isInteger(block.version) ? block.version : def.version,
|
||||
visible: block.visible !== false,
|
||||
props,
|
||||
return function sanitizeBlocks(blocks) {
|
||||
if (!Array.isArray(blocks)) return []
|
||||
return blocks.map(sanitizeOne)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sanitizeBlocks }
|
||||
// The page-registry binding — the export every existing caller already uses.
|
||||
const sanitizeBlocks = makeSanitizeBlocks(getBlock)
|
||||
|
||||
module.exports = { sanitizeBlocks, makeSanitizeBlocks }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Server-side validation for a page's `blocks` array, run on every save before
|
||||
// Server-side validation for a stored `blocks` array, run on every save before
|
||||
// persisting. The admin UI validates client-side too, but that can be bypassed
|
||||
// by a direct API call, so this is the authoritative gate: it enforces the block
|
||||
// envelope (reserved keys only), that every `type` is a registered block, that
|
||||
@@ -9,6 +9,14 @@
|
||||
// Returns { valid, errors } — a flat list of human-readable error strings, each
|
||||
// prefixed with the path to the offending block (e.g. `blocks[2].props.text`).
|
||||
// It never throws on bad input; callers turn a non-empty `errors` into a 400.
|
||||
//
|
||||
// **The walk is parameterized by a registry lookup, and the page registry is one
|
||||
// binding of it** (engagement Phase 5a). The `email.*` family is a SEPARATE
|
||||
// registry — its entries carry renderers instead of a cache policy, and a
|
||||
// CMS page must not validate with an email block inside it — but the envelope,
|
||||
// the id uniqueness, the schema dispatch and the nesting cap are the same rules
|
||||
// for both. Sharing the walk is what keeps them the same rules rather than two
|
||||
// copies that drift.
|
||||
|
||||
const { getBlock, RESERVED_KEYS } = require('./registry')
|
||||
|
||||
@@ -18,114 +26,129 @@ const MAX_SUBBLOCKS = 50 // sub-blocks per container slot
|
||||
const ID_RE = /^[A-Za-z0-9_-]{1,40}$/
|
||||
|
||||
/**
|
||||
* Validate a stored blocks array against the registry.
|
||||
* @param {unknown} blocks
|
||||
* @returns {{ valid: boolean, errors: string[] }}
|
||||
* Build a blocks validator bound to one registry.
|
||||
*
|
||||
* @param {(type: string) => object|null} lookup registry `getBlock`
|
||||
* @param {{ maxBlocks?: number, maxSubBlocks?: number }} [limits]
|
||||
* @returns {(blocks: unknown) => { valid: boolean, errors: string[] }}
|
||||
*/
|
||||
function validateBlocks(blocks) {
|
||||
const errors = []
|
||||
if (!Array.isArray(blocks)) {
|
||||
return { valid: false, errors: ['blocks must be an array'] }
|
||||
}
|
||||
if (blocks.length > MAX_BLOCKS) {
|
||||
errors.push(`blocks may not exceed ${MAX_BLOCKS} top-level entries`)
|
||||
}
|
||||
const seenIds = new Set()
|
||||
blocks.forEach((block, i) => {
|
||||
validateBlock(block, `blocks[${i}]`, seenIds, errors, { nested: false })
|
||||
})
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
function makeValidateBlocks(lookup, limits = {}) {
|
||||
const maxBlocks = limits.maxBlocks || MAX_BLOCKS
|
||||
const maxSubBlocks = limits.maxSubBlocks || MAX_SUBBLOCKS
|
||||
|
||||
// Envelope: only the reserved keys, nothing smuggled at the top level.
|
||||
function checkEnvelope(block, path, errors) {
|
||||
for (const key of Object.keys(block)) {
|
||||
if (!RESERVED_KEYS.includes(key)) {
|
||||
errors.push(`${path}.${key} is not an allowed top-level key`)
|
||||
// Envelope: only the reserved keys, nothing smuggled at the top level.
|
||||
function checkEnvelope(block, path, errors) {
|
||||
for (const key of Object.keys(block)) {
|
||||
if (!RESERVED_KEYS.includes(key)) {
|
||||
errors.push(`${path}.${key} is not an allowed top-level key`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// id — stable, unique across the whole page (top-level and nested share one
|
||||
// namespace since ids are the future join point for revision history).
|
||||
function checkId(block, path, seenIds, errors) {
|
||||
if (typeof block.id !== 'string' || !ID_RE.test(block.id)) {
|
||||
errors.push(`${path}.id must be a short id string`)
|
||||
} else if (seenIds.has(block.id)) {
|
||||
errors.push(`${path}.id duplicates another block id (${block.id})`)
|
||||
} else {
|
||||
seenIds.add(block.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-block prop schema from the registry (skipped when props isn't an object —
|
||||
// that's already reported separately).
|
||||
function checkPropSchema(def, props, path, errors) {
|
||||
if (!def.schema || !props || typeof props !== 'object') return
|
||||
let schemaErrors = []
|
||||
try {
|
||||
schemaErrors = def.schema(props) || []
|
||||
} catch (err) {
|
||||
schemaErrors = [`schema threw: ${err.message}`]
|
||||
}
|
||||
for (const e of schemaErrors) errors.push(`${path}.props.${e}`)
|
||||
}
|
||||
|
||||
// Nesting: only container blocks may hold sub-blocks, capped at one level.
|
||||
function checkNesting(def, props, path, seenIds, errors, nested) {
|
||||
if (nested) {
|
||||
errors.push(`${path} is a container and may not be nested inside another container`)
|
||||
return
|
||||
}
|
||||
for (const slot of def.containerSlots) {
|
||||
const sub = props ? props[slot] : undefined
|
||||
if (sub === undefined) continue // an empty slot is allowed
|
||||
if (!Array.isArray(sub)) {
|
||||
errors.push(`${path}.props.${slot} must be an array of blocks`)
|
||||
continue
|
||||
// id — stable, unique across the whole document (top-level and nested share one
|
||||
// namespace since ids are the future join point for revision history).
|
||||
function checkId(block, path, seenIds, errors) {
|
||||
if (typeof block.id !== 'string' || !ID_RE.test(block.id)) {
|
||||
errors.push(`${path}.id must be a short id string`)
|
||||
} else if (seenIds.has(block.id)) {
|
||||
errors.push(`${path}.id duplicates another block id (${block.id})`)
|
||||
} else {
|
||||
seenIds.add(block.id)
|
||||
}
|
||||
if (sub.length > MAX_SUBBLOCKS) {
|
||||
errors.push(`${path}.props.${slot} may not exceed ${MAX_SUBBLOCKS} blocks`)
|
||||
}
|
||||
|
||||
// Per-block prop schema from the registry (skipped when props isn't an object —
|
||||
// that's already reported separately).
|
||||
function checkPropSchema(def, props, path, errors) {
|
||||
if (!def.schema || !props || typeof props !== 'object') return
|
||||
let schemaErrors = []
|
||||
try {
|
||||
schemaErrors = def.schema(props) || []
|
||||
} catch (err) {
|
||||
schemaErrors = [`schema threw: ${err.message}`]
|
||||
}
|
||||
sub.forEach((child, j) => {
|
||||
validateBlock(child, `${path}.props.${slot}[${j}]`, seenIds, errors, { nested: true })
|
||||
for (const e of schemaErrors) errors.push(`${path}.props.${e}`)
|
||||
}
|
||||
|
||||
// Nesting: only container blocks may hold sub-blocks, capped at one level.
|
||||
function checkNesting(def, props, path, seenIds, errors, nested) {
|
||||
if (nested) {
|
||||
errors.push(`${path} is a container and may not be nested inside another container`)
|
||||
return
|
||||
}
|
||||
for (const slot of def.containerSlots) {
|
||||
const sub = props ? props[slot] : undefined
|
||||
if (sub === undefined) continue // an empty slot is allowed
|
||||
if (!Array.isArray(sub)) {
|
||||
errors.push(`${path}.props.${slot} must be an array of blocks`)
|
||||
continue
|
||||
}
|
||||
if (sub.length > maxSubBlocks) {
|
||||
errors.push(`${path}.props.${slot} may not exceed ${maxSubBlocks} blocks`)
|
||||
}
|
||||
sub.forEach((child, j) => {
|
||||
validateBlock(child, `${path}.props.${slot}[${j}]`, seenIds, errors, { nested: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one block envelope in place. `nested` = true when validating a
|
||||
* sub-block inside a container slot, which forbids further nesting.
|
||||
*/
|
||||
function validateBlock(block, path, seenIds, errors, { nested }) {
|
||||
if (block === null || typeof block !== 'object' || Array.isArray(block)) {
|
||||
errors.push(`${path} must be an object`)
|
||||
return
|
||||
}
|
||||
|
||||
checkEnvelope(block, path, errors)
|
||||
checkId(block, path, seenIds, errors)
|
||||
|
||||
// visible — optional in input, but if present must be a boolean.
|
||||
if (block.visible !== undefined && typeof block.visible !== 'boolean') {
|
||||
errors.push(`${path}.visible must be a boolean`)
|
||||
}
|
||||
|
||||
// props — always an object bag.
|
||||
const props = block.props
|
||||
if (props === null || typeof props !== 'object' || Array.isArray(props)) {
|
||||
errors.push(`${path}.props must be an object`)
|
||||
}
|
||||
|
||||
// type — must resolve to a registered block.
|
||||
const def = typeof block.type === 'string' ? lookup(block.type) : null
|
||||
if (!def) {
|
||||
errors.push(`${path}.type is not a registered block type (${String(block.type)})`)
|
||||
return // can't validate props or nesting without a definition
|
||||
}
|
||||
|
||||
checkPropSchema(def, props, path, errors)
|
||||
if (def.container) checkNesting(def, props, path, seenIds, errors, nested)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a stored blocks array against the bound registry.
|
||||
* @param {unknown} blocks
|
||||
* @returns {{ valid: boolean, errors: string[] }}
|
||||
*/
|
||||
return function validateBlocks(blocks) {
|
||||
const errors = []
|
||||
if (!Array.isArray(blocks)) {
|
||||
return { valid: false, errors: ['blocks must be an array'] }
|
||||
}
|
||||
if (blocks.length > maxBlocks) {
|
||||
errors.push(`blocks may not exceed ${maxBlocks} top-level entries`)
|
||||
}
|
||||
const seenIds = new Set()
|
||||
blocks.forEach((block, i) => {
|
||||
validateBlock(block, `blocks[${i}]`, seenIds, errors, { nested: false })
|
||||
})
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one block envelope in place. `nested` = true when validating a
|
||||
* sub-block inside a container slot, which forbids further nesting.
|
||||
*/
|
||||
function validateBlock(block, path, seenIds, errors, { nested }) {
|
||||
if (block === null || typeof block !== 'object' || Array.isArray(block)) {
|
||||
errors.push(`${path} must be an object`)
|
||||
return
|
||||
}
|
||||
// The page-registry binding — the export every existing caller already uses.
|
||||
const validateBlocks = makeValidateBlocks(getBlock)
|
||||
|
||||
checkEnvelope(block, path, errors)
|
||||
checkId(block, path, seenIds, errors)
|
||||
|
||||
// visible — optional in input, but if present must be a boolean.
|
||||
if (block.visible !== undefined && typeof block.visible !== 'boolean') {
|
||||
errors.push(`${path}.visible must be a boolean`)
|
||||
}
|
||||
|
||||
// props — always an object bag.
|
||||
const props = block.props
|
||||
if (props === null || typeof props !== 'object' || Array.isArray(props)) {
|
||||
errors.push(`${path}.props must be an object`)
|
||||
}
|
||||
|
||||
// type — must resolve to a registered block.
|
||||
const def = typeof block.type === 'string' ? getBlock(block.type) : null
|
||||
if (!def) {
|
||||
errors.push(`${path}.type is not a registered block type (${String(block.type)})`)
|
||||
return // can't validate props or nesting without a definition
|
||||
}
|
||||
|
||||
checkPropSchema(def, props, path, errors)
|
||||
if (def.container) checkNesting(def, props, path, seenIds, errors, nested)
|
||||
}
|
||||
|
||||
module.exports = { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS }
|
||||
module.exports = { validateBlocks, makeValidateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS }
|
||||
|
||||
154
server/src/config/coreTriggers.js
Normal file
154
server/src/config/coreTriggers.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// ── Core's own engagement triggers ─────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.3 and Phase 2. The twin of config/coreStreams.js, and
|
||||
// deliberately the SAME FIVE IDS — that is the org lead's §7.2 decision, taken at
|
||||
// the start of this phase: **one namespace.** A trigger is not a second thing
|
||||
// standing next to a stream; it is a payload contract attached to an id that may
|
||||
// also carry a subscription toggle. `news.post` names one event, whether the
|
||||
// question being asked of it is "may I push this?" or "what may a template
|
||||
// interpolate?".
|
||||
//
|
||||
// What that buys, concretely: `notification_channel_prefs.stream_id` (§4.5) stays
|
||||
// single-keyed. Under two namespaces it would have needed a `kind` discriminator
|
||||
// in its primary key, and `news.post` would have named two different things
|
||||
// forever.
|
||||
//
|
||||
// What it costs is the rule enforced in registries.js: an id has ONE owner across
|
||||
// both facets, so a module cannot attach a payload contract to another module's
|
||||
// stream, and core cannot attach one to a module's. Core's five ids below are
|
||||
// already core's five streams, so all five are the same-owner upgrade case.
|
||||
//
|
||||
// **These declare; nothing here emits yet.** Phase 2 is the contract only — the
|
||||
// Team pipeline keeps its own hardcoded mail until Phase 6 migrates it onto the
|
||||
// engine, and this file is what it migrates ONTO. Registering the declarations a
|
||||
// phase early is the same decision registerCore() has always taken: a registry
|
||||
// whose first real exercise is a module is a registry that has already drifted.
|
||||
//
|
||||
// Every variable carries an `example`, and that is required rather than
|
||||
// decorative (§4.3 property 3). It is what lets the template editor preview and
|
||||
// test-send without a live game event, which is the reason template systems go
|
||||
// untested.
|
||||
|
||||
const TRIGGERS = [
|
||||
{
|
||||
id: 'news.post',
|
||||
label: 'News post published',
|
||||
description: 'A news / Five-on-Friday / newsletter post was published.',
|
||||
kind: 'event',
|
||||
// No subjectKey. The subject of a cooldown here is the USER, not the post —
|
||||
// "do not mail me about news more than once an hour" is the useful rule, and
|
||||
// keying it per post would make every cooldown a no-op. Compare the four
|
||||
// Team triggers below, where the Team genuinely is the subject.
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'title', type: 'string', required: true, example: 'Five on Friday — the Yew invasion',
|
||||
description: 'The post title.' },
|
||||
{ name: 'excerpt', type: 'string', required: false, example: 'Four new champion spawns, and the fate of the Yew moongate…',
|
||||
description: 'A plain-text summary, already stripped of markup.' },
|
||||
{ name: 'category', type: 'string', required: false, example: 'Five on Friday',
|
||||
description: 'The post category, when it has one.' },
|
||||
// **`/site/news`, the LIST, and not a per-post path.** The example said
|
||||
// `/news/<slug>` when this was declared with no caller; Phase 11 gave it
|
||||
// one and the path turned out not to exist — `App.jsx` mounts `/site/news`
|
||||
// and nothing under it, which is why `announceJobs.logic.js` links the list
|
||||
// from the Discord and town-crier announcements too. An `example` is what
|
||||
// the template editor previews and test-sends with (§4.3 property 3), so an
|
||||
// example naming a 404 is a preview that looks right and a mail that is not.
|
||||
{ name: 'postUrl', type: 'url', required: true, example: '/site/news',
|
||||
description: 'Site-relative path to the post. The news list today — the site has no per-post route.' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── Teams (TEAMS.md Part 6) ─────────────────────────────────────────────
|
||||
//
|
||||
// All four ceiling at `members` and not one of them higher. Who may be told
|
||||
// about a Team event is the access resolver's answer and always has been
|
||||
// (coreStreams.js says the same thing about the push catalog); the ceiling is
|
||||
// that rule written where a RULE EDITOR has to obey it too. Without it an
|
||||
// operator could point a rule at `authenticated` and mail a private Team's
|
||||
// forum excerpt to the whole site.
|
||||
{
|
||||
id: 'team.member.joined',
|
||||
label: 'Team — new member',
|
||||
description: 'Someone joined a Team.',
|
||||
kind: 'event',
|
||||
subjectKey: 'teamName',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
|
||||
description: 'The Team the event is about. Also the cooldown subject.' },
|
||||
{ name: 'memberName', type: 'string', required: true, example: 'Darrow',
|
||||
description: 'Display name of the member who joined.' },
|
||||
{ name: 'teamUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil',
|
||||
description: 'Site-relative path to the Team page. Absent when no module supplies a pageUrlTemplate.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'team.leadership.changed',
|
||||
label: 'Team — leadership change',
|
||||
description: 'Leadership changed in a Team.',
|
||||
kind: 'event',
|
||||
subjectKey: 'teamName',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
|
||||
description: 'The Team the event is about. Also the cooldown subject.' },
|
||||
{ name: 'leaderName', type: 'string', required: true, example: 'Marisol',
|
||||
description: 'Display name of the new leader.' },
|
||||
{ name: 'teamUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil',
|
||||
description: 'Site-relative path to the Team page.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'team.forum.post',
|
||||
label: 'Team — new forum post',
|
||||
description: 'A new thread or reply in a Team forum.',
|
||||
kind: 'event',
|
||||
subjectKey: 'teamName',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
|
||||
description: 'The Team the event is about. Also the cooldown subject.' },
|
||||
{ name: 'authorName', type: 'string', required: true, example: 'Darrow',
|
||||
description: 'Display name of the poster.' },
|
||||
{ name: 'threadTitle', type: 'string', required: true, example: 'Tuesday champ rotation',
|
||||
description: 'Title of the thread the post belongs to.' },
|
||||
{ name: 'excerpt', type: 'string', required: false, example: 'Moving the Tuesday run an hour later…',
|
||||
description: 'Plain-text excerpt of the post body, already stripped of markup.' },
|
||||
{ name: 'postUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil/forum/412',
|
||||
description: 'Site-relative path to the post.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'team.announcement',
|
||||
label: 'Team — announcement',
|
||||
description: 'A leader posted an announcement in a Team.',
|
||||
kind: 'event',
|
||||
subjectKey: 'teamName',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
|
||||
description: 'The Team the event is about. Also the cooldown subject.' },
|
||||
{ name: 'authorName', type: 'string', required: true, example: 'Marisol',
|
||||
description: 'Display name of the leader who posted.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'Siege practice moved to Sunday',
|
||||
description: 'The announcement title.' },
|
||||
{ name: 'excerpt', type: 'string', required: false, example: 'We are moving practice to Sunday 8pm…',
|
||||
description: 'Plain-text excerpt of the announcement body.' },
|
||||
{ name: 'postUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil/forum/419',
|
||||
description: 'Site-relative path to the announcement.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { TRIGGERS }
|
||||
28
server/src/emailBlocks/index.js
Normal file
28
server/src/emailBlocks/index.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Email block registry entrypoint. Requiring this module registers every
|
||||
// `email.*` block definition exactly once, then re-exports the registry API, the
|
||||
// renderer and the registry-bound validator/sanitizer. Anything that needs to
|
||||
// validate or render a mail template's blocks should require THIS module, not
|
||||
// ./registry or ./render directly, so the definitions are guaranteed loaded.
|
||||
//
|
||||
// Same shape as `blocks/index.js`, on purpose — the two families are siblings
|
||||
// (see ./registry.js for why they are not one registry).
|
||||
|
||||
const registry = require('./registry')
|
||||
const render = require('./render')
|
||||
const interpolate = require('./interpolate')
|
||||
const variables = require('./variables')
|
||||
|
||||
// ── Block definitions (self-register on require) ───────────────────────────
|
||||
require('./types/heading')
|
||||
require('./types/text')
|
||||
require('./types/button')
|
||||
require('./types/divider')
|
||||
require('./types/image')
|
||||
require('./types/itemList')
|
||||
|
||||
module.exports = {
|
||||
...registry,
|
||||
...render,
|
||||
...interpolate,
|
||||
...variables,
|
||||
}
|
||||
79
server/src/emailBlocks/interpolate.js
Normal file
79
server/src/emailBlocks/interpolate.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// ── Template variable interpolation ────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.6.2's security posture, as code: "variable interpolation is
|
||||
// HTML-escaped by default with no raw-HTML variable type in v1. A module supplies
|
||||
// data; it does not supply markup."
|
||||
//
|
||||
// The token grammar is deliberately the smallest thing that works: `{{ name }}`,
|
||||
// a bare declared variable name, and NOTHING else. No filters, no conditionals,
|
||||
// no loops, no dotted paths. Three reasons:
|
||||
//
|
||||
// - A template is operator-authored data rendered by the server. Every construct
|
||||
// added here is a construct an operator can get wrong and a construct someone
|
||||
// has to sandbox.
|
||||
// - §4.3 makes the trigger declaration the source of truth for what a template
|
||||
// may reference, and a save-time check names the offending variable. That check
|
||||
// can only be exact if a token is a name — `{{ user.profile.email }}` is not a
|
||||
// declared variable, it is an expression over one.
|
||||
// - Repetition is a BLOCK (`email.itemList`), not a template construct, so the
|
||||
// one place a template needs "for each" already has a typed, validated home.
|
||||
//
|
||||
// A token whose variable has no value at render time becomes the empty string and
|
||||
// is reported in `missing`. It does not become "undefined", which is the failure
|
||||
// §4.3's versioning paragraph is about — a renamed variable rendering as the word
|
||||
// undefined in a person's inbox.
|
||||
|
||||
// `{{ name }}` / `{{name}}`. Leading letter, then letters/digits/underscore —
|
||||
// the same shape §4.3's declarations use.
|
||||
const TOKEN_RE = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g
|
||||
|
||||
/** Escape text for interpolation into HTML. Same table as utils/htmlShell.js. */
|
||||
function htmlEscape(s) {
|
||||
return String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every distinct variable name a string references, in first-appearance order.
|
||||
* This is what the save-time check (Phase 5b) walks to find undeclared variables.
|
||||
* @param {unknown} str
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function scanTokens(str) {
|
||||
if (typeof str !== 'string') return []
|
||||
const found = []
|
||||
for (const m of str.matchAll(TOKEN_RE)) {
|
||||
if (!found.includes(m[1])) found.push(m[1])
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute declared variables into a string.
|
||||
*
|
||||
* @param {unknown} str
|
||||
* @param {Record<string, unknown>} values
|
||||
* @param {{ escape?: boolean, missing?: Set<string> }} [opts]
|
||||
* `escape` (default true) HTML-escapes each value — pass false ONLY for the
|
||||
* plain-text part, where there is no markup to escape into and `&` in a
|
||||
* person's inbox is a bug. `missing` collects names with no value.
|
||||
* @returns {string}
|
||||
*/
|
||||
function interpolate(str, values, opts = {}) {
|
||||
if (typeof str !== 'string' || str === '') return ''
|
||||
const escape = opts.escape !== false
|
||||
const missing = opts.missing || null
|
||||
return str.replace(TOKEN_RE, (_match, name) => {
|
||||
const value = values ? values[name] : undefined
|
||||
if (value === undefined || value === null) {
|
||||
if (missing) missing.add(name)
|
||||
return ''
|
||||
}
|
||||
const asString = typeof value === 'string' ? value : String(value)
|
||||
return escape ? htmlEscape(asString) : asString
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { TOKEN_RE, htmlEscape, scanTokens, interpolate }
|
||||
138
server/src/emailBlocks/registry.js
Normal file
138
server/src/emailBlocks/registry.js
Normal file
@@ -0,0 +1,138 @@
|
||||
// ── The `email.*` block registry ───────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.4. A sibling of `blocks/registry.js`, not an extension of it,
|
||||
// settled with the org lead at the start of Phase 5a. Three reasons, in order of
|
||||
// how much they cost if ignored:
|
||||
//
|
||||
// 1. **These blocks render on the SERVER.** Page blocks do not: `blocks/` carries
|
||||
// `schema` / `sanitize` / `cacheTTL` and the actual drawing happens in React
|
||||
// (`client/src/blocks/BlockRenderer.jsx`). Mail has no React — a message body
|
||||
// is a string this process produces — so an email definition carries `toHtml`
|
||||
// and `toText`. `registerBlock` freezes a fixed field set and would silently
|
||||
// DROP both.
|
||||
// 2. **One registry would be one namespace.** `blocks/validateBlocks.js`'s only
|
||||
// server consumer is `pages.model.js`; registering `email.heading` into that
|
||||
// Map makes a CMS page containing an email block validate and save, and the
|
||||
// client renderer has nothing to draw for it.
|
||||
// 3. The two entry shapes genuinely differ: `cacheTTL` and `container` mean
|
||||
// nothing to a mail body, and a renderer means nothing to a cached page block.
|
||||
//
|
||||
// What IS shared is everything that is the same rule for both, and it is shared by
|
||||
// binding rather than by copy: `propHelpers`, the envelope/id/nesting walk
|
||||
// (`makeValidateBlocks`) and the validate-then-sanitize order (`makeSanitizeBlocks`).
|
||||
// §4.4's "do not build a second editor" is honoured where it is about the editor —
|
||||
// Phase 5b drives these through the existing block/prop-panel machinery.
|
||||
//
|
||||
// A registered definition looks like:
|
||||
// {
|
||||
// type: 'email.heading',
|
||||
// version: 1,
|
||||
// schema: (props) => [], // error strings ([] = valid)
|
||||
// sanitize: (props) => props, // optional, run on save AFTER validation
|
||||
// toHtml: (props, ctx) => '<tr>…', // a table ROW; see render.js for the shell
|
||||
// toText: (props, ctx) => 'text', // '' means "contributes nothing"
|
||||
// variables: (props) => [], // optional; see below
|
||||
// }
|
||||
//
|
||||
// `variables` exists because of ONE block, and the exception is the reason it has
|
||||
// to be declared rather than inferred. Every other block references a declared
|
||||
// variable the same way a person writes it — as a `{{token}}` inside an authored
|
||||
// string — so scanning the string props finds them all. `email.itemList` does not:
|
||||
// its `variable` prop holds a BARE NAME (`items`), because the block iterates the
|
||||
// value rather than interpolating it. A save-time check that only scanned tokens
|
||||
// would pass a template pointing its one repeating block at a variable no trigger
|
||||
// declares, and the failure would surface as an empty digest in someone's inbox.
|
||||
// A block that reads a variable by any means other than a token says so here.
|
||||
//
|
||||
// `ctx` is the render context (render.js): resolved brand values, an `interp`
|
||||
// that substitutes declared variables HTML-escaped, and `interpText` that does
|
||||
// the same without escaping for the plain-text part.
|
||||
|
||||
const registry = new Map()
|
||||
|
||||
// Same envelope as a page block — deliberately the same constant list, because
|
||||
// the shared validator enforces it and the two must not diverge.
|
||||
const { RESERVED_KEYS } = require('../blocks/registry')
|
||||
|
||||
/**
|
||||
* Register an email block definition. Throws on a missing type, a duplicate, or a
|
||||
* missing renderer — all three are programmer errors surfaced at boot.
|
||||
* @param {object} def
|
||||
* @returns {object} the normalized, frozen definition
|
||||
*/
|
||||
function registerEmailBlock(def) {
|
||||
if (!def || typeof def.type !== 'string' || def.type.length === 0) {
|
||||
throw new Error('registerEmailBlock: a block definition needs a string `type`')
|
||||
}
|
||||
if (!def.type.startsWith('email.')) {
|
||||
// The prefix is not needed to disambiguate — this is its own Map — but a
|
||||
// stored blocks array should say what it is when someone reads the row.
|
||||
throw new Error(`registerEmailBlock: ${def.type} must be namespaced "email."`)
|
||||
}
|
||||
if (registry.has(def.type)) {
|
||||
throw new Error(`registerEmailBlock: block type already registered: ${def.type}`)
|
||||
}
|
||||
if (typeof def.toHtml !== 'function' || typeof def.toText !== 'function') {
|
||||
// §4.4: "Every block type gets a toText(props) alongside its renderer, so a
|
||||
// text part always exists." A block that can only produce HTML would make a
|
||||
// published template's text part depend on which blocks it happened to use.
|
||||
throw new Error(`registerEmailBlock: ${def.type} needs both toHtml and toText`)
|
||||
}
|
||||
if (def.schema != null && typeof def.schema !== 'function') {
|
||||
throw new Error(`registerEmailBlock: ${def.type}.schema must be a function`)
|
||||
}
|
||||
if (def.sanitize != null && typeof def.sanitize !== 'function') {
|
||||
throw new Error(`registerEmailBlock: ${def.type}.sanitize must be a function`)
|
||||
}
|
||||
if (def.variables != null && typeof def.variables !== 'function') {
|
||||
throw new Error(`registerEmailBlock: ${def.type}.variables must be a function`)
|
||||
}
|
||||
const entry = Object.freeze({
|
||||
type: def.type,
|
||||
label: def.label || def.type,
|
||||
version: Number.isInteger(def.version) ? def.version : 1,
|
||||
schema: def.schema || null,
|
||||
sanitize: def.sanitize || null,
|
||||
toHtml: def.toHtml,
|
||||
toText: def.toText,
|
||||
// Null, not a default `() => []`: `variables.js` distinguishes "this block
|
||||
// declares no non-token references" from "this block was never asked", and
|
||||
// only the second is worth a comment when a new block type is added.
|
||||
variables: def.variables || null,
|
||||
// The shared walk reads these; email has no containers, and saying so here is
|
||||
// what lets `makeValidateBlocks` be the same function for both families.
|
||||
container: false,
|
||||
containerSlots: Object.freeze([]),
|
||||
})
|
||||
registry.set(entry.type, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/** @returns {object|null} the definition for `type`, or null if unknown. */
|
||||
function getEmailBlock(type) {
|
||||
return registry.get(type) || null
|
||||
}
|
||||
|
||||
/** @returns {boolean} whether `type` is a registered email block. */
|
||||
function hasEmailBlock(type) {
|
||||
return registry.has(type)
|
||||
}
|
||||
|
||||
/** @returns {object[]} all registered definitions (registration order). */
|
||||
function listEmailBlocks() {
|
||||
return [...registry.values()]
|
||||
}
|
||||
|
||||
/** Drop every registered block. Test-only. */
|
||||
function _resetRegistry() {
|
||||
registry.clear()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RESERVED_KEYS,
|
||||
registerEmailBlock,
|
||||
getEmailBlock,
|
||||
hasEmailBlock,
|
||||
listEmailBlocks,
|
||||
_resetRegistry,
|
||||
}
|
||||
196
server/src/emailBlocks/render.js
Normal file
196
server/src/emailBlocks/render.js
Normal file
@@ -0,0 +1,196 @@
|
||||
// ── Rendering a block array into a mail body ───────────────────────────────
|
||||
//
|
||||
// Pure and synchronous: everything that needs a database — the brand values, the
|
||||
// resolved theme, the site title — is resolved by `engagement/templates.js` and
|
||||
// arrives here as a plain object. That split is what lets the whole renderer be
|
||||
// tested without a MariaDB, and it is why the byte-comparison test for the five
|
||||
// transactional bodies (§5a acceptance) is a unit test rather than a live send.
|
||||
//
|
||||
// **The shell contributes structure and NO content.** No appended footer, no
|
||||
// injected logo, no "sent by" line. Two reasons, and the second is the load-bearing
|
||||
// one:
|
||||
//
|
||||
// - A person's mail must say what the operator wrote and nothing else. An
|
||||
// unsubscribe line is a variable inside the template (§4.6.1 lists
|
||||
// `unsubscribeUrl` for exactly the two templates that need one), so an operator
|
||||
// can move it, reword it, or see that a transactional mail correctly has none.
|
||||
// - **The HTML and text parts must say the same things.** A shell that put a
|
||||
// footer only in the HTML would make every message's two parts disagree, which
|
||||
// is a deliverability signal and, worse, means the text reader is told less
|
||||
// than the HTML reader. Every block produces both halves; nothing else does.
|
||||
//
|
||||
// The HTML is table-based and inline-styled throughout, which is not a stylistic
|
||||
// choice: `<div>` layout and a `<style>` block are the two things mail clients
|
||||
// most reliably break.
|
||||
|
||||
const { htmlEscape, interpolate } = require('./interpolate')
|
||||
const { getEmailBlock } = require('./registry')
|
||||
const { makeValidateBlocks } = require('../blocks/validateBlocks')
|
||||
const { makeSanitizeBlocks } = require('../blocks/sanitizeBlocks')
|
||||
const { isSafeUrl } = require('../blocks/propHelpers')
|
||||
|
||||
// Bound to the email registry — the same walk the page family gets, so the
|
||||
// envelope rules, id uniqueness and schema dispatch cannot drift between them.
|
||||
const validateEmailBlocks = makeValidateBlocks(getEmailBlock, { maxBlocks: 60 })
|
||||
const sanitizeEmailBlocks = makeSanitizeBlocks(getEmailBlock)
|
||||
|
||||
// A stack every mail client resolves. No webfont: a @font-face in mail is either
|
||||
// stripped or silently ignored, and the fallback is what the reader sees anyway.
|
||||
const FONT_STACK = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"
|
||||
|
||||
/**
|
||||
* The mail palette — a light scaffold plus the deployment's accent.
|
||||
*
|
||||
* **Only the accent comes from the theme, and that is deliberate.** Every shipped
|
||||
* preset (`config/themePresets.js`) is a DARK palette, and mail is not a page: a
|
||||
* dark-background body is what §4.6.2 names as rendering "unreadable dark-on-dark
|
||||
* in about a third of inboxes", because a good share of clients invert or force a
|
||||
* background of their own. Deriving a light palette from a dark one would be a
|
||||
* guess at six colours; taking the one colour that carries the brand — the accent,
|
||||
* used for the button and for links — is exact. §4.6.1's property 2 holds either
|
||||
* way: no seeded template contains a hex code, so one prebuilt image running as
|
||||
* any shard mails in that shard's colour.
|
||||
*
|
||||
* @param {{ accent?: string }} [theme] resolved theme tokens
|
||||
*/
|
||||
function palette(theme = {}) {
|
||||
const accent = isHex(theme.accent) ? theme.accent : '#7f99bd'
|
||||
return Object.freeze({
|
||||
accent,
|
||||
onAccent: readableOn(accent),
|
||||
heading: '#151a20',
|
||||
text: '#33404d',
|
||||
muted: '#6b7885',
|
||||
rule: '#dfe4ea',
|
||||
page: '#f4f6f8',
|
||||
card: '#ffffff',
|
||||
fontStack: FONT_STACK,
|
||||
})
|
||||
}
|
||||
|
||||
function isHex(v) {
|
||||
return typeof v === 'string' && /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(v)
|
||||
}
|
||||
|
||||
/** Black or white text over `hex`, whichever a reader can actually read. */
|
||||
function readableOn(hex) {
|
||||
let h = hex.slice(1)
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
|
||||
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255)
|
||||
// Relative luminance (WCAG). 0.45 rather than 0.5: the accents here are mid-tone
|
||||
// and white-on-mid reads better than black-on-mid at button weight.
|
||||
const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
|
||||
const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
||||
return L > 0.45 ? '#151a20' : '#ffffff'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the render context every block's `toHtml` / `toText` receives.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {Record<string, unknown>} opts.values variable values
|
||||
* @param {object} [opts.theme] resolved theme tokens
|
||||
* @param {string} [opts.baseUrl] absolute site base, for relative urls
|
||||
* @param {Set<string>} [opts.missing] collects unresolved variable names
|
||||
*/
|
||||
function buildContext({ values = {}, theme = {}, baseUrl = '', missing = new Set() }) {
|
||||
const base = String(baseUrl || '').replace(/\/+$/, '')
|
||||
const ctx = {
|
||||
values,
|
||||
missing,
|
||||
palette: palette(theme),
|
||||
escape: htmlEscape,
|
||||
/** Interpolate + HTML-escape — for anything going into markup. */
|
||||
h: (s) => interpolate(s, values, { escape: true, missing }),
|
||||
/** Interpolate WITHOUT escaping — for the plain-text part only. */
|
||||
t: (s) => interpolate(s, values, { escape: false, missing }),
|
||||
/**
|
||||
* Interpolate a URL and re-check it. Returns the URL or null.
|
||||
*
|
||||
* A stored `{{resetUrl}}` says nothing about where it points; the value
|
||||
* arrives from a caller or a module at render time. Checking only the stored
|
||||
* literal would mean a variable carrying `javascript:` becomes an href.
|
||||
*/
|
||||
safeHref: (s) => {
|
||||
const url = interpolate(s, values, { escape: false, missing })
|
||||
return url && isSafeUrl(url) ? url : null
|
||||
},
|
||||
/** Same-origin path → absolute URL; http(s) unchanged; anything else null. */
|
||||
absolute: (url) => {
|
||||
if (!url) return null
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
if (url.startsWith('/')) return base ? `${base}${url}` : null
|
||||
return null
|
||||
},
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a blocks array into the two body parts.
|
||||
*
|
||||
* Blocks are joined by a blank line in text and stacked as table rows in HTML.
|
||||
* A block whose `toText` returns '' contributes nothing to the text part and does
|
||||
* not leave a doubled blank line behind it (`email.divider` is the case).
|
||||
*
|
||||
* @returns {{ html: string, text: string }} html is the ROWS, not a document
|
||||
*/
|
||||
function renderBlocks(blocks, ctx) {
|
||||
const rows = []
|
||||
const paras = []
|
||||
for (const block of Array.isArray(blocks) ? blocks : []) {
|
||||
if (block && block.visible === false) continue
|
||||
const def = block && typeof block.type === 'string' ? getEmailBlock(block.type) : null
|
||||
if (!def) continue // unreachable after validation; never emit an unknown block
|
||||
const props = block.props && typeof block.props === 'object' ? block.props : {}
|
||||
try {
|
||||
const html = def.toHtml(props, ctx)
|
||||
if (html) rows.push(html)
|
||||
const text = def.toText(props, ctx)
|
||||
if (text) paras.push(text)
|
||||
} catch {
|
||||
// One misbehaving block must not cost the whole message. Skipped in both
|
||||
// parts together, so the two never disagree about what the mail contains.
|
||||
}
|
||||
}
|
||||
return { html: rows.join(''), text: paras.join('\n\n') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap rendered rows in the mail document.
|
||||
* @param {string} rowsHtml
|
||||
* @param {object} ctx
|
||||
* @param {string} [title] the <title>, shown by a few webmail clients
|
||||
*/
|
||||
function renderDocument(rowsHtml, ctx, title = '') {
|
||||
const p = ctx.palette
|
||||
return (
|
||||
'<!doctype html><html><head><meta charset="utf-8" />' +
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1" />' +
|
||||
// Tells a client that inverts colours that this body already handles both,
|
||||
// so it leaves the palette alone instead of inverting the card to near-black.
|
||||
'<meta name="color-scheme" content="light" />' +
|
||||
'<meta name="supported-color-schemes" content="light" />' +
|
||||
`<title>${htmlEscape(title)}</title></head>` +
|
||||
`<body style="margin:0;padding:0;background:${p.page};">` +
|
||||
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${p.page};">` +
|
||||
'<tr><td align="center" style="padding:24px 12px;">' +
|
||||
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" ` +
|
||||
`style="width:100%;max-width:600px;background:${p.card};border:1px solid ${p.rule};border-radius:6px;">` +
|
||||
'<tr><td style="padding:28px 28px 16px 28px;">' +
|
||||
'<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">' +
|
||||
rowsHtml +
|
||||
'</table></td></tr></table></td></tr></table></body></html>'
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FONT_STACK,
|
||||
palette,
|
||||
readableOn,
|
||||
buildContext,
|
||||
renderBlocks,
|
||||
renderDocument,
|
||||
validateEmailBlocks,
|
||||
sanitizeEmailBlocks,
|
||||
}
|
||||
98
server/src/emailBlocks/types/button.js
Normal file
98
server/src/emailBlocks/types/button.js
Normal file
@@ -0,0 +1,98 @@
|
||||
// email.button — the call to action, and the one block whose two renderings are
|
||||
// deliberately NOT the same content.
|
||||
//
|
||||
// **`textLead` is why the plain-text part is authored rather than derived.** In
|
||||
// HTML this is a button reading "Choose a new password"; in plain text a button
|
||||
// is nothing, and what a reader needs is the sentence that introduces the URL
|
||||
// ("Choose a new password here:") followed by the URL on its own line. Deriving
|
||||
// the second from the first produces either a bare URL with no lead-in or the
|
||||
// button's label used as a sentence. §4.4 calls the text part generated-by-default
|
||||
// and overridable; this block is the reason the default has to be good enough that
|
||||
// an operator rarely reaches for the override.
|
||||
//
|
||||
// **The href is re-checked AFTER interpolation.** `url` is nearly always a token
|
||||
// (`{{resetUrl}}`), so nothing about the stored value tells you where it points —
|
||||
// the value arrives at render time from a module or a caller. A substituted URL
|
||||
// that is not http/https/same-origin loses its href and renders as inert text
|
||||
// rather than as a link the reader would have no reason to distrust.
|
||||
const { registerEmailBlock } = require('../registry')
|
||||
const { requiredText, optionalText, onlyKeys, isSafeUrl } = require('../../blocks/propHelpers')
|
||||
const { scanTokens } = require('../interpolate')
|
||||
|
||||
const MAX_LABEL = 80
|
||||
const MAX_URL = 600
|
||||
const MAX_LEAD = 200
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.button',
|
||||
label: 'Button / link',
|
||||
version: 1,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['label', 'url', 'textLead'])
|
||||
const label = requiredText('label', props.label, MAX_LABEL)
|
||||
if (label) errors.push(label)
|
||||
const lead = optionalText('textLead', props.textLead, MAX_LEAD)
|
||||
if (lead) errors.push(lead)
|
||||
|
||||
const url = requiredText('url', props.url, MAX_URL)
|
||||
if (url) {
|
||||
errors.push(url)
|
||||
} else if (scanTokens(props.url).length === 0 && !isSafeUrl(props.url)) {
|
||||
// A literal url is checked here, at save. One built from variables cannot
|
||||
// be — see the header note; render.js checks the substituted value instead.
|
||||
errors.push('url must be a relative path, an http(s) URL, or a template variable')
|
||||
}
|
||||
return errors
|
||||
},
|
||||
toHtml(props, ctx) {
|
||||
// An EMPTY url and an UNSAFE one are different failures and get different
|
||||
// answers. Empty means the caller chose not to supply this link at all (an
|
||||
// unsubscribe line on a transactional mail), so the block disappears from both
|
||||
// parts. Unsafe means a value arrived that must not become an href — the label
|
||||
// still renders, inert, because dropping it silently would hide from the
|
||||
// reader that the mail was meant to offer them something.
|
||||
if (ctx.t(props.url).trim() === '') return ''
|
||||
// ABSOLUTIZED, like `email.image` and `email.itemList` already do, and this
|
||||
// was a real defect until Phase 6 put a rule-driven variable in here. A
|
||||
// trigger's `url` variables are validated site-RELATIVE by construction
|
||||
// (`engagementEmit.RELATIVE_URL`), so `{{actionUrl}}` interpolates to
|
||||
// `/guilds/the-silver-anvil` and a mail client has no origin to resolve that
|
||||
// against: the button rendered a dead link. `absolute()` returns null for a
|
||||
// relative path when no base is configured, which falls into the inert-label
|
||||
// branch below rather than shipping the broken href.
|
||||
const href = ctx.absolute(ctx.safeHref(props.url))
|
||||
const label = ctx.h(props.label)
|
||||
if (!href) {
|
||||
return (
|
||||
`<tr><td style="padding:4px 0 16px 0;font-family:${ctx.palette.fontStack};` +
|
||||
`font-size:15px;color:${ctx.palette.muted};">${label}</td></tr>`
|
||||
)
|
||||
}
|
||||
// Table-wrapped, inline-styled, with explicit padding on the anchor: the shape
|
||||
// that survives Outlook, which ignores padding on a <td> containing an <a>.
|
||||
return (
|
||||
'<tr><td style="padding:4px 0 20px 0;">' +
|
||||
'<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>' +
|
||||
`<td bgcolor="${ctx.palette.accent}" style="border-radius:4px;">` +
|
||||
`<a href="${ctx.escape(href)}" style="display:inline-block;padding:11px 22px;` +
|
||||
`font-family:${ctx.palette.fontStack};font-size:15px;font-weight:600;` +
|
||||
`color:${ctx.palette.onAccent};text-decoration:none;border-radius:4px;">${label}</a>` +
|
||||
'</td></tr></table>' +
|
||||
// The bare URL under the button, for the clients that strip anchors and for
|
||||
// the reader who wants to see where it goes before pressing it.
|
||||
`<div style="padding-top:10px;font-family:${ctx.palette.fontStack};font-size:12px;` +
|
||||
`line-height:1.5;color:${ctx.palette.muted};word-break:break-all;">${ctx.escape(href)}</div>` +
|
||||
'</td></tr>'
|
||||
)
|
||||
},
|
||||
toText(props, ctx) {
|
||||
const raw = ctx.t(props.url).trim()
|
||||
if (raw === '') return '' // see toHtml: no url, no block, in either part
|
||||
// The text part shows the same absolute URL the button links to. Falls back
|
||||
// to the raw value rather than dropping the block: a reader who can see a
|
||||
// relative path can still find the site, and `itemList` makes the same trade.
|
||||
const url = ctx.absolute(raw) || raw
|
||||
const lead = props.textLead ? ctx.t(props.textLead).trim() : ''
|
||||
return lead ? `${lead}\n${url}` : url
|
||||
},
|
||||
})
|
||||
29
server/src/emailBlocks/types/divider.js
Normal file
29
server/src/emailBlocks/types/divider.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// email.divider — a horizontal rule.
|
||||
//
|
||||
// **Its text form is the empty string, not a row of dashes.** A block whose only
|
||||
// job is visual separation has no plain-text equivalent, and render.js already
|
||||
// joins blocks with a blank line. Rendering `-----` would put a decoration in the
|
||||
// text part that the author never wrote and cannot remove without deleting the
|
||||
// rule from the HTML too. Returning '' is what the "'' means contributes nothing"
|
||||
// contract in registry.js exists for.
|
||||
const { registerEmailBlock } = require('../registry')
|
||||
const { onlyKeys } = require('../../blocks/propHelpers')
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.divider',
|
||||
label: 'Divider',
|
||||
version: 1,
|
||||
schema(props) {
|
||||
return onlyKeys(props, [])
|
||||
},
|
||||
toHtml(_props, ctx) {
|
||||
return (
|
||||
'<tr><td style="padding:8px 0 20px 0;">' +
|
||||
`<div style="height:1px;line-height:1px;font-size:0;background:${ctx.palette.rule};"> </div>` +
|
||||
'</td></tr>'
|
||||
)
|
||||
},
|
||||
toText() {
|
||||
return ''
|
||||
},
|
||||
})
|
||||
41
server/src/emailBlocks/types/heading.js
Normal file
41
server/src/emailBlocks/types/heading.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// email.heading — a section heading inside a mail body.
|
||||
//
|
||||
// `level` is a SIZE, not a tag hierarchy: mail clients do not build an outline
|
||||
// from an email and several strip heading tags outright, so this renders a styled
|
||||
// <div> at one of three sizes rather than h1/h2/h3. Keeping the prop named `level`
|
||||
// means the prop panel Phase 5b reuses reads the same as the page block's.
|
||||
const { registerEmailBlock } = require('../registry')
|
||||
const { oneOf, requiredText, onlyKeys } = require('../../blocks/propHelpers')
|
||||
|
||||
const LEVELS = ['h1', 'h2', 'h3']
|
||||
const MAX_TEXT = 200
|
||||
|
||||
const SIZES = { h1: '24px', h2: '19px', h3: '16px' }
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.heading',
|
||||
label: 'Heading',
|
||||
version: 1,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['level', 'text'])
|
||||
const level = oneOf('level', LEVELS)(props.level)
|
||||
if (level) errors.push(level)
|
||||
const text = requiredText('text', props.text, MAX_TEXT)
|
||||
if (text) errors.push(text)
|
||||
return errors
|
||||
},
|
||||
toHtml(props, ctx) {
|
||||
// Same "nothing in, nothing out" rule as email.text: a heading that is one
|
||||
// optional variable disappears rather than leaving its margin behind.
|
||||
if (ctx.t(props.text).trim() === '') return ''
|
||||
const size = SIZES[props.level] || SIZES.h2
|
||||
return (
|
||||
`<tr><td style="padding:0 0 12px 0;font-family:${ctx.palette.fontStack};` +
|
||||
`font-size:${size};line-height:1.3;font-weight:700;color:${ctx.palette.heading};">` +
|
||||
`${ctx.h(props.text)}</td></tr>`
|
||||
)
|
||||
},
|
||||
toText(props, ctx) {
|
||||
return ctx.t(props.text).trim()
|
||||
},
|
||||
})
|
||||
60
server/src/emailBlocks/types/image.js
Normal file
60
server/src/emailBlocks/types/image.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// email.image — an inline image.
|
||||
//
|
||||
// Two things differ from the page block of the same name, both because the reader
|
||||
// is in a mail client rather than on the site:
|
||||
//
|
||||
// - **The src is absolutized.** `brand_assets` stores `/uploads/…` and every page
|
||||
// renderer is same-origin, so a relative src has always been correct there. In
|
||||
// an inbox there is no origin to be relative to; render.js's `absolute()` turns
|
||||
// it into a URL against APP_BASE_URL / BRAND_URL, and an image that cannot be
|
||||
// absolutized is DROPPED rather than emitted broken.
|
||||
// - **`alt` is required.** Most mail clients block remote images by default, so
|
||||
// for a large share of readers the alt text IS the image. On a web page it is
|
||||
// an accessibility nicety; here it is the common case.
|
||||
const { registerEmailBlock } = require('../registry')
|
||||
const { requiredText, onlyKeys, isSafeUrl } = require('../../blocks/propHelpers')
|
||||
const { scanTokens } = require('../interpolate')
|
||||
|
||||
const MAX_URL = 600
|
||||
const MAX_ALT = 200
|
||||
const MAX_WIDTH = 560
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.image',
|
||||
label: 'Image',
|
||||
version: 1,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['url', 'alt', 'width'])
|
||||
const alt = requiredText('alt', props.alt, MAX_ALT)
|
||||
if (alt) errors.push(alt)
|
||||
const url = requiredText('url', props.url, MAX_URL)
|
||||
if (url) {
|
||||
errors.push(url)
|
||||
} else if (scanTokens(props.url).length === 0 && !isSafeUrl(props.url)) {
|
||||
errors.push('url must be a relative path, an http(s) URL, or a template variable')
|
||||
}
|
||||
if (props.width !== undefined) {
|
||||
if (!Number.isInteger(props.width) || props.width < 16 || props.width > MAX_WIDTH) {
|
||||
errors.push(`width must be a whole number between 16 and ${MAX_WIDTH}`)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
},
|
||||
toHtml(props, ctx) {
|
||||
const src = ctx.absolute(ctx.safeHref(props.url))
|
||||
if (!src) return '' // unresolvable: no broken image in someone's inbox
|
||||
const width = props.width ? ` width="${props.width}"` : ''
|
||||
const style = props.width
|
||||
? `max-width:100%;width:${props.width}px;height:auto;display:block;border:0;`
|
||||
: 'max-width:100%;height:auto;display:block;border:0;'
|
||||
return (
|
||||
`<tr><td style="padding:0 0 16px 0;">` +
|
||||
`<img src="${ctx.escape(src)}" alt="${ctx.h(props.alt)}"${width} style="${style}" /></td></tr>`
|
||||
)
|
||||
},
|
||||
toText(props, ctx) {
|
||||
// The alt text alone, with no [image] decoration: it was written to stand in
|
||||
// for the picture, and in the text part standing in for it is all it does.
|
||||
return ctx.t(props.alt)
|
||||
},
|
||||
})
|
||||
112
server/src/emailBlocks/types/itemList.js
Normal file
112
server/src/emailBlocks/types/itemList.js
Normal file
@@ -0,0 +1,112 @@
|
||||
// email.itemList — the one repeating block, and the reason the token grammar in
|
||||
// interpolate.js needs no loop construct.
|
||||
//
|
||||
// It renders an ARRAY variable rather than an inline list: the prop is the NAME of
|
||||
// a declared variable (`items`), and the value arrives at render time. §4.6.1's
|
||||
// two generic templates — `notify.event` and `notify.digest` — are generic because
|
||||
// of this block: their variables are structural (`title`, `intro`, `items[]`), so
|
||||
// a trigger from any module renders through them with no authoring at all.
|
||||
//
|
||||
// **The item shape is `{ heading, excerpt?, url? }`, matching what
|
||||
// `teamNotify`/`teamDigestWorker` already build**, so Phase 6's migration onto the
|
||||
// engine is a rewiring rather than a reshaping of every producer.
|
||||
//
|
||||
// A non-array value, or an empty one, renders `emptyText` if there is one and
|
||||
// nothing at all otherwise. That is the same fail-soft posture `settingsJson`
|
||||
// takes: a stored value that is unusable is treated as absent, never as an error —
|
||||
// a digest whose item query returned nothing must still be a sendable mail.
|
||||
const { registerEmailBlock } = require('../registry')
|
||||
const { requiredText, optionalText, onlyKeys } = require('../../blocks/propHelpers')
|
||||
|
||||
const MAX_NAME = 64
|
||||
const MAX_EMPTY = 200
|
||||
const MAX_ITEMS = 100
|
||||
const NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/
|
||||
|
||||
/** Coerce whatever the caller passed into a bounded array of item objects. */
|
||||
function itemsOf(value) {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.slice(0, MAX_ITEMS)
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return { heading: item }
|
||||
if (!item || typeof item !== 'object') return null
|
||||
return {
|
||||
heading: item.heading == null ? '' : String(item.heading),
|
||||
excerpt: item.excerpt == null ? '' : String(item.excerpt),
|
||||
url: item.url == null ? '' : String(item.url),
|
||||
}
|
||||
})
|
||||
.filter((item) => item && item.heading !== '')
|
||||
}
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.itemList',
|
||||
label: 'Item list',
|
||||
version: 1,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['variable', 'emptyText'])
|
||||
const variable = requiredText('variable', props.variable, MAX_NAME)
|
||||
if (variable) {
|
||||
errors.push(variable)
|
||||
} else if (!NAME_RE.test(props.variable)) {
|
||||
errors.push('variable must be the name of a declared list variable')
|
||||
}
|
||||
const empty = optionalText('emptyText', props.emptyText, MAX_EMPTY)
|
||||
if (empty) errors.push(empty)
|
||||
return errors
|
||||
},
|
||||
// The one block whose variable reference is not a token (see registry.js).
|
||||
// Without this the Phase 5b save check reads a template whose digest points at
|
||||
// `itmes` as clean, and the mistake surfaces as an empty mail rather than as an
|
||||
// error naming the variable.
|
||||
variables(props) {
|
||||
return typeof props.variable === 'string' && props.variable ? [props.variable] : []
|
||||
},
|
||||
toHtml(props, ctx) {
|
||||
const items = itemsOf(ctx.values[props.variable])
|
||||
if (items.length === 0) {
|
||||
if (!props.emptyText) return ''
|
||||
return (
|
||||
`<tr><td style="padding:0 0 16px 0;font-family:${ctx.palette.fontStack};font-size:14px;` +
|
||||
`line-height:1.55;color:${ctx.palette.muted};">${ctx.h(props.emptyText)}</td></tr>`
|
||||
)
|
||||
}
|
||||
const rows = items
|
||||
.map((item) => {
|
||||
const href = ctx.absolute(ctx.safeHref(item.url))
|
||||
const heading = ctx.escape(item.heading)
|
||||
const title = href
|
||||
? `<a href="${ctx.escape(href)}" style="color:${ctx.palette.accent};text-decoration:none;font-weight:600;">${heading}</a>`
|
||||
: `<span style="font-weight:600;color:${ctx.palette.heading};">${heading}</span>`
|
||||
const excerpt = item.excerpt
|
||||
? `<div style="padding-top:4px;font-size:14px;color:${ctx.palette.muted};">${ctx.escape(item.excerpt)}</div>`
|
||||
: ''
|
||||
return (
|
||||
`<tr><td style="padding:0 0 14px 0;border-left:3px solid ${ctx.palette.rule};padding-left:12px;` +
|
||||
`font-family:${ctx.palette.fontStack};font-size:15px;line-height:1.5;color:${ctx.palette.text};">` +
|
||||
`${title}${excerpt}</td></tr>`
|
||||
)
|
||||
})
|
||||
.join('')
|
||||
return (
|
||||
'<tr><td style="padding:0 0 8px 0;">' +
|
||||
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">${rows}</table>` +
|
||||
'</td></tr>'
|
||||
)
|
||||
},
|
||||
toText(props, ctx) {
|
||||
const items = itemsOf(ctx.values[props.variable])
|
||||
if (items.length === 0) return props.emptyText ? ctx.t(props.emptyText) : ''
|
||||
// Heading flush left, excerpt and url indented two spaces, one blank line
|
||||
// between items — the shape `mailer.sendTeamNotification` builds today.
|
||||
return items
|
||||
.map((item) => {
|
||||
const lines = [item.heading]
|
||||
if (item.excerpt) lines.push(` ${item.excerpt}`)
|
||||
if (item.url) lines.push(` ${ctx.absolute(item.url) || item.url}`)
|
||||
return lines.join('\n')
|
||||
})
|
||||
.join('\n\n')
|
||||
},
|
||||
})
|
||||
69
server/src/emailBlocks/types/text.js
Normal file
69
server/src/emailBlocks/types/text.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// email.text — a run of plain-text paragraphs.
|
||||
//
|
||||
// **There is no rich-text email block, and that is the §4.6.2 posture rather than
|
||||
// an omission.** The page family has `rich_text` because a page author is trusted
|
||||
// staff writing into a surface the site's own CSS controls. A mail body is
|
||||
// different in both halves: the markup an operator writes here is re-rendered by
|
||||
// thirty mail clients with thirty different subsets of HTML, and the VALUES
|
||||
// interpolated into it come from modules and from game data. §4.6.2 settles the
|
||||
// second half — "a module supplies data; it does not supply markup" — and the
|
||||
// first is why even the operator's own markup earns nothing here: a <div> an
|
||||
// author typed is a layout bug in Outlook, while `email.heading` / `email.button`
|
||||
// are shapes this renderer knows how to make survive.
|
||||
//
|
||||
// So: blank line separates paragraphs, single newline is a line break, and every
|
||||
// character is escaped on the way into HTML.
|
||||
const { registerEmailBlock } = require('../registry')
|
||||
const { requiredText, onlyKeys } = require('../../blocks/propHelpers')
|
||||
|
||||
const MAX_TEXT = 4000
|
||||
|
||||
/** Split on blank lines; each paragraph keeps its internal single newlines. */
|
||||
function paragraphs(s) {
|
||||
return String(s)
|
||||
.split(/\n[ \t]*\n/)
|
||||
.map((p) => p.replace(/^\n+|\n+$/g, ''))
|
||||
.filter((p) => p !== '')
|
||||
}
|
||||
|
||||
registerEmailBlock({
|
||||
type: 'email.text',
|
||||
label: 'Paragraph',
|
||||
version: 1,
|
||||
schema(props) {
|
||||
const errors = onlyKeys(props, ['text', 'muted'])
|
||||
const text = requiredText('text', props.text, MAX_TEXT)
|
||||
if (text) errors.push(text)
|
||||
if (props.muted !== undefined && typeof props.muted !== 'boolean') {
|
||||
errors.push('muted must be a boolean')
|
||||
}
|
||||
return errors
|
||||
},
|
||||
toHtml(props, ctx) {
|
||||
const color = props.muted ? ctx.palette.muted : ctx.palette.text
|
||||
const size = props.muted ? '13px' : '15px'
|
||||
// Interpolate FIRST, then split: a variable carrying a blank line becomes two
|
||||
// paragraphs, which is what the contact-message mail needs (a player's typed
|
||||
// message arrives as one variable and reads as they wrote it).
|
||||
const body = ctx.h(props.text)
|
||||
const parts = paragraphs(body)
|
||||
// A block whose whole content is one optional variable renders NOTHING when
|
||||
// that variable is absent, rather than an empty paragraph with its margin.
|
||||
// This is what stands in for a conditional: `{{moreNote}}` on its own line is
|
||||
// a line the caller can choose not to supply, and the template stays
|
||||
// logic-free (interpolate.js).
|
||||
if (parts.length === 0) return ''
|
||||
const html = parts
|
||||
.map((p) => `<p style="margin:0 0 12px 0;">${p.replace(/\n/g, '<br />')}</p>`)
|
||||
.join('')
|
||||
return (
|
||||
`<tr><td style="padding:0;font-family:${ctx.palette.fontStack};font-size:${size};` +
|
||||
`line-height:1.55;color:${color};">${html}</td></tr>`
|
||||
)
|
||||
},
|
||||
toText(props, ctx) {
|
||||
// Trimmed to match toHtml's "nothing in, nothing out": the two parts must
|
||||
// agree about whether this block contributed anything at all.
|
||||
return ctx.t(props.text).replace(/^\s+|\s+$/g, '')
|
||||
},
|
||||
})
|
||||
100
server/src/emailBlocks/variables.js
Normal file
100
server/src/emailBlocks/variables.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// ── Which declared variables a template references ─────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.6.2: "A template referencing an undeclared variable is refused
|
||||
// at save, naming the variable — the editor validates, it does not blindly
|
||||
// interpolate module JSON."
|
||||
//
|
||||
// This is the walk that makes that sentence enforceable. It is deliberately a
|
||||
// SEPARATE pass from rendering: a render only discovers a bad reference when a
|
||||
// value happens to be missing at that moment, which makes the failure depend on
|
||||
// the event rather than on the template. Phase 5a's `renderTemplate` already
|
||||
// reports `missing` for exactly that runtime case; this answers the static
|
||||
// question — what does this template ask for at all — and it can therefore refuse
|
||||
// a save before any mail exists.
|
||||
//
|
||||
// Two kinds of reference, and both have to be found or the check is theatre:
|
||||
//
|
||||
// - **Tokens** in every authored string: the subject, an overriding text part,
|
||||
// and every string-valued prop on every block. `scanTokens` finds these.
|
||||
// - **Named references** a block declares (`registry.js`'s `variables`), which
|
||||
// today is `email.itemList.variable` and its bare `items`. A token scan cannot
|
||||
// see these and would pass them silently.
|
||||
//
|
||||
// The block walk mirrors `makeValidateBlocks`' — top level plus container slots —
|
||||
// rather than sharing it, because that function's job is to decide validity and
|
||||
// this one's is to collect names from a structure already known to be valid. The
|
||||
// email family has no containers today; the slot arm exists so that adding one
|
||||
// does not quietly halve this function's coverage.
|
||||
|
||||
const { scanTokens } = require('./interpolate')
|
||||
const { getEmailBlock } = require('./registry')
|
||||
|
||||
/** Every distinct token name in a string, an array of strings, or a nested plain object. */
|
||||
function tokensIn(value, out) {
|
||||
if (typeof value === 'string') {
|
||||
for (const name of scanTokens(value)) out.add(name)
|
||||
return
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) tokensIn(entry, out)
|
||||
return
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
for (const entry of Object.values(value)) tokensIn(entry, out)
|
||||
}
|
||||
}
|
||||
|
||||
function walkBlock(block, out) {
|
||||
if (!block || typeof block !== 'object') return
|
||||
tokensIn(block.props, out)
|
||||
const def = getEmailBlock(block.type)
|
||||
if (def && typeof def.variables === 'function') {
|
||||
let named = []
|
||||
try {
|
||||
named = def.variables(block.props || {}) || []
|
||||
} catch {
|
||||
// A definition that throws on malformed props must not take the save path
|
||||
// down with it: validation runs first and has already refused those props,
|
||||
// so the only way here is a definition bug, and the right answer to that is
|
||||
// to contribute no names rather than to 500 the request.
|
||||
named = []
|
||||
}
|
||||
for (const name of named) if (typeof name === 'string' && name) out.add(name)
|
||||
}
|
||||
for (const slot of def?.containerSlots || []) {
|
||||
const children = block.props?.[slot]
|
||||
if (Array.isArray(children)) for (const child of children) walkBlock(child, out)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every declared-variable name this template references, in no particular order.
|
||||
*
|
||||
* @param {{ blocks?: unknown[], subject?: string, text_body?: string|null }} template
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function referencedVariables(template) {
|
||||
const out = new Set()
|
||||
tokensIn(template?.subject, out)
|
||||
tokensIn(template?.text_body, out)
|
||||
if (Array.isArray(template?.blocks)) for (const block of template.blocks) walkBlock(block, out)
|
||||
return [...out]
|
||||
}
|
||||
|
||||
/**
|
||||
* The names `referencedVariables` found that `declared` does not contain.
|
||||
*
|
||||
* @param {{ blocks?: unknown[], subject?: string, text_body?: string|null }} template
|
||||
* @param {Array<{ name: string }>} declared what §4.3 declares for this template's
|
||||
* trigger, PLUS the ambient variables every template may use — the caller
|
||||
* passes `templates.variablesFor(...)`, which already merges the two.
|
||||
* @returns {string[]} sorted, so the error message is stable across saves
|
||||
*/
|
||||
function undeclaredVariables(template, declared) {
|
||||
const known = new Set((declared || []).map((v) => v && v.name).filter(Boolean))
|
||||
return referencedVariables(template)
|
||||
.filter((name) => !known.has(name))
|
||||
.sort()
|
||||
}
|
||||
|
||||
module.exports = { referencedVariables, undeclaredVariables }
|
||||
166
server/src/engagement/audiences.js
Normal file
166
server/src/engagement/audiences.js
Normal file
@@ -0,0 +1,166 @@
|
||||
// ── Resolving a rule's audience to recipients ──────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §5.1a / §4.5, Phase 4a. A rule names an audience two ways and
|
||||
// only ever one at a time: a **plain ceiling name** (`owner`, `staff`, `admin`,
|
||||
// `subscribers`, `authenticated`, `everyone`) resolved from core's own tables, or
|
||||
// an **`audience_segment_id`** pointing at an operator-composed tree of
|
||||
// module-declared audiences (segments.js). This file turns either into user ids.
|
||||
//
|
||||
// **Three things it is careful about, all of them the same worry.** The set this
|
||||
// function returns is the set that gets mailed, so:
|
||||
//
|
||||
// 1. Every id is checked against `users.status = 'active'` - including the ones a
|
||||
// MODULE's resolver produced, which core has no reason to trust with account
|
||||
// status it does not know about.
|
||||
// 2. A dormant segment (its module uninstalled) resolves to EMPTY and says so.
|
||||
// The caller must not send. Falling back to the rule's plain `audience`
|
||||
// column would reach a different population than the one composed (§5.1a
|
||||
// rule 4), which is the failure mode this whole design exists to avoid.
|
||||
// 3. `members` resolves to nobody unless something NAMED the list: a segment, or
|
||||
// (from Phase 6) an event carrying its own access-checked recipient set. Core
|
||||
// knows no game vocabulary and cannot guess which members were meant. A rule
|
||||
// with neither is inert and visible as such, rather than quietly falling back
|
||||
// to something wider.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const channels = require('./channels')
|
||||
const segments = require('./segments')
|
||||
const segmentsDb = require('../model/engagement/engagementSegments.db')
|
||||
const recipients = require('../model/engagement/engagementRecipients.db')
|
||||
const ceilings = require('../modules/ceilings')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
/**
|
||||
* Which registered channels default to something other than 'off'?
|
||||
*
|
||||
* Read once per resolution rather than hardcoded, because it is the difference
|
||||
* between "opted in" meaning a stored row and meaning the absence of one
|
||||
* (§3.1, G9). All three of core's channels default 'off' today, so this is empty
|
||||
* and `subscribers` is the simple query - but the answer lives in the registry.
|
||||
*/
|
||||
const defaultOnChannels = () => channels.all().filter((c) => c.defaultMode !== 'off').map((c) => c.id)
|
||||
|
||||
/**
|
||||
* Resolve one rule against one event.
|
||||
*
|
||||
* @returns {{ userIds: number[], ceiling: string|null, dormant: boolean, reason: string|null }}
|
||||
* `dormant` means "this rule cannot be resolved right now"; `reason` names why
|
||||
* for the log and, in Phase 4b, for the admin list's dormant badge.
|
||||
*/
|
||||
async function resolveForRule(rule, event) {
|
||||
if (rule.audience_segment_id) {
|
||||
const segment = await segmentsDb.getById(rule.audience_segment_id)
|
||||
if (!segment) {
|
||||
// The segment was deleted out from under the rule. `audience_segment_id`
|
||||
// deliberately has no ON DELETE SET NULL (see schema.sql), because that
|
||||
// would silently fall back to the rule's plain `audience` column and mail
|
||||
// a different set of people.
|
||||
return { userIds: [], ceiling: null, dormant: true, reason: 'audience segment no longer exists' }
|
||||
}
|
||||
const { dormant, userIds } = await segments.resolve(segment.expression)
|
||||
if (dormant) {
|
||||
return { userIds: [], ceiling: segment.ceiling, dormant: true, reason: 'audience segment is dormant' }
|
||||
}
|
||||
return {
|
||||
userIds: await recipients.filterActive(userIds),
|
||||
// The STORED ceiling, not one re-derived now: a module that has since
|
||||
// widened its own audience's ceiling must not widen a segment that was
|
||||
// saved under the old one.
|
||||
ceiling: segment.ceiling,
|
||||
dormant: false,
|
||||
reason: null,
|
||||
}
|
||||
}
|
||||
|
||||
switch (rule.audience) {
|
||||
case 'owner': {
|
||||
if (!event.ownerUserId) {
|
||||
// Not dormant: the rule is fine and this particular event simply has no
|
||||
// owner to mail. A trigger that never carries one is an operator's
|
||||
// mistake the rule editor should catch (Phase 4b), not a runtime error.
|
||||
return { userIds: [], ceiling: 'owner', dormant: false, reason: 'event carries no ownerUserId' }
|
||||
}
|
||||
return {
|
||||
userIds: await recipients.filterActive([event.ownerUserId]),
|
||||
ceiling: 'owner',
|
||||
dormant: false,
|
||||
reason: null,
|
||||
}
|
||||
}
|
||||
case 'staff':
|
||||
case 'admin':
|
||||
// Both role-gated, and resolved through the ONE query rather than two.
|
||||
// `ceilings.ROLE_CEILINGS` holds which roles each names, so the day a
|
||||
// third is added the resolver does not need a third case — and, more to
|
||||
// the point, cannot get one of them wrong while the others stay right.
|
||||
return {
|
||||
userIds: await recipients.staff(ceilings.ROLE_CEILINGS[rule.audience].roles),
|
||||
ceiling: rule.audience,
|
||||
dormant: false,
|
||||
reason: null,
|
||||
}
|
||||
case 'subscribers':
|
||||
return {
|
||||
userIds: await recipients.subscribers(event.triggerId, defaultOnChannels()),
|
||||
ceiling: 'subscribers',
|
||||
dormant: false,
|
||||
reason: null,
|
||||
}
|
||||
case 'authenticated':
|
||||
case 'everyone':
|
||||
return { userIds: await recipients.active(), ceiling: rule.audience, dormant: false, reason: null }
|
||||
case 'members': {
|
||||
// **The event may name its own list, and Phase 6 is why that exists.**
|
||||
// `members` is the ceiling for "a module-declared list", and until this
|
||||
// phase the only way to name one was a segment — an operator-composed tree
|
||||
// over audiences with CONSTANT params. That cannot express "the members of
|
||||
// the Team this particular post was in": the list is different for every
|
||||
// firing, and nothing in a saved segment reads the event.
|
||||
//
|
||||
// So an emitter that has already computed an access-checked recipient set
|
||||
// hands it over on the envelope, and this is where it is used. It is not a
|
||||
// bypass of anything: the set is still filtered through `users.status`
|
||||
// below, and the ceiling returned is still `members`, so the G24 re-check
|
||||
// in the engine still refuses a rule whose trigger has since narrowed.
|
||||
// What it removes is core having to guess a game's membership vocabulary —
|
||||
// the thing this case's original comment said it could not do.
|
||||
if (Array.isArray(event.recipientUserIds) && event.recipientUserIds.length) {
|
||||
return {
|
||||
userIds: await recipients.filterActive(event.recipientUserIds),
|
||||
ceiling: 'members',
|
||||
dormant: false,
|
||||
reason: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
userIds: [],
|
||||
ceiling: 'members',
|
||||
dormant: false,
|
||||
reason: 'a "members" audience needs a segment naming which list, or an event that carries one',
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Fails closed on an audience name the lattice does not know - the same
|
||||
// posture `ceilings.permits` takes, and for the same reason.
|
||||
log.warn('rule names an unknown audience', { rule: rule.id, audience: rule.audience })
|
||||
return { userIds: [], ceiling: null, dormant: true, reason: `unknown audience "${rule.audience}"` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The G24 gate, re-run at SEND time and not only at save time.
|
||||
*
|
||||
* A rule's audience was checked against its trigger's ceiling when it was saved,
|
||||
* so this can only fail when something changed underneath: a module upgraded and
|
||||
* narrowed its trigger's ceiling, or a module was replaced by one declaring the
|
||||
* same id more tightly. That is precisely the case where a stale rule would
|
||||
* otherwise mail a population the current declaration forbids, which is what
|
||||
* makes this the security boundary rather than a duplicate check.
|
||||
*/
|
||||
function permitted(triggerId, ceiling) {
|
||||
const declaration = registries.eventTrigger(triggerId)
|
||||
if (!declaration) return false
|
||||
return ceilings.permits(declaration.ceiling, ceiling)
|
||||
}
|
||||
|
||||
module.exports = { resolveForRule, permitted, defaultOnChannels }
|
||||
216
server/src/engagement/bounceClassify.js
Normal file
216
server/src/engagement/bounceClassify.js
Normal file
@@ -0,0 +1,216 @@
|
||||
// ── Which send failures are facts about the RECIPIENT ───────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 9. The suppression list's whole value is that an address on
|
||||
// it is genuinely undeliverable; the moment it fills with addresses that were
|
||||
// fine, an operator learns to ignore it and it may as well not exist. This file
|
||||
// is the one place that judgement is made.
|
||||
//
|
||||
// **It is deliberately NOT `mailer.PERMANENT_CODES`, and reusing that set would
|
||||
// have been a mass-suppression bug.** That set answers "is retrying pointless?"
|
||||
// and holds `EAUTH` and `554` alongside `550` — an authentication failure and a
|
||||
// relay-wide policy refusal. Both are permanent and neither says anything about
|
||||
// the person: one wrong SMTP password would suppress every address the outbox
|
||||
// worker touched before anybody noticed the mail had stopped. "Do not retry" and
|
||||
// "this mailbox does not exist" are different questions, and this file only
|
||||
// answers the second.
|
||||
//
|
||||
// **The primary signal is the enhanced status code (RFC 3463), not the reply
|
||||
// code.** `550` alone is the catch-all every refusal arrives as; `5.1.1` means
|
||||
// one specific thing — no such mailbox. Every relay worth configuring emits an
|
||||
// enhanced code, so it is read first and, when present, decides on its own.
|
||||
//
|
||||
// **The fallback is narrow on purpose.** Without an enhanced code a phrase match
|
||||
// is all that is left, and phrase matching is how a classifier quietly starts
|
||||
// suppressing everything. So it applies only after the reply code has already
|
||||
// narrowed the failure to the recipient address — 550, 551 and 553 are RFC 5321's
|
||||
// recipient-address codes — and only for phrases that cannot mean anything else,
|
||||
// with a veto list checked first. `552` (storage exceeded) and `554` (transaction
|
||||
// failed) are excluded from even that: a full mailbox gets emptied, and a generic
|
||||
// transaction failure is generic.
|
||||
//
|
||||
// Anything this file is unsure about is NOT suppressed. The cost of a false
|
||||
// negative is mailing a dead address again next month; the cost of a false
|
||||
// positive is a person who silently stops hearing from the deployment and has no
|
||||
// way to find out.
|
||||
|
||||
// RFC 3463 subject.detail pairs that mean "this address will not accept mail,
|
||||
// today or ever". Kept as strings because `5.1.10` and `5.1.1` are different
|
||||
// codes and numeric parsing loses that.
|
||||
const PERMANENT_RECIPIENT = new Set([
|
||||
'1.1', // bad destination mailbox address — no such user
|
||||
'1.2', // bad destination system address — the domain does not take mail
|
||||
'1.3', // bad destination mailbox address syntax
|
||||
'1.6', // mailbox has moved, no forwarding address
|
||||
'1.10', // recipient address has a null MX (RFC 7505)
|
||||
'2.1', // mailbox disabled, not accepting messages
|
||||
])
|
||||
|
||||
// Enhanced subjects that are permanent but are NOT about the recipient. Listed
|
||||
// rather than merely omitted, because each is a plausible-looking 5.x.y that a
|
||||
// later edit would otherwise be tempted to add:
|
||||
// 2.2 — mailbox full. Permanent-coded by some relays, emptied by every user.
|
||||
// 7.x — policy. Our sending reputation, our SPF, our content; the recipient is
|
||||
// the one party it is not about.
|
||||
// 3.x — the destination MAIL SYSTEM is full or refusing. Not the mailbox.
|
||||
// 5.x — protocol failure. A bug at one end or the other.
|
||||
const NEVER_RECIPIENT_SUBJECTS = new Set(['3', '5', '7'])
|
||||
|
||||
// RFC 5321 reply codes that name the recipient address specifically. 554 is
|
||||
// absent deliberately: "transaction failed" is what a relay reaches for when it
|
||||
// does not want to say why, and it is the commonest shape of a content or policy
|
||||
// rejection.
|
||||
const RECIPIENT_REPLY_CODES = new Set([550, 551, 553])
|
||||
|
||||
// Phrases that only ever mean "no such mailbox", checked only once a reply code
|
||||
// above has established the failure is about the address. Each is a substring of
|
||||
// a real refusal from a widely deployed MTA (Postfix, Exim, Exchange, Google,
|
||||
// Microsoft 365).
|
||||
const NO_SUCH_MAILBOX = [
|
||||
'user unknown',
|
||||
'unknown user',
|
||||
'no such user',
|
||||
'no such recipient',
|
||||
'unknown recipient',
|
||||
'invalid recipient',
|
||||
'recipient address rejected',
|
||||
'recipient not found',
|
||||
'address does not exist',
|
||||
'does not exist',
|
||||
'mailbox unavailable',
|
||||
'mailbox not found',
|
||||
'no mailbox',
|
||||
'user does not exist',
|
||||
'address rejected',
|
||||
]
|
||||
|
||||
// Phrases that appear alongside the ones above and mean the opposite, checked
|
||||
// FIRST. "Mailbox unavailable" is a substring of the sentence a relay sends when
|
||||
// a mailbox is merely full, so a substring match with no veto list would read a
|
||||
// temporary condition as a dead address.
|
||||
const NOT_A_DEAD_MAILBOX = [
|
||||
'full',
|
||||
'quota',
|
||||
'storage',
|
||||
'temporar',
|
||||
'try again',
|
||||
'greylist',
|
||||
'rate limit',
|
||||
'too many',
|
||||
'spam',
|
||||
'blocked',
|
||||
'blacklist',
|
||||
'blocklist',
|
||||
'reputation',
|
||||
'policy',
|
||||
'authentication',
|
||||
'not authorized',
|
||||
]
|
||||
|
||||
/**
|
||||
* The enhanced status code in an SMTP response, as `{ class, subject, detail }`,
|
||||
* or null.
|
||||
*
|
||||
* Anchored to the start of the line rather than searched for anywhere in it: a
|
||||
* bounce that quotes another server's answer ("...said: 550 5.1.1...") contains
|
||||
* two, and the one that matters is the one this relay just gave us. A free search
|
||||
* finds whichever comes first, which is not the same thing.
|
||||
*/
|
||||
function parseEnhanced(response) {
|
||||
if (!response) return null
|
||||
const m = /^\s*(\d{3})[\s-]+(\d)\.(\d{1,3})\.(\d{1,3})\b/.exec(String(response))
|
||||
if (!m) return null
|
||||
return { class: m[2], subject: m[3], detail: m[4] }
|
||||
}
|
||||
|
||||
/** The three-digit reply code, off the error object or out of the response text. */
|
||||
function replyCode(err) {
|
||||
const direct = Number(err && err.responseCode)
|
||||
if (Number.isInteger(direct) && direct >= 400 && direct <= 599) return direct
|
||||
const m = /^\s*(\d{3})\b/.exec(String((err && err.response) || ''))
|
||||
return m ? Number(m[1]) : null
|
||||
}
|
||||
|
||||
const lower = (s) => String(s || '').toLowerCase()
|
||||
|
||||
/**
|
||||
* Should this send failure suppress the address?
|
||||
*
|
||||
* @param {object} err the error a transport's send threw, or an object carrying
|
||||
* the `responseCode` / `response` / `code` lifted off one
|
||||
* @returns {{ suppress: boolean, reason: string, evidence: string|null }}
|
||||
*
|
||||
* `reason` is populated on a refusal too, and that is not decoration: it becomes
|
||||
* the send log's `detail`, so "not suppressed: 554 does not name the recipient
|
||||
* address" is the line that stops somebody re-deriving this decision from an
|
||||
* unexplained non-event six months from now.
|
||||
*/
|
||||
function classify(err) {
|
||||
const e = err || {}
|
||||
const response = e.response || e.message || ''
|
||||
const enhanced = parseEnhanced(response)
|
||||
const code = replyCode(e)
|
||||
|
||||
// No reply code at all means the failure happened before or outside the SMTP
|
||||
// transaction: the connection, the credentials, the socket. Never the
|
||||
// recipient. `EAUTH` lands here, which is the whole reason this file exists.
|
||||
if (!code) {
|
||||
return {
|
||||
suppress: false,
|
||||
reason: `no SMTP reply code (${e.code || 'transport failure'}); not a recipient failure`,
|
||||
evidence: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (code < 500) {
|
||||
return { suppress: false, reason: `${code} is a temporary failure`, evidence: null }
|
||||
}
|
||||
|
||||
if (enhanced) {
|
||||
const pair = `${enhanced.subject}.${enhanced.detail}`
|
||||
if (enhanced.class !== '5') {
|
||||
return { suppress: false, reason: `enhanced status ${enhanced.class}.${pair} is not permanent`, evidence: null }
|
||||
}
|
||||
if (PERMANENT_RECIPIENT.has(pair)) {
|
||||
return { suppress: true, reason: 'bounce', evidence: `5.${pair}` }
|
||||
}
|
||||
if (NEVER_RECIPIENT_SUBJECTS.has(enhanced.subject)) {
|
||||
return {
|
||||
suppress: false,
|
||||
reason: `5.${pair} is about the server or our standing with it, not the address`,
|
||||
evidence: `5.${pair}`,
|
||||
}
|
||||
}
|
||||
// A permanent 5.x.y this file has no opinion on. Unknown means no.
|
||||
return {
|
||||
suppress: false,
|
||||
reason: `5.${pair} is not a known recipient failure`,
|
||||
evidence: `5.${pair}`,
|
||||
}
|
||||
}
|
||||
|
||||
// No enhanced code: the narrow fallback.
|
||||
if (!RECIPIENT_REPLY_CODES.has(code)) {
|
||||
return { suppress: false, reason: `${code} does not name the recipient address`, evidence: null }
|
||||
}
|
||||
const text = lower(response)
|
||||
const veto = NOT_A_DEAD_MAILBOX.find((p) => text.includes(p))
|
||||
if (veto) {
|
||||
return { suppress: false, reason: `${code}, but the response says "${veto}"`, evidence: null }
|
||||
}
|
||||
const hit = NO_SUCH_MAILBOX.find((p) => text.includes(p))
|
||||
if (hit) {
|
||||
return { suppress: true, reason: 'bounce', evidence: `${code} "${hit}"` }
|
||||
}
|
||||
return { suppress: false, reason: `${code} with no enhanced status and no recognised reason`, evidence: null }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classify,
|
||||
parseEnhanced,
|
||||
replyCode,
|
||||
PERMANENT_RECIPIENT,
|
||||
NEVER_RECIPIENT_SUBJECTS,
|
||||
RECIPIENT_REPLY_CODES,
|
||||
NO_SUCH_MAILBOX,
|
||||
NOT_A_DEAD_MAILBOX,
|
||||
}
|
||||
197
server/src/engagement/channels.js
Normal file
197
server/src/engagement/channels.js
Normal file
@@ -0,0 +1,197 @@
|
||||
// ── The delivery-channel registry ──────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §3.1, Phase 3. The other half of the axis `transports/index.js`
|
||||
// splits: a **channel** is what kind of sink this is (email, push, in-app), a
|
||||
// **transport** is how one channel actually delivers (SMTP, ntfy, FCM). Push has
|
||||
// had this shape since before anyone named it — `push_devices.transport` is a
|
||||
// transport column on a channel with one implementation.
|
||||
//
|
||||
// **Only the declarative half registers here today**, and that is the whole of
|
||||
// what Phase 3 needs. `addressFor` / `render` / `deliver` arrive with the phases
|
||||
// that can exercise them: email in Phase 6, in-app in Phase 7. Declaring a
|
||||
// function nothing calls freezes a signature before anything has tried to use
|
||||
// it, which is the reason `transports/index.js` deferred this file at all.
|
||||
//
|
||||
// What forced it into Phase 3 rather than Phase 6: `notification_channel_prefs`
|
||||
// stores a mode only when a user has expressed one, so reading a preference
|
||||
// means knowing the channel's default — and §3.1 says `defaultMode` is expressed
|
||||
// **once**. A constant list beside the prefs model would be that expression in a
|
||||
// second place two phases before the registry replaced it.
|
||||
//
|
||||
// Nothing here touches the database, the network or a user record.
|
||||
|
||||
// id → channel definition, in registration order.
|
||||
const channels = new Map()
|
||||
|
||||
// The three modes a preference can take. `digest` is only offered by a channel
|
||||
// that declares `supportsDigest` — push and in-app are instant-only in v1,
|
||||
// because a digest of content-free tickles is not a thing you can batch.
|
||||
const MODES = ['off', 'instant', 'digest']
|
||||
|
||||
const isMode = (value) => MODES.includes(value)
|
||||
|
||||
/**
|
||||
* Register a delivery channel.
|
||||
*
|
||||
* Validate-then-commit, the same discipline `registerMailTransport` and
|
||||
* `modules/registries.js` use: every check runs before the map is touched, so a
|
||||
* rejected registration leaves nothing behind.
|
||||
*
|
||||
* @param {object} def
|
||||
* @param {string} def.id 'email' | 'push' | 'inapp' | later 'discord.dm'
|
||||
* @param {string} def.label operator/user-facing name
|
||||
* @param {boolean} def.carriesContent false for push — the tickle invariant, stated structurally
|
||||
* @param {string} def.defaultMode the mode that applies with no stored row
|
||||
* @param {boolean} def.supportsDigest may a preference for this channel be 'digest'
|
||||
* @param {string} [def.description] one line for the preferences screen
|
||||
* @param {(userId: number) => Promise<{address: string}|null>} [def.addressFor]
|
||||
* where this channel would send to, or null when it cannot reach the user
|
||||
* @param {(row: object) => Promise<{ok?: boolean, retry?: boolean, transport?: string, detail?: string, addressHash?: string}>}
|
||||
* [def.deliver] deliver one claimed outbox row. **Must not throw** — the
|
||||
* worker treats a throw as a transient failure, which is the right guess
|
||||
* and a worse answer than the channel's own classification. A channel
|
||||
* without one is declared but not yet deliverable, which is exactly what
|
||||
* `inapp` is until Phase 7; the worker finishes such a row `failed` and
|
||||
* says so in the send log rather than pretending it was sent.
|
||||
* @param {(userIds: number[]) => Promise<{userIds: number[], excluded: object}>} [def.eligible]
|
||||
* Phase 9. Narrow an already-resolved audience to the users this channel
|
||||
* may write an outbox row for, and say how many it dropped and why.
|
||||
*
|
||||
* **It exists so the engine can stay channel-agnostic.** The verification
|
||||
* gate is an email fact — an unverified address is a reason not to mail
|
||||
* somebody and no reason at all not to put an item in their inbox — and a
|
||||
* rule may name both channels. Filtering the shared audience before the
|
||||
* per-channel loop would have silenced the wrong sink; an `if (channel ===
|
||||
* 'email')` in `engine.js` would have put a channel's rule inside the
|
||||
* generic engine. This is the seam that is neither.
|
||||
*
|
||||
* Distinct from `deliver`'s refusals on purpose: this runs at ENQUEUE and
|
||||
* is for standing properties of a user (is this address verified), which
|
||||
* are stable across a delay window and are worth not writing a row for.
|
||||
* A suppression is not one of those — it can appear between the enqueue
|
||||
* and the send — so it is checked in `deliver`, where it produces a
|
||||
* `suppressed` row in the send log the acceptance criterion asks for.
|
||||
*/
|
||||
function registerDeliveryChannel(def) {
|
||||
if (!def || typeof def !== 'object') throw new Error('registerDeliveryChannel: definition required')
|
||||
const { id, label, carriesContent, defaultMode, supportsDigest } = def
|
||||
if (typeof id !== 'string' || !/^[a-z][a-z0-9_.-]*$/.test(id)) {
|
||||
throw new Error(`registerDeliveryChannel: invalid id ${JSON.stringify(id)}`)
|
||||
}
|
||||
if (channels.has(id)) throw new Error(`registerDeliveryChannel: ${id} is already registered`)
|
||||
if (typeof label !== 'string' || !label) throw new Error(`registerDeliveryChannel(${id}): label required`)
|
||||
if (typeof carriesContent !== 'boolean') {
|
||||
throw new Error(`registerDeliveryChannel(${id}): carriesContent must be declared explicitly`)
|
||||
}
|
||||
if (!isMode(defaultMode)) {
|
||||
throw new Error(`registerDeliveryChannel(${id}): defaultMode must be one of ${MODES.join(', ')}`)
|
||||
}
|
||||
if (typeof supportsDigest !== 'boolean') {
|
||||
throw new Error(`registerDeliveryChannel(${id}): supportsDigest must be declared explicitly`)
|
||||
}
|
||||
// A channel that cannot batch cannot default to batching. Cheap to check, and
|
||||
// the failure it prevents is a stored 'digest' row no delivery path can honour.
|
||||
if (defaultMode === 'digest' && !supportsDigest) {
|
||||
throw new Error(`registerDeliveryChannel(${id}): defaultMode 'digest' needs supportsDigest`)
|
||||
}
|
||||
// Optional, but not optionally-typed. A channel registering `deliver: true` or
|
||||
// a stale import that resolved to undefined would otherwise be a channel that
|
||||
// silently never delivers — the failure Phase 3 deferred the whole behavioural
|
||||
// half to avoid freezing, and the one the worker's "no delivery implementation
|
||||
// yet" branch would report as if it were by design.
|
||||
for (const fn of ['addressFor', 'deliver', 'eligible']) {
|
||||
if (def[fn] !== undefined && typeof def[fn] !== 'function') {
|
||||
throw new Error(`registerDeliveryChannel(${id}): ${fn} must be a function`)
|
||||
}
|
||||
}
|
||||
|
||||
channels.set(id, {
|
||||
id,
|
||||
label,
|
||||
description: def.description || null,
|
||||
carriesContent,
|
||||
defaultMode,
|
||||
supportsDigest,
|
||||
addressFor: def.addressFor,
|
||||
deliver: def.deliver,
|
||||
eligible: def.eligible,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Every channel, in registration order. The preferences screen's column set.
|
||||
*
|
||||
* **Declarative fields only** — `addressFor`, `deliver` and `eligible` are
|
||||
* stripped. This is what a route serializes, and a function on an object bound
|
||||
* for `res.json` is a key that silently disappears rather than an error; keeping
|
||||
* the boundary here means the API shape is decided in one place instead of by
|
||||
* JSON.stringify.
|
||||
*/
|
||||
const all = () =>
|
||||
[...channels.values()].map(({ addressFor, deliver, eligible, ...declared }) => ({ ...declared }))
|
||||
|
||||
/** Just the ids. */
|
||||
const ids = () => [...channels.keys()]
|
||||
|
||||
/** One channel, or null. Callers must handle null: a stored pref row can name a
|
||||
* channel that is no longer registered, and that must read as "off", not throw. */
|
||||
const get = (id) => {
|
||||
const c = channels.get(id)
|
||||
return c ? { ...c } : null
|
||||
}
|
||||
|
||||
const has = (id) => channels.has(id)
|
||||
|
||||
/** The mode that applies when the user has expressed nothing. An unregistered
|
||||
* channel is 'off' — never on by accident. */
|
||||
const defaultMode = (id) => (channels.get(id) || {}).defaultMode || 'off'
|
||||
|
||||
/** Which modes this channel will accept from a client. */
|
||||
const modesFor = (id) => {
|
||||
const c = channels.get(id)
|
||||
if (!c) return []
|
||||
return c.supportsDigest ? MODES.slice() : MODES.filter((m) => m !== 'digest')
|
||||
}
|
||||
|
||||
/** Is `mode` a mode this channel accepts? The gate on every preference write. */
|
||||
const acceptsMode = (id, mode) => modesFor(id).includes(mode)
|
||||
|
||||
/**
|
||||
* Narrow an audience to the users this channel may enqueue for (Phase 9).
|
||||
*
|
||||
* The default for a channel that declares no `eligible` is "everyone the
|
||||
* audience resolved to", which is what every channel but email does. It lives
|
||||
* here rather than at each call site so the two consumers — the engine and the
|
||||
* admin reach preview — cannot answer the question differently, which is exactly
|
||||
* how a preview comes to promise a number the engine will not deliver.
|
||||
*/
|
||||
async function eligibleFor(id, userIds) {
|
||||
const c = channels.get(id)
|
||||
if (!c || typeof c.eligible !== 'function') return { userIds: userIds.slice(), excluded: {} }
|
||||
const result = await c.eligible(userIds)
|
||||
return {
|
||||
userIds: (result && result.userIds) || [],
|
||||
excluded: (result && result.excluded) || {},
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only: the registry is module-level state.
|
||||
function _reset() {
|
||||
channels.clear()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MODES,
|
||||
isMode,
|
||||
registerDeliveryChannel,
|
||||
all,
|
||||
ids,
|
||||
get,
|
||||
has,
|
||||
defaultMode,
|
||||
modesFor,
|
||||
acceptsMode,
|
||||
eligibleFor,
|
||||
_reset,
|
||||
}
|
||||
251
server/src/engagement/conditions.js
Normal file
251
server/src/engagement/conditions.js
Normal file
@@ -0,0 +1,251 @@
|
||||
// ── Rule conditions — a predicate over a trigger's DECLARED variables ───────
|
||||
//
|
||||
// ENGAGEMENT.md §4.5, Phase 4a. `engagement_rules.conditions` is the half of a
|
||||
// rule that decides *whether* this particular firing is interesting: "only when
|
||||
// decayStatus is IDOC", "only for threads in this Team". Without it every rule is
|
||||
// all-or-nothing per trigger, and an operator's only way to narrow is to ask a
|
||||
// module author for a second trigger.
|
||||
//
|
||||
// **It is validated against the declaration, not against a payload.** A condition
|
||||
// naming a variable the trigger does not declare is refused at SAVE, with the
|
||||
// variable named, for the same reason §4.3 gives the template editor: a predicate
|
||||
// that silently reads `undefined` is a rule that silently never fires (or always
|
||||
// does), and the day you find out is the day the mail did not go.
|
||||
//
|
||||
// **The grammar is small and closed on purpose.** No arbitrary expressions, no
|
||||
// arithmetic, no regex. An operator composes and/or/not over comparisons of one
|
||||
// declared variable against a literal, and every operator here is one a rule
|
||||
// editor can render as a dropdown. Anything that needs more than this is asking
|
||||
// for a condition the module should have declared as a variable.
|
||||
//
|
||||
// Nothing in this file reaches the database or the network.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
|
||||
// Comparison operators, grouped by what they may be applied to. The grouping is
|
||||
// the whole of the type check: `gt` on a boolean and `startsWith` on an int are
|
||||
// both refused at save rather than quietly answering false forever.
|
||||
const OPERATORS = {
|
||||
eq: { label: 'is', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 1 },
|
||||
ne: { label: 'is not', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 1 },
|
||||
in: { label: 'is one of', types: ['string', 'int', 'float', 'url'], arity: 'list' },
|
||||
nin: { label: 'is none of', types: ['string', 'int', 'float', 'url'], arity: 'list' },
|
||||
gt: { label: 'is greater than', types: ['int', 'float', 'datetime'], arity: 1 },
|
||||
gte: { label: 'is at least', types: ['int', 'float', 'datetime'], arity: 1 },
|
||||
lt: { label: 'is less than', types: ['int', 'float', 'datetime'], arity: 1 },
|
||||
lte: { label: 'is at most', types: ['int', 'float', 'datetime'], arity: 1 },
|
||||
contains: { label: 'contains', types: ['string', 'url'], arity: 1 },
|
||||
startsWith: { label: 'starts with', types: ['string', 'url'], arity: 1 },
|
||||
// The one operator that takes no value: "the emit carried this variable at
|
||||
// all". It is the honest way to write a rule about an OPTIONAL variable, and
|
||||
// without it `ne` would have to double as a presence test and get it wrong
|
||||
// (an absent variable is not "not equal to X"; it is absent).
|
||||
present: { label: 'is present', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 0 },
|
||||
absent: { label: 'is absent', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 0 },
|
||||
}
|
||||
|
||||
const BOOLEAN_OPS = ['and', 'or', 'not']
|
||||
|
||||
// A list literal an operator may type. Bounded because it is stored in a JSON
|
||||
// column an admin can write, and an unbounded IN list is an unbounded predicate
|
||||
// evaluated on every event.
|
||||
const MAX_LIST = 50
|
||||
// Depth of the and/or/not tree. Three levels is more nesting than any rule
|
||||
// editor should offer; the bound is here so a hand-written JSON body cannot
|
||||
// recurse this evaluator into a stack overflow on the emit path.
|
||||
const MAX_DEPTH = 5
|
||||
|
||||
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/**
|
||||
* Check one literal against the declared type of the variable it is compared to.
|
||||
*
|
||||
* `datetime` accepts anything `Date` parses and is normalised to an ISO string,
|
||||
* which is what `engagementEmit.coerce` does to the payload side — so both sides
|
||||
* of every comparison are the same representation of a moment, and a lexical
|
||||
* `<` on two ISO strings is a chronological one.
|
||||
*/
|
||||
function checkLiteral(type, raw) {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
case 'url':
|
||||
return typeof raw === 'string' ? { value: raw } : { error: 'expected a string' }
|
||||
case 'int':
|
||||
return Number.isInteger(raw) ? { value: raw } : { error: 'expected an integer' }
|
||||
case 'float':
|
||||
return typeof raw === 'number' && Number.isFinite(raw)
|
||||
? { value: raw }
|
||||
: { error: 'expected a finite number' }
|
||||
case 'boolean':
|
||||
return typeof raw === 'boolean' ? { value: raw } : { error: 'expected a boolean' }
|
||||
case 'datetime': {
|
||||
const d = raw instanceof Date ? raw : new Date(raw)
|
||||
if (Number.isNaN(d.getTime())) return { error: 'expected a date' }
|
||||
return { value: d.toISOString() }
|
||||
}
|
||||
default:
|
||||
return { error: `unsupported type "${type}"` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a condition tree against a trigger declaration.
|
||||
*
|
||||
* Returns `{ ok: true, conditions }` with a NEW normalised tree — literals
|
||||
* coerced, unknown keys dropped — or `{ ok: false, errors }` listing every
|
||||
* problem rather than the first, the posture `validatePayload` takes and for the
|
||||
* same reason: an operator fixing one clause at a time is an operator making six
|
||||
* round trips through a form.
|
||||
*
|
||||
* `null` and `undefined` are valid and mean "no conditions" — a rule that fires
|
||||
* on every occurrence of its trigger, which is the common case.
|
||||
*/
|
||||
function validate(declaration, raw) {
|
||||
const errors = []
|
||||
const variables = new Map((declaration?.variables || []).map((v) => [v.name, v]))
|
||||
|
||||
function walk(node, depth, path) {
|
||||
if (depth > MAX_DEPTH) {
|
||||
errors.push(`${path}: nested deeper than ${MAX_DEPTH}`)
|
||||
return null
|
||||
}
|
||||
if (!isPlainObject(node)) {
|
||||
errors.push(`${path}: expected an object`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (BOOLEAN_OPS.includes(node.op)) {
|
||||
// `not` takes exactly one node; `and`/`or` take a list. Both are written
|
||||
// as `nodes` so a client walks one shape.
|
||||
const raws = Array.isArray(node.nodes) ? node.nodes : []
|
||||
if (!raws.length) {
|
||||
errors.push(`${path}: "${node.op}" has no nodes`)
|
||||
return null
|
||||
}
|
||||
if (node.op === 'not' && raws.length !== 1) {
|
||||
errors.push(`${path}: "not" takes exactly one node`)
|
||||
return null
|
||||
}
|
||||
const nodes = raws.map((child, i) => walk(child, depth + 1, `${path}.nodes[${i}]`)).filter(Boolean)
|
||||
return nodes.length === raws.length ? { op: node.op, nodes } : null
|
||||
}
|
||||
|
||||
if (node.op !== undefined) {
|
||||
errors.push(`${path}: unknown operator "${node.op}"`)
|
||||
return null
|
||||
}
|
||||
|
||||
// A leaf: { variable, cmp, value }.
|
||||
const variable = variables.get(node.variable)
|
||||
if (!variable) {
|
||||
errors.push(`${path}: "${node.variable}" is not a variable of "${declaration?.id}"`)
|
||||
return null
|
||||
}
|
||||
const operator = OPERATORS[node.cmp]
|
||||
if (!operator) {
|
||||
errors.push(`${path}: unknown comparison "${node.cmp}"`)
|
||||
return null
|
||||
}
|
||||
if (!operator.types.includes(variable.type)) {
|
||||
errors.push(`${path}: "${node.cmp}" cannot be applied to a ${variable.type}`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (operator.arity === 0) return { variable: variable.name, cmp: node.cmp }
|
||||
|
||||
if (operator.arity === 'list') {
|
||||
if (!Array.isArray(node.value) || !node.value.length) {
|
||||
errors.push(`${path}: "${node.cmp}" needs a non-empty list`)
|
||||
return null
|
||||
}
|
||||
if (node.value.length > MAX_LIST) {
|
||||
errors.push(`${path}: "${node.cmp}" list is longer than ${MAX_LIST}`)
|
||||
return null
|
||||
}
|
||||
const value = []
|
||||
let bad = false
|
||||
node.value.forEach((item, i) => {
|
||||
const checked = checkLiteral(variable.type, item)
|
||||
if (checked.error) {
|
||||
errors.push(`${path}.value[${i}]: ${checked.error}`)
|
||||
bad = true
|
||||
} else value.push(checked.value)
|
||||
})
|
||||
return bad ? null : { variable: variable.name, cmp: node.cmp, value }
|
||||
}
|
||||
|
||||
const checked = checkLiteral(variable.type, node.value)
|
||||
if (checked.error) {
|
||||
errors.push(`${path}: ${checked.error}`)
|
||||
return null
|
||||
}
|
||||
return { variable: variable.name, cmp: node.cmp, value: checked.value }
|
||||
}
|
||||
|
||||
if (raw === null || raw === undefined) return { ok: true, conditions: null }
|
||||
const conditions = walk(raw, 0, 'conditions')
|
||||
return errors.length ? { ok: false, errors } : { ok: true, conditions }
|
||||
}
|
||||
|
||||
/** Compare one already-normalised leaf against a payload. */
|
||||
function evaluateLeaf(leaf, data) {
|
||||
const present = Object.prototype.hasOwnProperty.call(data, leaf.variable)
|
||||
const actual = data[leaf.variable]
|
||||
|
||||
if (leaf.cmp === 'present') return present
|
||||
if (leaf.cmp === 'absent') return !present
|
||||
// Every other comparison against an absent variable is FALSE, never true.
|
||||
// `ne` is the one that tempts otherwise — "not equal to X" reads as satisfied
|
||||
// by nothing at all — and treating it as true would make an optional variable's
|
||||
// absence fire the rule.
|
||||
if (!present) return false
|
||||
|
||||
switch (leaf.cmp) {
|
||||
case 'eq': return actual === leaf.value
|
||||
case 'ne': return actual !== leaf.value
|
||||
case 'in': return leaf.value.includes(actual)
|
||||
case 'nin': return !leaf.value.includes(actual)
|
||||
case 'gt': return actual > leaf.value
|
||||
case 'gte': return actual >= leaf.value
|
||||
case 'lt': return actual < leaf.value
|
||||
case 'lte': return actual <= leaf.value
|
||||
case 'contains': return typeof actual === 'string' && actual.includes(leaf.value)
|
||||
case 'startsWith': return typeof actual === 'string' && actual.startsWith(leaf.value)
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this event's payload satisfy the rule's conditions?
|
||||
*
|
||||
* `null` conditions are satisfied — a rule with no conditions fires on every
|
||||
* occurrence. A tree this evaluator does not recognise answers **false**, which
|
||||
* is the fail-closed direction: a stored condition that no longer parses (a rule
|
||||
* saved against an older trigger version, say) must stop the mail rather than
|
||||
* become "no conditions" and mail everyone.
|
||||
*/
|
||||
function evaluate(conditions, data = {}) {
|
||||
if (conditions === null || conditions === undefined) return true
|
||||
if (!isPlainObject(conditions)) return false
|
||||
|
||||
if (conditions.op === 'and') return (conditions.nodes || []).every((n) => evaluate(n, data))
|
||||
if (conditions.op === 'or') return (conditions.nodes || []).some((n) => evaluate(n, data))
|
||||
if (conditions.op === 'not') return !evaluate((conditions.nodes || [])[0], data)
|
||||
if (conditions.op !== undefined) return false
|
||||
|
||||
return evaluateLeaf(conditions, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* The operator vocabulary a rule editor renders, with the variable types each
|
||||
* one applies to. Served with the rule surface in Phase 4b rather than hardcoded
|
||||
* in the client, on the same argument the ceiling vocabulary is served with the
|
||||
* trigger catalog: a second copy of a rule is a copy that drifts.
|
||||
*/
|
||||
const vocabulary = () =>
|
||||
Object.entries(OPERATORS).map(([cmp, o]) => ({ cmp, label: o.label, types: o.types, arity: o.arity }))
|
||||
|
||||
/** Convenience for a caller holding only a trigger id. */
|
||||
const validateFor = (triggerId, raw) => validate(registries.eventTrigger(triggerId), raw)
|
||||
|
||||
module.exports = { validate, validateFor, evaluate, vocabulary, OPERATORS, MAX_LIST, MAX_DEPTH }
|
||||
103
server/src/engagement/coreChannels.js
Normal file
103
server/src/engagement/coreChannels.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// ── Core's own delivery channels ───────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §3.1 / Phase 3. All three are core's, and none of them is a game
|
||||
// concept: a mailbox, a push endpoint and an inbox row are the same three things
|
||||
// on any shard running this platform.
|
||||
//
|
||||
// **They are declared here before two of them can deliver anything**, and that is
|
||||
// deliberate rather than premature. A preference is a durable user statement; the
|
||||
// three columns of the preferences screen have to exist from the moment the table
|
||||
// does, or the first person to open it after Phase 6 finds an email toggle that
|
||||
// has never had a value and a screen that changed shape under them. Registering
|
||||
// the metadata early costs nothing — the registry holds no behaviour yet — while
|
||||
// registering it late means back-filling opinions users were never asked for.
|
||||
//
|
||||
// The `defaultMode`s below are the whole of G9: "per-channel defaults differ and
|
||||
// there is nowhere to express that generically". This is that place.
|
||||
|
||||
const { registerDeliveryChannel } = require('./channels')
|
||||
const emailChannel = require('./emailChannel')
|
||||
const pushChannel = require('./pushChannel')
|
||||
const inappChannel = require('./inappChannel')
|
||||
|
||||
const CHANNELS = [
|
||||
{
|
||||
id: 'push',
|
||||
label: 'Push',
|
||||
description: 'A silent tickle to your phone; the app then pulls the real content.',
|
||||
// The tickle invariant (docs/android/PLAN.md §11), stated structurally rather
|
||||
// than as a comment: what leaves the server on this channel is { stream, ref }
|
||||
// and never a message body. Phase 7's `deliver` reads this flag; declaring it
|
||||
// false here is what makes "push must not carry content" a property of the
|
||||
// registration instead of a rule each caller has to remember.
|
||||
carriesContent: false,
|
||||
// **Opt-IN, and this is the one place the phase's own acceptance line was
|
||||
// wrong.** ENGAGEMENT.md Phase 3 said a fresh user's push defaults to
|
||||
// 'instant'; §3.1 called push "opt-OUT", borrowing the semantics of
|
||||
// `team_notification_prefs` (where no row does mean notified). But push
|
||||
// STREAM subscriptions have never worked that way: `notification_subscriptions`
|
||||
// holds a row only when a user opted in, so no row means not subscribed.
|
||||
// Defaulting to 'instant' here would have projected the entire catalog into
|
||||
// `GET /auth/me/notifications/subscriptions` for every existing user, and the
|
||||
// shipped Android client would have shown every toggle switched on after an
|
||||
// upgrade nobody asked for. Settled by the org lead 2026-08-29: 'off'.
|
||||
defaultMode: 'off',
|
||||
// A batched tickle is a contradiction — the content is not in the message, so
|
||||
// there is nothing to roll up. Ten events are ten wakeups or one; either way
|
||||
// the app pulls the same inbox.
|
||||
supportsDigest: false,
|
||||
// Phase 7: the oldest sink is the last to get a `deliver`, because until the
|
||||
// inbox existed there was nothing for a content-free tickle to point at.
|
||||
addressFor: pushChannel.addressFor,
|
||||
deliver: pushChannel.deliver,
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
label: 'Email',
|
||||
description: 'A message to your verified address.',
|
||||
carriesContent: true,
|
||||
// Opt-IN, per §7.1 Q1: standard marketing-email practice, and the posture
|
||||
// `team_notification_prefs.email_mode` already takes ('off' by default).
|
||||
defaultMode: 'off',
|
||||
supportsDigest: true,
|
||||
// Phase 6: the first channel with a body. `supportsDigest` above is now load-
|
||||
// bearing rather than aspirational — a 'digest' preference means the engine
|
||||
// writes NO outbox row and the digest worker re-derives the content at send
|
||||
// time (§4.2b), which is a different delivery path rather than a batched one.
|
||||
addressFor: emailChannel.addressFor,
|
||||
deliver: emailChannel.deliver,
|
||||
// Phase 9: the only channel that declares one. The Phase 1b verification
|
||||
// gate is an email fact, and this is the seam that keeps it out of the
|
||||
// generic engine - see channels.js's `eligible` docs.
|
||||
eligible: emailChannel.eligible,
|
||||
},
|
||||
{
|
||||
id: 'inapp',
|
||||
label: 'On the site',
|
||||
description: 'An item in your notification inbox on the website and in the app.',
|
||||
carriesContent: true,
|
||||
// **Opt-OUT, and the only one of the three that is** — settled by the org
|
||||
// lead 2026-08-31, which is the Phase 7 decision this comment used to defer.
|
||||
//
|
||||
// The argument against a live default was never about in-app: it was that
|
||||
// push wakes a device the user is holding and email leaves the building, so
|
||||
// both must be asked for. An inbox item does neither. It is a row on a page
|
||||
// the user chose to open, on this deployment, costing them one glance — and
|
||||
// left at 'off' the surface would ship dead, because no rule could reach
|
||||
// anyone until every user found a toggle for a channel they had never seen
|
||||
// deliver anything. The backlog Phase 3 worried about cannot happen either:
|
||||
// the table is empty at cutover, rules default to `enabled = 0`, and every
|
||||
// rule carries a per-hour ceiling.
|
||||
defaultMode: 'instant',
|
||||
// Instant-only, and unlike push the reason is not that batching is
|
||||
// meaningless — it is that the inbox IS the batch. A digest of inbox items
|
||||
// is a list of things already sitting in a list.
|
||||
supportsDigest: false,
|
||||
addressFor: inappChannel.addressFor,
|
||||
deliver: inappChannel.deliver,
|
||||
},
|
||||
]
|
||||
|
||||
for (const channel of CHANNELS) registerDeliveryChannel(channel)
|
||||
|
||||
module.exports = { CHANNELS }
|
||||
229
server/src/engagement/coreRules.js
Normal file
229
server/src/engagement/coreRules.js
Normal file
@@ -0,0 +1,229 @@
|
||||
// ── The five rules core ships, all of them OFF ─────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 6, decision 3. Before this phase the Team pipeline mailed
|
||||
// people with no operator configuration at all: the code decided who was mailed
|
||||
// and about what, and the only knobs were per-user. Phase 6 moves that decision
|
||||
// onto rules — which default `enabled = 0`, and of which core seeds none.
|
||||
//
|
||||
// **So a straight migration would have stopped Team email on every existing
|
||||
// deployment, silently.** The org lead's decision was to honour the invariant
|
||||
// rather than carve an exception into it: the rules are seeded, and they are
|
||||
// seeded OFF. Team email resumes when an operator opens Admin → Engagement →
|
||||
// Rules and switches one on, and until then the admin screen says so in as many
|
||||
// words (`EngagementRules.jsx`). The release note names it.
|
||||
//
|
||||
// The alternative — seeding them enabled so nothing changes for anybody — was
|
||||
// considered and refused. "Nothing is seeded, nothing is on by default" is what
|
||||
// makes a rules table safe to restore, import or replicate, and an exception
|
||||
// carved for the one pipeline that predates the engine is an exception that has
|
||||
// to be re-argued every time somebody reads the invariant.
|
||||
//
|
||||
// **Seeded once, not ensured on every boot**, and the difference matters: an
|
||||
// operator who deletes a rule must not find it back after a restart. The guard is
|
||||
// a settings key, the same mechanism a one-shot migration uses, so a deployment
|
||||
// that has seen this seed never sees it again — deleted rules stay deleted, and
|
||||
// an enabled rule stays enabled rather than being reset to off.
|
||||
|
||||
// **Phase 11 added a fifth, for `news.post`, and it needed its OWN one-shot key
|
||||
// rather than an entry in the list above.** The Team key is already stamped on
|
||||
// every deployment that has booted since Phase 6, and the guard reads its
|
||||
// presence — so appending to `RULES` would have seeded the news rule on fresh
|
||||
// installs only, and on exactly the upgrades that need it, never. Those are the
|
||||
// deployments where `pushDispatch.publish('news.post', …)` used to run and no
|
||||
// longer does (§7.1 Q9): they would have lost news push with no rule to switch
|
||||
// on and no way to tell why. One key per seed GROUP is the rule this establishes;
|
||||
// a sixth rule for a new trigger takes a sixth key, and a rule added to an
|
||||
// existing group is a rule that only fresh installs will ever see.
|
||||
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const settingsDb = require('../model/settings/settings.db')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
// The one-shot guard. Its VALUE is the timestamp, purely so an operator reading
|
||||
// the settings table can tell when it ran; only its presence is read.
|
||||
const SEEDED_KEY = 'engagement_team_rules_seeded'
|
||||
|
||||
// Phase 11's, and separate for the reason above. Same shape, same semantics.
|
||||
const NEWS_SEEDED_KEY = 'engagement_news_rule_seeded'
|
||||
|
||||
const RULES = [
|
||||
{
|
||||
trigger_id: 'team.forum.post',
|
||||
name: 'Team forum posts',
|
||||
// `members`, which resolves to the recipient set the event carries — the
|
||||
// access-checked list `teamNotify` has always computed. Not `authenticated`,
|
||||
// and the trigger's own ceiling would refuse that anyway: a private Team's
|
||||
// forum excerpt reaching the whole site is the failure G24 exists for.
|
||||
audience: 'members',
|
||||
channels: ['email'],
|
||||
// `email` is the instant body; `digest` is what the digest worker renders.
|
||||
// Two keys because they are two different messages — a template written for
|
||||
// one post renders a day of them as a single missing variable.
|
||||
template_keys: { email: 'notify.team-post', digest: 'notify.digest' },
|
||||
// No cooldown. A busy thread is exactly what the per-user `email_mode` and
|
||||
// the digest option are for, and a cooldown here would silently drop the
|
||||
// second reply of a conversation rather than batching it.
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
{
|
||||
trigger_id: 'team.announcement',
|
||||
name: 'Team announcements',
|
||||
audience: 'members',
|
||||
channels: ['email'],
|
||||
// **The generic body, not `notify.team-post`, and the reason is a naming
|
||||
// inconsistency in the Phase 2 declarations rather than a design choice
|
||||
// here.** The two triggers describe the same underlying thing — a thread in a
|
||||
// Team forum — but `team.forum.post` declares its title as `threadTitle` and
|
||||
// `team.announcement` declares it as `title`. A template can only name one of
|
||||
// them, so `notify.team-post`'s `{{threadTitle}}` renders empty for an
|
||||
// announcement. `notify.event` + the structural projection gets it right
|
||||
// (`title` is in the payload, `actionUrl` falls back to `postUrl`), and
|
||||
// reconciling the two declarations is a version bump this phase did not take
|
||||
// on its own authority.
|
||||
template_keys: { email: 'notify.event', digest: 'notify.digest' },
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 500,
|
||||
},
|
||||
{
|
||||
trigger_id: 'team.member.joined',
|
||||
name: 'Team — new member',
|
||||
audience: 'members',
|
||||
channels: ['email'],
|
||||
// The generic body: `notify.event` plus the structural projection renders it
|
||||
// with no authoring (§4.6.1 property 1). A deployment that wants a better one
|
||||
// duplicates the template and points this rule at the copy.
|
||||
template_keys: { email: 'notify.event' },
|
||||
// An hour, per user per Team. This is the rule §6.4 argued should not exist
|
||||
// as a sink at all — a fifteen-minute sweep, already on the activity feed —
|
||||
// and the cooldown is what makes it survivable for the operator who wants it
|
||||
// anyway: a guild recruiting ten people in an afternoon sends one mail.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
{
|
||||
trigger_id: 'team.leadership.changed',
|
||||
name: 'Team — leadership change',
|
||||
audience: 'members',
|
||||
channels: ['email'],
|
||||
template_keys: { email: 'notify.event' },
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
]
|
||||
|
||||
// Phase 11's one rule, in its own list so it can carry its own one-shot key.
|
||||
const NEWS_RULES = [
|
||||
{
|
||||
trigger_id: 'news.post',
|
||||
name: 'News posts',
|
||||
// `subscribers`, which is the trigger's declared default and the population
|
||||
// `pushDispatch.publish('news.post', …)` used to reach directly: users who
|
||||
// opted into this id on at least one channel. Not `authenticated`, even
|
||||
// though the trigger's ceiling permits it — a news post is worth telling
|
||||
// people who asked to be told, and mailing the whole user table on every
|
||||
// publish is how a notification feature earns a spam complaint.
|
||||
audience: 'subscribers',
|
||||
// **All three channels, unlike the Team rules' `email` alone**, and that is
|
||||
// the continuity half of §7.1 Q9's answer. Push is on this rule because push
|
||||
// is what the raw tickle did; leaving it off would mean an operator who
|
||||
// enabled the rule to restore news push got mail instead. In-app rides along
|
||||
// because the inbox is the surface a tickle deep-links into (Phase 7).
|
||||
channels: ['email', 'inapp', 'push'],
|
||||
// The generic body plus the structural projection (§4.6.1 property 1):
|
||||
// `news.post` declares its own `title` and `postUrl`, which the projection
|
||||
// leaves exactly as emitted, so an unauthored mail already names the post and
|
||||
// links it. `inapp.event` is the in-app renderer's; push carries no content
|
||||
// by construction and needs no template.
|
||||
template_keys: { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' },
|
||||
// An hour, per USER — `news.post` declares no `subjectKey`, so the cooldown
|
||||
// subject is the recipient. "Do not tell me about news more than once an
|
||||
// hour" is the useful rule; keying it per post would make it a no-op, since
|
||||
// every post is a new subject.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 1000,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Seed one group of rules, once, under its own guard key.
|
||||
*
|
||||
* Never throws: it is on the boot path beside `seedTemplates`, and a rule that
|
||||
* failed to seed costs an operator one visit to the "new rule" form, not a
|
||||
* deployment.
|
||||
*
|
||||
* @param {string} key the one-shot settings guard for THIS group
|
||||
* @param {object[]} rules
|
||||
* @param {string} note what the boot log should say when it inserts
|
||||
*/
|
||||
async function seedGroup(key, rules, note) {
|
||||
const summary = { inserted: 0, skipped: 0 }
|
||||
try {
|
||||
const seen = await settingsDb.get(key)
|
||||
if (seen) return { ...summary, skipped: rules.length }
|
||||
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
await rulesDb.insert({
|
||||
audience_segment_id: null,
|
||||
conditions: null,
|
||||
// No delay and nothing cancels these. `delay_seconds` is the grace
|
||||
// window a cancelling event needs, and nothing cancels "someone
|
||||
// posted" — the post happened.
|
||||
delay_seconds: 0,
|
||||
cancel_on: [],
|
||||
...rule,
|
||||
enabled: 0,
|
||||
updated_by: null,
|
||||
})
|
||||
summary.inserted += 1
|
||||
} catch (err) {
|
||||
log.error('team rule seed failed', { trigger: rule.trigger_id, message: err.message })
|
||||
}
|
||||
}
|
||||
// Stamped even on a partial run. Re-running would duplicate the rules that
|
||||
// did insert, and a duplicate rule is two mails per event — a worse outcome
|
||||
// than the one missing rule an operator can add from the screen.
|
||||
await settingsDb.set(key, new Date().toISOString())
|
||||
if (summary.inserted) {
|
||||
log.info('seeded engagement rules, all disabled', { rules: summary.inserted, note })
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('rule seeding failed', { key, message: err.message })
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/** The four Team rules (Phase 6). */
|
||||
const seedTeamRules = () =>
|
||||
seedGroup(SEEDED_KEY, RULES, 'Team email stays off until an operator enables one')
|
||||
|
||||
/** The one news rule (Phase 11). */
|
||||
const seedNewsRule = () =>
|
||||
seedGroup(NEWS_SEEDED_KEY, NEWS_RULES, 'News notifications stay off until an operator enables this rule')
|
||||
|
||||
/**
|
||||
* Both groups, which is what the boot path calls.
|
||||
*
|
||||
* Sequential rather than concurrent, and not for correctness — each group has its
|
||||
* own guard key and its own rows — but so the boot log reads in a fixed order and
|
||||
* a failure names one group rather than an interleaving of two.
|
||||
*/
|
||||
async function seedCoreRules() {
|
||||
const team = await seedTeamRules()
|
||||
const news = await seedNewsRule()
|
||||
return {
|
||||
inserted: team.inserted + news.inserted,
|
||||
skipped: team.skipped + news.skipped,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
seedCoreRules,
|
||||
seedTeamRules,
|
||||
seedNewsRule,
|
||||
RULES,
|
||||
NEWS_RULES,
|
||||
SEEDED_KEY,
|
||||
NEWS_SEEDED_KEY,
|
||||
}
|
||||
64
server/src/engagement/coreScopePrefs.js
Normal file
64
server/src/engagement/coreScopePrefs.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// ── Core's own scope-preference provider: Teams ────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 6, decision 4. `team_notification_prefs` stays exactly
|
||||
// where it is and keeps exactly the meaning it has had since Teams shipped; this
|
||||
// is the adapter that lets the generic engine read it without knowing what a Team
|
||||
// is. Registered here rather than at the bottom of `scopedPrefs.js` for the same
|
||||
// reason `coreChannels` and `transports/smtp` are: requiring a registry must not
|
||||
// have the side effect of populating it.
|
||||
//
|
||||
// **The two columns say different things and the mapping is not symmetric.**
|
||||
//
|
||||
// - `muted` is the Team's master switch and it silences EVERY channel. That is
|
||||
// what the toggle has always meant on the account screen ("mute this Team"),
|
||||
// and narrowing it to email would be a behaviour change nobody asked for. Note
|
||||
// this is belt-and-braces on the live path — `teamNotify.recipientIds` already
|
||||
// excludes muted users before the event is emitted — and it is here anyway so
|
||||
// the meaning survives an emitter that stops filtering.
|
||||
// - `email_mode` says nothing about any other channel, so on push or in-app this
|
||||
// provider returns no opinion and the stream-level preference decides.
|
||||
//
|
||||
// **Absence of a row means 'off' for email, and that is the whole reason this
|
||||
// provider answers for every user rather than only for the rows it finds.** The
|
||||
// column defaults to `'off'` and both recipient queries COALESCE to it: no row
|
||||
// has always meant "this person has not asked for Team email". Deferring to the
|
||||
// stream-level preference instead would mean a user who once switched on
|
||||
// `team.forum.post` email in the channels screen starts receiving mail from every
|
||||
// Team on the deployment — a widening, produced by a migration, of a preference
|
||||
// they expressed about something else.
|
||||
|
||||
const { registerScopePreference } = require('./scopedPrefs')
|
||||
const teamNotify = require('../model/teams/teamNotify.model')
|
||||
|
||||
// team_notification_prefs.email_mode → the three modes the engine speaks. The
|
||||
// vocabularies differ by one word and only one word: 'immediate' predates
|
||||
// `notification_channel_prefs`, whose ENUM says 'instant'.
|
||||
const EMAIL_MODE = { off: 'off', immediate: 'instant', digest: 'digest' }
|
||||
|
||||
async function modesFor(userIds, channel, scopeId) {
|
||||
const teamId = Number(scopeId)
|
||||
if (!Number.isInteger(teamId) || teamId < 1) return new Map()
|
||||
|
||||
const rows = await teamNotify.prefsForTeam(userIds, teamId)
|
||||
const byUser = new Map(rows.map((r) => [Number(r.user_id), r]))
|
||||
const modes = new Map()
|
||||
|
||||
for (const userId of userIds) {
|
||||
const row = byUser.get(Number(userId))
|
||||
if (row && Number(row.muted)) {
|
||||
modes.set(Number(userId), 'off')
|
||||
continue
|
||||
}
|
||||
if (channel !== 'email') continue // no opinion; the stream preference decides
|
||||
modes.set(Number(userId), EMAIL_MODE[(row && row.email_mode) || 'off'] || 'off')
|
||||
}
|
||||
return modes
|
||||
}
|
||||
|
||||
registerScopePreference({
|
||||
prefix: 'team',
|
||||
label: 'Team',
|
||||
modesFor,
|
||||
})
|
||||
|
||||
module.exports = { modesFor, EMAIL_MODE }
|
||||
241
server/src/engagement/emailChannel.js
Normal file
241
server/src/engagement/emailChannel.js
Normal file
@@ -0,0 +1,241 @@
|
||||
// ── The email DeliveryChannel: addressFor + deliver ────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 6. Phase 3 declared this channel and deliberately left it
|
||||
// behaviourless ("declaring a function nothing calls freezes a signature before
|
||||
// anything has tried to use it"); this is the phase that has something to try it
|
||||
// with, and the signature survived unchanged.
|
||||
//
|
||||
// **What it does is four lookups and one send**, and the order matters because
|
||||
// each step is a way the mail should not go out:
|
||||
//
|
||||
// 1. the address — re-checked for `status = 'active'`, because a delayed
|
||||
// row can outlive the account it was queued for
|
||||
// 2. the rule — for its per-channel template key; the outbox row
|
||||
// carries `rule_id` and FK CASCADE guarantees it exists
|
||||
// 3. the values — the payload snapshot, plus §4.6.1's structural
|
||||
// projection, plus this recipient's unsubscribe link
|
||||
// 4. the template — `renderByKey`, which falls back to the shipped seed
|
||||
// rather than failing, and refuses a draft
|
||||
// 5. the send — `mailer.sendNotification`, which classifies rather
|
||||
// than throwing
|
||||
//
|
||||
// **It never throws**, and that is a stronger statement than the worker's
|
||||
// `try/catch` around it: a throw would be read as a transient failure and retried
|
||||
// five times, so an unrenderable template would become five identical failures in
|
||||
// the send log instead of one honest terminal row.
|
||||
//
|
||||
// **The unsubscribe link is per recipient and is built from `scope_key`, never
|
||||
// from `subject_key`.** They differ for every Team event: the subject is
|
||||
// `teamName` (a display string the cooldown keys on) and the scope is `team:12`.
|
||||
// A Team renamed between the mail and the click must not orphan the link in it.
|
||||
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const recipients = require('../model/engagement/engagementRecipients.db')
|
||||
const templates = require('./templates')
|
||||
const projection = require('./projection')
|
||||
const suppressions = require('./suppressions')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const unsubscribeToken = require('../utils/unsubscribeToken')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
// **Required lazily, and it is a real cycle rather than a style preference.**
|
||||
// `engagement/index.js` requires `coreChannels`, which requires this file; and
|
||||
// `utils/mailer` requires `engagement/index` for the transport registry. A
|
||||
// top-level `require('../utils/mailer')` here therefore resolves while
|
||||
// `engagement/index` is mid-evaluation, so mailer would capture `{}` for
|
||||
// `transports` and every send would fail on `transports.get is not a function` —
|
||||
// at send time, on a deployment, with the boot log clean. Resolved at call time
|
||||
// instead, by which point both modules are fully evaluated.
|
||||
const mailer = () => require('../utils/mailer')
|
||||
|
||||
// The template a rule renders through when it names none. §4.6.1 property 1: a
|
||||
// new trigger must be mailable with no authoring at all, and this plus
|
||||
// `projection.project` is that property's implementation.
|
||||
const DEFAULT_TEMPLATE = 'notify.event'
|
||||
|
||||
const baseUrl = () => templates.baseUrl()
|
||||
|
||||
// Re-exported rather than defined here since Phase 9: the send log's hash and
|
||||
// the suppression list's key have to be the same function or a bounce never finds
|
||||
// the row it belongs to. `suppressions.js` owns it, next to the masking.
|
||||
const hashAddress = suppressions.hashAddress
|
||||
|
||||
/**
|
||||
* The two unsubscribe URLs for one recipient of one scope, or nulls.
|
||||
*
|
||||
* 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 for 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 clients that render the header as an ordinary link.
|
||||
*
|
||||
* A scope the token format cannot carry yields nulls rather than an exception:
|
||||
* the mail is worth sending without a one-click unsubscribe, and the recipient
|
||||
* still has the preferences screen. It is logged because it is a programming
|
||||
* error in whatever chose the scope key.
|
||||
*/
|
||||
function unsubscribeUrls(userId, scopeKey) {
|
||||
try {
|
||||
const token = unsubscribeToken.sign(userId, 'email', scopeKey || '')
|
||||
const base = baseUrl()
|
||||
return {
|
||||
unsubscribeUrl: `${base}/unsubscribe/${token}`,
|
||||
unsubscribeApiUrl: `${base}/api/v1/public/engagement/unsubscribe/${token}`,
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('could not build an unsubscribe link', { scope: scopeKey, message: err.message })
|
||||
return { unsubscribeUrl: null, unsubscribeApiUrl: null }
|
||||
}
|
||||
}
|
||||
|
||||
/** Where this channel would send to, or null. */
|
||||
const addressFor = (userId) => recipients.addressFor(userId)
|
||||
|
||||
/**
|
||||
* Narrow an audience to the users this channel may write an outbox row for
|
||||
* (Phase 9, decision 4).
|
||||
*
|
||||
* **One gate, and it is the Phase 1b verification setting.** With
|
||||
* `email_verification_required` on, a user whose address is unverified is
|
||||
* excluded here rather than refused at delivery, and the org lead settled it that
|
||||
* way for two reasons. It is a STANDING property — unlike a suppression, which
|
||||
* can appear inside a `delay_seconds` window and therefore has to be re-checked
|
||||
* at send time — so the outbox row would be written only to be thrown away. And a
|
||||
* deployment that upgraded before verifying anybody has an audience that is
|
||||
* almost entirely unverified: excluding at delivery would write a `suppressed`
|
||||
* row per person per rule firing, which is a send log nobody can read.
|
||||
*
|
||||
* The count comes back so the admin reach preview can say "1,204 excluded:
|
||||
* unverified" instead of quietly promising a number the engine will not deliver.
|
||||
*
|
||||
* **It fails OPEN, and the try/catch is load-bearing rather than defensive
|
||||
* habit.** `settings.isEmailVerificationRequired` swallows its own errors and
|
||||
* answers `off`, but `unverifiedAmong` does not, and an uncaught throw here does
|
||||
* not fail one recipient — `applyRule` awaits this before the per-user loop, so
|
||||
* it would abandon the whole rule for every channel it names. A database having
|
||||
* a bad minute would become a rule that silently sent nothing, with a clean send
|
||||
* log and nothing in the outbox to retry. Same direction as the suppression
|
||||
* check, for the same reason: the recoverable mistake is mail going out, not mail
|
||||
* silently stopping.
|
||||
*/
|
||||
async function eligible(userIds) {
|
||||
const list = userIds || []
|
||||
if (!list.length) return { userIds: [], excluded: {} }
|
||||
try {
|
||||
if (!(await settings.isEmailVerificationRequired())) {
|
||||
return { userIds: list.slice(), excluded: {} }
|
||||
}
|
||||
const unverified = await recipients.unverifiedAmong(list)
|
||||
if (!unverified.size) return { userIds: list.slice(), excluded: {} }
|
||||
return {
|
||||
userIds: list.filter((id) => !unverified.has(Number(id))),
|
||||
excluded: { unverified: unverified.size },
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('verification gate could not be evaluated; not excluding anyone', { message: err.message })
|
||||
return { userIds: list.slice(), excluded: {} }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one claimed outbox row.
|
||||
*
|
||||
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
|
||||
*/
|
||||
async function deliver(row) {
|
||||
try {
|
||||
const to = await addressFor(row.user_id)
|
||||
if (!to) {
|
||||
// Terminal. Retrying does not give somebody an address, and a banned
|
||||
// account is not going to be un-banned by a five-minute backoff.
|
||||
return { ok: false, detail: 'no deliverable address for this user' }
|
||||
}
|
||||
|
||||
const rule = await rulesDb.getById(row.rule_id)
|
||||
const key = (rule && rule.template_keys && rule.template_keys.email) || DEFAULT_TEMPLATE
|
||||
|
||||
// Once, not once per use: the body's link and the header's must be the same
|
||||
// token, or a client that offers both offers two different unsubscribes.
|
||||
const unsub = unsubscribeUrls(row.user_id, row.scope_key)
|
||||
const values = projection.project(row.trigger_id, row.payload || {}, unsub)
|
||||
|
||||
const rendered = await templates.renderByKey(key, values)
|
||||
if (!rendered) {
|
||||
// Neither a usable row nor a shipped seed. Terminal, and it names the key:
|
||||
// the operator deleted a template a rule points at, which the admin surface
|
||||
// refuses with a 409 — so reaching here means it happened out of band.
|
||||
return { ok: false, detail: `no template and no shipped default for "${key}"` }
|
||||
}
|
||||
if (rendered.missing.length) {
|
||||
// Not a refusal: an optional variable a trigger chose not to supply renders
|
||||
// as nothing by design. Logged with NAMES ONLY, never values — the same
|
||||
// rule the emit and dispatch log lines follow.
|
||||
log.debug('template variables had no value', { key, missing: rendered.missing })
|
||||
}
|
||||
|
||||
// **The suppression check is HERE and not at enqueue** (Phase 9). An outbox
|
||||
// row can sit through a `delay_seconds` grace window, and an address can hard
|
||||
// bounce inside it — so the only check that can be correct is the one taken
|
||||
// immediately before the transport call. It is also the check the acceptance
|
||||
// criterion describes: a `suppressed` row in the send log, and no transport
|
||||
// call at all.
|
||||
const blocked = await suppressions.isSuppressed(to.address)
|
||||
if (blocked) {
|
||||
return {
|
||||
ok: false,
|
||||
suppressed: true,
|
||||
detail: `address is suppressed (${blocked.reason})`,
|
||||
addressHash: hashAddress(to.address),
|
||||
}
|
||||
}
|
||||
|
||||
const result = await mailer().sendNotification({ to: to.address, rendered, ...unsub })
|
||||
|
||||
// A failed send is where a hard bounce enters the system on SMTP, and the
|
||||
// reason it is worth catching rather than waiting for an API provider: a
|
||||
// single-recipient send refused at RCPT TO is a synchronous 5.1.1, which is
|
||||
// the most valuable deliverability signal there is and it was already being
|
||||
// thrown away. `considerFailure` is narrow — see bounceClassify.js — and its
|
||||
// note goes into the log on BOTH outcomes, so "this failed and was not
|
||||
// suppressed" says why.
|
||||
if (result && !result.ok && result.smtp) {
|
||||
const verdict = await suppressions.considerFailure({ address: to.address, error: result.smtp })
|
||||
// A bounce is terminal by definition. Overriding `retry` matters because
|
||||
// `PERMANENT_CODES` does not contain every code that can carry a 5.1.x, so
|
||||
// without this a genuine dead mailbox could still be retried four more
|
||||
// times — each one another refusal on our record with the relay.
|
||||
if (verdict.suppressed) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
// `engagement_sends.status` has carried 'bounced' since §4.5 and
|
||||
// nothing wrote it until here, so the Send Log's "Bounced" filter
|
||||
// matched nothing — the live rig is what showed that. It is a distinct
|
||||
// status rather than a flavour of 'failed' because the two need
|
||||
// different actions: a failure means look at the relay, and a bounce
|
||||
// means that person's address is gone.
|
||||
bounced: true,
|
||||
transport: result.transport,
|
||||
detail: `${result.detail} — ${verdict.note}`,
|
||||
addressHash: hashAddress(to.address),
|
||||
}
|
||||
}
|
||||
return { ...result, detail: `${result.detail} — ${verdict.note}`, addressHash: hashAddress(to.address) }
|
||||
}
|
||||
|
||||
// The send log stores a sha256 of the address and never the address itself
|
||||
// (schema.sql): enough to correlate a bounce, useless as a mailing list.
|
||||
// Attached on every outcome, because a failure is exactly the row a bounce
|
||||
// would need to be matched against.
|
||||
return { ...result, addressHash: hashAddress(to.address) }
|
||||
} catch (err) {
|
||||
// See the header: a throw here would be retried as if it were the relay's
|
||||
// fault. Classified as terminal instead, with the reason in the send log.
|
||||
log.error('email delivery failed', { outbox: row.id, message: err.message })
|
||||
return { ok: false, detail: `delivery error: ${err.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { addressFor, eligible, deliver, unsubscribeUrls, hashAddress, DEFAULT_TEMPLATE }
|
||||
328
server/src/engagement/engine.js
Normal file
328
server/src/engagement/engine.js
Normal file
@@ -0,0 +1,328 @@
|
||||
// ── The engagement engine ──────────────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 4a. `ctx.events.emit` validated a payload against a
|
||||
// declaration and stopped (Phase 2); this is what it now hands the validated
|
||||
// event to. The engine's whole job is to answer, for one event, **who gets told,
|
||||
// on what, and not too often** - and then to write that down as outbox rows.
|
||||
// It never delivers: `engagementWorker` drains the outbox, and what actually
|
||||
// carries a message arrives with the channels' `deliver` in Phases 6 and 7.
|
||||
//
|
||||
// **The order of the gates is the design, and each one is here because skipping
|
||||
// it is a way to mail the wrong people or too many of them:**
|
||||
//
|
||||
// 1. enabled rules for this trigger - nothing is seeded, nothing is on by default
|
||||
// 2. conditions - is this particular firing interesting
|
||||
// 3. audience -> user ids - core's tables, or a composed segment
|
||||
// 4. ceiling re-check (G24) - re-run at SEND time, not only at save
|
||||
// 5. per-channel preference - a user's own opt-in, effective mode
|
||||
// 6. per-rule hourly ceiling (§7.1 Q3) - the hard stop that makes rules-as-data safe
|
||||
// 7. cooldown, per (rule, user, subject) - one statement, so two emits cannot race
|
||||
// 8. enqueue, deduped - a replayed event is one row, not two
|
||||
//
|
||||
// Steps 6 and 7 are in that order deliberately. The hourly ceiling is about the
|
||||
// RULE and is the thing that stops a mail storm; the cooldown is about one
|
||||
// recipient and one subject. Checking the cheap global bound before consuming a
|
||||
// per-recipient cooldown slot means a rule that has hit its ceiling does not also
|
||||
// silently burn everybody's cooldowns on sends that never happen.
|
||||
//
|
||||
// **Nothing here throws at its caller.** It is invoked from inside a game-event
|
||||
// handler by way of `ctx.events.emit`, and a database problem of core's must not
|
||||
// become a module's control flow (the same posture the emit validator takes).
|
||||
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const outboxDb = require('../model/engagement/engagementOutbox.db')
|
||||
const cooldownsDb = require('../model/engagement/engagementCooldowns.db')
|
||||
const sendsDb = require('../model/engagement/engagementSends.db')
|
||||
const recipients = require('../model/engagement/engagementRecipients.db')
|
||||
const conditions = require('./conditions')
|
||||
const audiences = require('./audiences')
|
||||
const channels = require('./channels')
|
||||
const scopedPrefs = require('./scopedPrefs')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
|
||||
// Channels one of whose payloads can REFERENCE another's result, earliest first
|
||||
// (Phase 7). Only one pair qualifies today: a push tickle's `ref` deep-links to
|
||||
// the inbox row `inapp` writes, and the outbox is swept `ORDER BY due_at, id`,
|
||||
// so enqueueing in-app first is what makes that ref resolve on the first pass
|
||||
// rather than on a retry. Everything not named here keeps the operator's own
|
||||
// order, which is the order the rules screen shows.
|
||||
//
|
||||
// It is an ordering, not a dependency: `pushChannel` treats a missing ref as
|
||||
// null and the app pulls regardless, so a rule that names only push, or a row
|
||||
// that gets retried out of sequence, is still correct.
|
||||
const CHANNEL_ORDER = ['inapp']
|
||||
const channelRank = (id) => {
|
||||
const i = CHANNEL_ORDER.indexOf(id)
|
||||
return i === -1 ? CHANNEL_ORDER.length : i
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of a rule's channels are actually deliverable right now?
|
||||
*
|
||||
* A rule stores channel ids as data (`channels JSON`), so it can name one whose
|
||||
* module has been removed since. An unregistered channel is dropped rather than
|
||||
* failing the rule: the other channels of that rule are still correct, and a
|
||||
* dropped one is visible in the log line below.
|
||||
*/
|
||||
const liveChannels = (rule) =>
|
||||
(rule.channels || [])
|
||||
.filter((c) => channels.has(c))
|
||||
.sort((a, b) => channelRank(a) - channelRank(b))
|
||||
|
||||
/**
|
||||
* The EFFECTIVE mode each candidate holds for (id, channel), given the event's
|
||||
* scope.
|
||||
*
|
||||
* Effective, not stored: a row exists only where a user has expressed something,
|
||||
* and absence means the channel's `defaultMode` (§3.1). Reading the stored rows
|
||||
* and applying the default here keeps that answer in the registry, which is the
|
||||
* invariant Phase 3 established.
|
||||
*
|
||||
* **A scoped preference wins outright where one exists** (Phase 6, decision 4).
|
||||
* `team_notification_prefs` stayed where it is and `scopedPrefs` is the adapter;
|
||||
* for a Team-scoped event that table is the preference, exactly as it has been
|
||||
* since Teams shipped. The argument for replacing rather than intersecting is in
|
||||
* scopedPrefs.js's header, and it is short: intersecting would have silenced
|
||||
* every existing Team-email subscriber on the deploy that migrated them.
|
||||
*/
|
||||
async function effectiveModes(userIds, streamId, channel, scopeKey) {
|
||||
const modes = new Map()
|
||||
if (!userIds.length) return modes
|
||||
const scoped = await scopedPrefs.resolve(userIds, channel, scopeKey)
|
||||
const stored = await recipients.storedModes(userIds, streamId, channel)
|
||||
const fallback = channels.defaultMode(channel)
|
||||
for (const id of userIds) modes.set(id, scoped.get(id) ?? stored.get(id) ?? fallback)
|
||||
return modes
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidates who should get an OUTBOX ROW for this channel.
|
||||
*
|
||||
* `off` is excluded for the obvious reason. **`digest` is excluded too, and that
|
||||
* corrects what Phase 4a said here** — its comment read "a 'digest' preference is
|
||||
* kept, not dropped… what changes in Phase 6 is who drains it", and what changed
|
||||
* in Phase 6 is that nothing drains it. §4.2b keeps `teamDigestWorker`'s
|
||||
* compute-at-send-time design, so a digest is re-derived from the source tables
|
||||
* when it goes out, not assembled from snapshots taken hours earlier. An outbox
|
||||
* row for a digest recipient would be a second copy of the content with none of
|
||||
* the three properties that design exists for — most importantly, it would mail
|
||||
* a user who lost access between the post and the send.
|
||||
*/
|
||||
async function subscribedTo(userIds, streamId, channel, scopeKey = null) {
|
||||
const modes = await effectiveModes(userIds, streamId, channel, scopeKey)
|
||||
return userIds.filter((id) => modes.get(id) === 'instant')
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one rule against one event. Returns a small summary, for the log line and
|
||||
* for tests; it is not read by the caller for control flow.
|
||||
*/
|
||||
async function applyRule(rule, event, now) {
|
||||
const summary = {
|
||||
ruleId: rule.id,
|
||||
enqueued: 0,
|
||||
deduped: 0,
|
||||
cooled: 0,
|
||||
capped: 0,
|
||||
// Phase 9: people a CHANNEL refused to enqueue for, by reason. Counted
|
||||
// separately from `cooled` and `capped` because those are the engine holding
|
||||
// a message back and this is a channel saying it cannot carry one at all.
|
||||
ineligible: {},
|
||||
skipped: null,
|
||||
}
|
||||
|
||||
if (!conditions.evaluate(rule.conditions, event.data)) {
|
||||
summary.skipped = 'conditions'
|
||||
return summary
|
||||
}
|
||||
|
||||
const resolved = await audiences.resolveForRule(rule, event)
|
||||
if (resolved.dormant) {
|
||||
summary.skipped = resolved.reason || 'dormant'
|
||||
return summary
|
||||
}
|
||||
if (!resolved.userIds.length) {
|
||||
summary.skipped = resolved.reason || 'empty audience'
|
||||
return summary
|
||||
}
|
||||
|
||||
// G24, re-run at send time. A rule saved when its trigger permitted a wider
|
||||
// audience must not keep reaching it after a module upgrade narrowed the
|
||||
// declaration - and that is the only way this can fail, since the save path
|
||||
// ran the same check.
|
||||
if (!audiences.permitted(event.triggerId, resolved.ceiling)) {
|
||||
log.warn('rule audience exceeds its trigger ceiling - refusing', {
|
||||
rule: rule.id,
|
||||
trigger: event.triggerId,
|
||||
audience: resolved.ceiling,
|
||||
})
|
||||
summary.skipped = 'ceiling'
|
||||
return summary
|
||||
}
|
||||
|
||||
const live = liveChannels(rule)
|
||||
if (!live.length) {
|
||||
summary.skipped = 'no registered channel'
|
||||
return summary
|
||||
}
|
||||
|
||||
// The per-rule hourly ceiling (§7.1 Q3). Counted once for the whole event
|
||||
// rather than per channel: an operator setting "100 an hour" means a hundred
|
||||
// messages, not a hundred per channel per event.
|
||||
const sentThisHour = await sendsDb.countSentSince(rule.id, new Date(now.getTime() - HOUR_MS))
|
||||
let budget = Math.max(0, rule.max_sends_per_hour - sentThisHour)
|
||||
if (budget === 0) {
|
||||
log.warn('rule is at its hourly send ceiling', {
|
||||
rule: rule.id,
|
||||
trigger: event.triggerId,
|
||||
ceiling: rule.max_sends_per_hour,
|
||||
})
|
||||
summary.skipped = 'hourly ceiling'
|
||||
return summary
|
||||
}
|
||||
|
||||
const subjectKey = (event.subject ?? '').toString().slice(0, 190)
|
||||
const dueAt = new Date(now.getTime() + Math.max(0, rule.delay_seconds) * 1000)
|
||||
|
||||
for (const channel of live) {
|
||||
// Phase 9, and it runs BEFORE the preference filter rather than after. Both
|
||||
// orders reach the same recipients; this one costs one query against the
|
||||
// narrower set only when the channel declares an `eligible` at all, and it
|
||||
// means `summary.ineligible` counts people the CHANNEL cannot reach rather
|
||||
// than people who happened to also be opted in. Channels that declare none —
|
||||
// push and in-app — pass straight through.
|
||||
const gated = await channels.eligibleFor(channel, resolved.userIds)
|
||||
for (const [why, n] of Object.entries(gated.excluded)) {
|
||||
summary.ineligible[why] = (summary.ineligible[why] || 0) + n
|
||||
}
|
||||
const eligible = await subscribedTo(gated.userIds, event.triggerId, channel, event.scopeKey)
|
||||
for (const userId of eligible) {
|
||||
if (budget <= 0) {
|
||||
summary.capped += 1
|
||||
continue
|
||||
}
|
||||
// Guarded on the interval, so two concurrent emits cannot both pass a
|
||||
// read-then-write check (§4.1).
|
||||
//
|
||||
// **Keyed on the CHANNEL as well**, which is what makes this loop correct
|
||||
// rather than what makes it work. Without the channel, the first channel of
|
||||
// a rule claims the cooldown and every later one is refused as cooling —
|
||||
// and `inapp` is ranked first above, so a rule naming email + in-app would
|
||||
// deliver the in-app item and silently never the mail. Found on Phase 11b's
|
||||
// live rig; a cooldown is per delivery, not per occasion.
|
||||
const allowed = await cooldownsDb.claim(
|
||||
rule.id, userId, subjectKey, channel, rule.cooldown_seconds, now,
|
||||
)
|
||||
if (!allowed) {
|
||||
summary.cooled += 1
|
||||
continue
|
||||
}
|
||||
const id = await outboxDb.enqueue({
|
||||
rule_id: rule.id,
|
||||
trigger_id: event.triggerId,
|
||||
user_id: userId,
|
||||
channel,
|
||||
subject_key: subjectKey,
|
||||
// The scope a PREFERENCE and an UNSUBSCRIBE are keyed on, which is not
|
||||
// `subject_key`: for the Team triggers the subject is `teamName` (what a
|
||||
// cooldown counts) and the scope is `team:12` (what survives a rename).
|
||||
scope_key: event.scopeKey ?? null,
|
||||
payload: event.data,
|
||||
// Scoped per (rule, user, channel) by the unique index, so one event
|
||||
// fanned out to fifty people is fifty rows carrying the same key.
|
||||
dedupe_key: event.dedupeKey,
|
||||
due_at: dueAt,
|
||||
})
|
||||
if (id === null) summary.deduped += 1
|
||||
else {
|
||||
summary.enqueued += 1
|
||||
budget -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending rows that this event resolves (§4.2a).
|
||||
*
|
||||
* This is the actual point of `delay_seconds`: without cancellation a delay is
|
||||
* just a late mail. A house repaired back to LikeNew fires a trigger that some
|
||||
* rule names in its `cancel_on`, and every still-scheduled row for that
|
||||
* (rule, subject) stops.
|
||||
*
|
||||
* When the resolving event names an owner, only that user's rows are cancelled;
|
||||
* when it does not, every user queued about that subject is - which is the
|
||||
* house-repaired case, where the event is about the house and not about any one
|
||||
* of the people who were going to be told.
|
||||
*/
|
||||
async function applyCancellations(event, summary) {
|
||||
const rules = await rulesDb.enabledCancelledBy(event.triggerId)
|
||||
if (!rules.length) return
|
||||
const subjectKey = (event.subject ?? '').toString().slice(0, 190)
|
||||
for (const rule of rules) {
|
||||
const n = await outboxDb.cancel(rule.id, subjectKey, event.ownerUserId || null)
|
||||
if (n) {
|
||||
summary.cancelled += n
|
||||
log.info('cancelled scheduled sends', {
|
||||
rule: rule.id,
|
||||
by: event.triggerId,
|
||||
subject: subjectKey,
|
||||
rows: n,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one validated event. Called by `engagementEmit.emit` after the payload
|
||||
* has been checked against the declaration.
|
||||
*
|
||||
* @param {object} event the envelope `engagementEmit` built
|
||||
* @returns {Promise<{ rules: number, enqueued: number, cancelled: number }>}
|
||||
*/
|
||||
async function dispatch(event, now = new Date()) {
|
||||
const summary = { rules: 0, enqueued: 0, deduped: 0, cooled: 0, capped: 0, cancelled: 0 }
|
||||
try {
|
||||
const rules = await rulesDb.enabledForTrigger(event.triggerId)
|
||||
summary.rules = rules.length
|
||||
|
||||
for (const rule of rules) {
|
||||
const result = await applyRule(rule, event, now)
|
||||
summary.enqueued += result.enqueued
|
||||
summary.deduped += result.deduped
|
||||
summary.cooled += result.cooled
|
||||
summary.capped += result.capped
|
||||
}
|
||||
|
||||
await applyCancellations(event, summary)
|
||||
|
||||
// Keys and counts, never values - the same rule the emit log line follows.
|
||||
// A payload carries player names, house locations and forum excerpts, and a
|
||||
// log that reproduces them is a second copy of exactly the content
|
||||
// `engagement_sends` is careful to keep out of the database.
|
||||
if (summary.rules || summary.cancelled) {
|
||||
log.info('event dispatched', { trigger: event.triggerId, ...summary })
|
||||
}
|
||||
} catch (err) {
|
||||
// A database problem of core's must not become the module's control flow at
|
||||
// three in the morning. The emit already succeeded as a contract; what failed
|
||||
// is delivery, and it is logged as core's failure.
|
||||
log.error('dispatch failed', { trigger: event.triggerId, message: err.message })
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dispatch,
|
||||
applyRule,
|
||||
applyCancellations,
|
||||
subscribedTo,
|
||||
effectiveModes,
|
||||
liveChannels,
|
||||
CHANNEL_ORDER,
|
||||
HOUR_MS,
|
||||
}
|
||||
204
server/src/engagement/inappChannel.js
Normal file
204
server/src/engagement/inappChannel.js
Normal file
@@ -0,0 +1,204 @@
|
||||
// ── The in-app DeliveryChannel: addressFor + deliver ───────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 7. The third channel to get behaviour, and the one whose
|
||||
// "address" is not an address at all: the destination is the user's own row in
|
||||
// this deployment's own table. `addressFor` still exists and still answers null,
|
||||
// because the question it asks — *can this channel reach this user right now* —
|
||||
// has a real answer here, and it is the same answer email's has: not if the
|
||||
// account is no longer active. An outbox row can sit through a `delay_seconds`
|
||||
// grace window, so a user banned between the emit and the send is exactly the
|
||||
// case this catches.
|
||||
//
|
||||
// **What makes it different from email is what it does NOT have to do.** There
|
||||
// is no transport, no relay to classify a failure for us, no unsubscribe link to
|
||||
// mint per recipient, and no address to hash — an inbox item is addressed to a
|
||||
// user id, and `engagement_sends.address_hash` exists to correlate a bounce that
|
||||
// this channel cannot have. So `deliver` is two steps: render the template into
|
||||
// the three columns, and insert.
|
||||
//
|
||||
// **It never throws**, for the reason `emailChannel` states: the worker reads a
|
||||
// throw as a transient failure and retries five times, so an unrenderable
|
||||
// template would become five identical failures in the send log instead of one
|
||||
// honest terminal row.
|
||||
//
|
||||
// **A duplicate `dedupe_key` reports success.** The acceptance line calls it a
|
||||
// no-op; from the recipient's side it is a delivery — they have the item — and
|
||||
// recording `failed` for it would put a red row in the send log for the
|
||||
// mechanism working exactly as designed. The detail says which it was.
|
||||
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const registries = require('../modules/registries')
|
||||
const channelRegistry = require('./channels')
|
||||
const inbox = require('../model/userNotifications/userNotifications.db')
|
||||
const recipients = require('../model/engagement/engagementRecipients.db')
|
||||
const templates = require('./templates')
|
||||
const projection = require('./projection')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
// The template a rule renders through when it names none — §4.6.1 property 1's
|
||||
// implementation for this channel, exactly as `notify.event` is for email.
|
||||
const DEFAULT_TEMPLATE = 'inapp.event'
|
||||
|
||||
/**
|
||||
* Can this channel reach `userId`?
|
||||
*
|
||||
* Returns the shape every `addressFor` returns rather than a boolean, so the
|
||||
* registry's contract stays one contract. The "address" is the user id as a
|
||||
* string, which is the honest answer: this channel's destination is an account,
|
||||
* and there is nothing else to name.
|
||||
*/
|
||||
const addressFor = async (userId) => {
|
||||
const active = await recipients.filterActive([userId])
|
||||
return active.length ? { address: String(active[0]) } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one event into an inbox item. Shared with `ctx.inbox.push`'s rule-less
|
||||
* path only in spirit — that one is handed its title and body by the module and
|
||||
* renders nothing.
|
||||
*/
|
||||
async function renderItem(triggerId, payload, templateKey) {
|
||||
const values = projection.project(triggerId, payload || {})
|
||||
const rendered = await templates.renderInappByKey(templateKey, values)
|
||||
if (!rendered) return null
|
||||
if (rendered.missing.length) {
|
||||
// Names only, never values — the rule every log line in this subsystem
|
||||
// follows. An optional variable a trigger chose not to supply renders as
|
||||
// nothing by design, so this is debug rather than a warning.
|
||||
log.debug('template variables had no value', { key: templateKey, missing: rendered.missing })
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one claimed outbox row.
|
||||
*
|
||||
* @returns {Promise<{ok: boolean, retry?: boolean, detail?: string}>}
|
||||
*/
|
||||
async function deliver(row) {
|
||||
try {
|
||||
if (!(await addressFor(row.user_id))) {
|
||||
// Terminal. A five-minute backoff does not un-ban an account, and writing
|
||||
// the item anyway would put content in the inbox of somebody who is no
|
||||
// longer allowed to open it.
|
||||
return { ok: false, detail: 'this user can no longer be reached' }
|
||||
}
|
||||
|
||||
const rule = await rulesDb.getById(row.rule_id)
|
||||
const key = (rule && rule.template_keys && rule.template_keys.inapp) || DEFAULT_TEMPLATE
|
||||
|
||||
const rendered = await renderItem(row.trigger_id, row.payload, key)
|
||||
if (!rendered) {
|
||||
// Neither a usable row nor a shipped seed: the operator deleted a template
|
||||
// a rule points at, which the admin surface refuses with a 409, so reaching
|
||||
// here means it happened out of band. Terminal, and it names the key.
|
||||
return { ok: false, detail: `no template and no shipped default for "${key}"` }
|
||||
}
|
||||
|
||||
const { inserted } = await inbox.insert({
|
||||
userId: row.user_id,
|
||||
triggerId: row.trigger_id,
|
||||
title: rendered.title,
|
||||
body: rendered.body,
|
||||
url: rendered.url,
|
||||
dedupeKey: row.dedupe_key || null,
|
||||
})
|
||||
|
||||
// `transport` is left absent rather than invented. The column means "which
|
||||
// implementation of this channel delivered it", and this channel has one
|
||||
// sink by construction — a value there would be a name nothing else uses.
|
||||
return inserted
|
||||
? { ok: true }
|
||||
: { ok: true, detail: 'already in this inbox (duplicate dedupe key)' }
|
||||
} catch (err) {
|
||||
log.error('in-app delivery failed', { outbox: row.id, message: err.message })
|
||||
return { ok: false, detail: `delivery error: ${err.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
// ── The rule-less sink: ctx.inbox.push (§5.1) ──────────────────────────────
|
||||
//
|
||||
// A module writing the inbox directly, with no trigger declaration to project
|
||||
// from, no rule to pick a template, and no audience to resolve. It exists for
|
||||
// the cases a rule cannot express — something that concerns exactly one person
|
||||
// and needs no operator configuration to be worth telling them about.
|
||||
//
|
||||
// **It respects the user's in-app preference where there is one to respect**
|
||||
// (settled by the org lead 2026-08-31). If `triggerId` names a REGISTERED
|
||||
// trigger, the user's effective mode for it decides, and 'off' drops the write:
|
||||
// a toggle somebody switched off on the preferences screen must not be walkable
|
||||
// around by the module that owns the trigger behind it. If it names nothing
|
||||
// registered there is no toggle, nothing on any screen to have switched off, and
|
||||
// the item is written — refusing it would make the sink useless for the one job
|
||||
// it has while protecting a preference that does not exist.
|
||||
//
|
||||
// Scoped preferences are deliberately not consulted: a scope is a property of an
|
||||
// EVENT (`team:12`), and a caller with no trigger declaration has no scope to
|
||||
// name. The engine's path, which does, still applies them.
|
||||
//
|
||||
// Fire-and-forget, never throws, never rejects — `ctx.teams.activity.push`'s
|
||||
// posture, for its reason: this is called from inside a game-event handler and a
|
||||
// storage problem of core's must not become the module's control flow.
|
||||
|
||||
// user_notifications.title. Truncated rather than refused: a module that built a
|
||||
// long title has still said something worth showing.
|
||||
const MAX_TITLE = 300
|
||||
// user_notifications.body is TEXT; this is a sanity bound, not the column's.
|
||||
const MAX_BODY = 4000
|
||||
|
||||
/**
|
||||
* Write one item on a module's behalf.
|
||||
*
|
||||
* @param {string} moduleId bound by the loader, never taken from the arguments
|
||||
* @param {number} userId
|
||||
* @param {{triggerId: string, title: string, body?: string, url?: string, dedupeKey?: string}} item
|
||||
* @returns {Promise<{written: boolean, reason?: string}>} for tests; the loader
|
||||
* discards it, because a module has nothing correct to do with it.
|
||||
*/
|
||||
async function pushDirect(moduleId, userId, item = {}) {
|
||||
try {
|
||||
const uid = Number(userId)
|
||||
if (!Number.isInteger(uid) || uid <= 0) return { written: false, reason: 'invalid user id' }
|
||||
|
||||
const triggerId = String(item.triggerId || '').trim()
|
||||
const title = String(item.title || '').trim().slice(0, MAX_TITLE)
|
||||
if (!triggerId || !title) return { written: false, reason: 'triggerId and title are required' }
|
||||
|
||||
// The declaration is consulted for ONE thing — whether a preference for this
|
||||
// id exists — and not to validate a payload: there is no payload here, only
|
||||
// the three strings the module composed itself.
|
||||
if (registries.eventTrigger(triggerId)) {
|
||||
const stored = await recipients.storedModes([uid], triggerId, 'inapp')
|
||||
const mode = stored.get(uid) ?? channelRegistry.defaultMode('inapp')
|
||||
if (mode !== 'instant') return { written: false, reason: 'the user has this switched off' }
|
||||
}
|
||||
|
||||
if (!(await addressFor(uid))) return { written: false, reason: 'this user can no longer be reached' }
|
||||
|
||||
// Same relative-only rule the rendered path applies, and for the same reason:
|
||||
// this string ends up in an href on a page a signed-in user is looking at.
|
||||
const url = item.url ? templates.relativeUrl(item.url, templates.baseUrl()) : null
|
||||
if (item.url && !url) {
|
||||
log.warn('ctx.inbox.push dropped an off-site url', { module: moduleId, trigger: triggerId })
|
||||
}
|
||||
|
||||
const body = item.body ? String(item.body).slice(0, MAX_BODY) : null
|
||||
const { inserted } = await inbox.insert({
|
||||
userId: uid,
|
||||
triggerId,
|
||||
title,
|
||||
// A module supplies data, never markup (§4.6.2's security posture). The
|
||||
// body is stored as the text it claims to be and every surface renders it
|
||||
// as text, so there is no markup to sanitize and none to be trusted.
|
||||
body,
|
||||
url,
|
||||
dedupeKey: item.dedupeKey ? String(item.dedupeKey).slice(0, 190) : null,
|
||||
})
|
||||
return { written: inserted, reason: inserted ? undefined : 'duplicate dedupe key' }
|
||||
} catch (err) {
|
||||
log.error('ctx.inbox.push failed', { module: moduleId, message: err.message })
|
||||
return { written: false, reason: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { addressFor, deliver, renderItem, pushDirect, DEFAULT_TEMPLATE }
|
||||
28
server/src/engagement/index.js
Normal file
28
server/src/engagement/index.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// ── The engagement subsystem — one door ────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phases 1 and 3. Today this is the mail transport registry, core's
|
||||
// own transports, and the delivery-channel registry with core's three channels;
|
||||
// the rules engine and the render/deliver half of a channel arrive in later
|
||||
// phases and hang here too. (The trigger registry lives in `modules/registries.js`
|
||||
// instead, because a trigger is something a MODULE declares and modules only ever
|
||||
// see one registration door.)
|
||||
//
|
||||
// **Core's transports register through the same door a module's would**, and
|
||||
// they register HERE rather than at the bottom of the registry file. That keeps
|
||||
// the registry free of any knowledge of its registrants — the same reason
|
||||
// `registerCore()` is called from app.js rather than from inside
|
||||
// `modules/registries.js` (MODULE_API.md §7.6) — and it means requiring the
|
||||
// registry never has the side effect of populating it.
|
||||
//
|
||||
// Requiring this module is what makes `smtp` and the three channels available.
|
||||
// Everything that resolves either goes through here, so there is exactly one
|
||||
// place a transport or a channel can come into existence.
|
||||
|
||||
require('./transports/smtp')
|
||||
require('./coreChannels')
|
||||
require('./coreScopePrefs')
|
||||
|
||||
const transports = require('./transports')
|
||||
const channels = require('./channels')
|
||||
|
||||
module.exports = { transports, channels }
|
||||
179
server/src/engagement/moduleSeeds.js
Normal file
179
server/src/engagement/moduleSeeds.js
Normal file
@@ -0,0 +1,179 @@
|
||||
// ── Seeding what a module ships (ENGAGEMENT.md Phase 11b, decision 7) ──────
|
||||
//
|
||||
// Core's own bodies and rules are seeded from `seedDefaults()`, and a module's
|
||||
// cannot be: `server.js` calls `seedDefaults()` BEFORE it requires `app.js`, and
|
||||
// requiring `app.js` is what scans the volume and runs the loader. At the moment
|
||||
// core seeds, no module has registered anything at all.
|
||||
//
|
||||
// So this runs from `modules/lifecycle.js` `boot()` instead — after the
|
||||
// `installed_modules` reconcile, so a module the operator disabled or one that
|
||||
// failed to load is skipped rather than seeded, and BEFORE the `onBoot`
|
||||
// dispatch, so a module that warms a cache in `onBoot` may assume its rules
|
||||
// exist.
|
||||
//
|
||||
// **It reuses core's two seeders rather than reimplementing them**, which is the
|
||||
// whole argument for the registry existing (decision 7): `seedOne` owns the
|
||||
// `customized` skip and the `seed_version` comparison, `validateEmailBlocks`
|
||||
// owns what a renderable body is, and a module supplies data. A copy of either
|
||||
// living outside this directory would drift the first time core improved the
|
||||
// original — and the drift would surface as a mail somebody already received.
|
||||
//
|
||||
// ── The asymmetry, once more, because it is the thing to get right ─────────
|
||||
//
|
||||
// **Templates are re-ensured every boot.** A row carries `seed_key`,
|
||||
// `seed_version` and `customized`, so re-ensuring is how a better default
|
||||
// reaches a deployment without stealing an operator's edit (§4.6.1 property 3),
|
||||
// and a template added in a later module version reaches every deployment rather
|
||||
// than only fresh ones.
|
||||
//
|
||||
// **Rule groups are one-shot, each under its own settings guard.** Re-ensuring a
|
||||
// rule would resurrect one an operator deleted and reset one they enabled. This
|
||||
// is 11a's seed-key finding as a mechanism: a rule appended to an existing group
|
||||
// reaches fresh installs only, and a rule that must reach already-stamped
|
||||
// deployments takes a new group key. The module chooses; this file honours it.
|
||||
//
|
||||
// **Never throws.** It is on the boot path beside every other `safe()`-wrapped
|
||||
// step in `lifecycle.boot()`, and a body that would not seed costs the shipped
|
||||
// default — `renderByKey`'s fallback stays in charge — not the deployment.
|
||||
|
||||
const templatesDb = require('../model/engagement/engagementTemplates.db')
|
||||
const rulesDb = require('../model/engagement/engagementRules.db')
|
||||
const settingsDb = require('../model/settings/settings.db')
|
||||
const emailBlocks = require('../emailBlocks')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
/**
|
||||
* The one-shot guard for one module's rule group.
|
||||
*
|
||||
* Namespaced by owner AND by group so two modules may use the same group name,
|
||||
* and so a module can add a second group later without touching the first. Its
|
||||
* VALUE is the timestamp — purely so an operator reading the settings table can
|
||||
* tell when it ran; only its presence is read.
|
||||
*/
|
||||
const guardKey = (owner, group) => `engagement_module_rules_seeded:${owner}:${group}`
|
||||
|
||||
/**
|
||||
* Ensure one module's templates, and bring un-customized rows up to the current
|
||||
* seed. Idempotent.
|
||||
*/
|
||||
async function seedModuleTemplates(owner, templates, deps = {}) {
|
||||
const templates_ = deps.templatesDb || templatesDb
|
||||
const counts = { inserted: 0, updated: 0, skipped: 0, invalid: 0 }
|
||||
for (const seed of templates) {
|
||||
// Validated against the block registry before it is stored, exactly as core's
|
||||
// own seeds are and for the same reason: a shipped block array no renderer
|
||||
// understands sitting in the table reads to an operator as their deployment
|
||||
// being broken. Refusing to write it leaves the fallback in charge and puts
|
||||
// the reason in the boot log, with the module named.
|
||||
const { valid, errors } = emailBlocks.validateEmailBlocks(seed.blocks)
|
||||
if (!valid) {
|
||||
log.error('a module template is invalid and was not seeded', { owner, key: seed.key, errors })
|
||||
counts.invalid += 1
|
||||
continue
|
||||
}
|
||||
try {
|
||||
counts[await templates_.seedOne(seed)] += 1
|
||||
} catch (err) {
|
||||
log.error('module template seed failed', { owner, key: seed.key, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// The third arm of §4.6.1 property 3: a customized row is never touched, and
|
||||
// the fact that a better default now exists is surfaced instead of applied.
|
||||
let stale = []
|
||||
try {
|
||||
stale = await templates_.staleCustomized(
|
||||
templates.map((t) => ({ key: t.key, seedVersion: t.seedVersion })),
|
||||
)
|
||||
} catch {
|
||||
stale = []
|
||||
}
|
||||
if (stale.length) {
|
||||
log.info('customized module templates have a newer shipped default', {
|
||||
owner,
|
||||
keys: stale.map((t) => t.key),
|
||||
})
|
||||
}
|
||||
return { ...counts, stale: stale.map((t) => t.key) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed one named rule group, once, under its own guard.
|
||||
*
|
||||
* Mirrors `coreRules.seedGroup` deliberately, including the stamp-on-partial
|
||||
* behaviour: re-running would duplicate the rules that DID insert, and a
|
||||
* duplicate rule is two mails per event — worse than the one missing rule an
|
||||
* operator can add from the Rules screen.
|
||||
*/
|
||||
async function seedRuleGroup(owner, group, deps = {}) {
|
||||
const rules_ = deps.rulesDb || rulesDb
|
||||
const settings_ = deps.settingsDb || settingsDb
|
||||
const summary = { inserted: 0, skipped: 0 }
|
||||
const key = guardKey(owner, group.key)
|
||||
try {
|
||||
const seen = await settings_.get(key)
|
||||
if (seen) return { ...summary, skipped: group.rules.length }
|
||||
|
||||
for (const rule of group.rules) {
|
||||
try {
|
||||
await rules_.insert(rule)
|
||||
summary.inserted += 1
|
||||
} catch (err) {
|
||||
log.error('module rule seed failed', {
|
||||
owner,
|
||||
group: group.key,
|
||||
trigger: rule.trigger_id,
|
||||
message: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
await settings_.set(key, new Date().toISOString())
|
||||
if (summary.inserted) {
|
||||
log.info('seeded module engagement rules, all disabled', {
|
||||
owner,
|
||||
group: group.key,
|
||||
rules: summary.inserted,
|
||||
note: group.note || undefined,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('module rule group seeding failed', { owner, group: group.key, message: err.message })
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed every registered module's engagement content.
|
||||
*
|
||||
* @param {object} [deps]
|
||||
* @param {Function} [deps.seeds] () => [{ owner, templates, ruleGroups }]
|
||||
* @param {Set} [deps.skip] owners not to seed (disabled or failed)
|
||||
* @param {object} [deps.templatesDb] / [deps.rulesDb] / [deps.settingsDb] — test seams
|
||||
*/
|
||||
async function seedModuleEngagement({ seeds, skip = new Set(), ...dbs } = {}) {
|
||||
// eslint-disable-next-line global-require
|
||||
const read = seeds || require('../modules/registries').allEngagementSeeds
|
||||
const totals = { templates: 0, rules: 0 }
|
||||
|
||||
for (const entry of read()) {
|
||||
if (skip.has(entry.owner)) {
|
||||
log.info('skipping engagement seeds for a module that is not booting', { owner: entry.owner })
|
||||
continue
|
||||
}
|
||||
const t = await seedModuleTemplates(entry.owner, entry.templates || [], dbs)
|
||||
totals.templates += t.inserted + t.updated
|
||||
for (const group of entry.ruleGroups || []) {
|
||||
const r = await seedRuleGroup(entry.owner, group, dbs)
|
||||
totals.rules += r.inserted
|
||||
}
|
||||
log.info('module engagement seeds ensured', { owner: entry.owner, ...t })
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
seedModuleEngagement,
|
||||
seedModuleTemplates,
|
||||
seedRuleGroup,
|
||||
guardKey,
|
||||
}
|
||||
73
server/src/engagement/projection.js
Normal file
73
server/src/engagement/projection.js
Normal file
@@ -0,0 +1,73 @@
|
||||
// ── The structural projection: any trigger through a generic template ──────
|
||||
//
|
||||
// ENGAGEMENT.md §4.6.1 property 1, implemented in Phase 6. The property is that
|
||||
// **a new trigger renders through `notify.event` with no authoring at all** —
|
||||
// "add a trigger" must not mean "and now write a template". Nothing implemented
|
||||
// it before this phase, and building the email channel is what made the hole
|
||||
// visible: a trigger payload is domain-named (`teamName`, `threadTitle`,
|
||||
// `postUrl`) while the generic seeds are structural (`title`, `intro`, `items`,
|
||||
// `actionUrl`). The two vocabularies never met.
|
||||
//
|
||||
// **The rule, settled by the org lead 2026-08-29: the payload wins, and the
|
||||
// projection fills gaps.** A name the payload already carries is left exactly as
|
||||
// emitted — `news.post` and `team.announcement` both declare their own `title`,
|
||||
// and a projection that overwrote it would replace a real headline with a
|
||||
// category label. Only a name the payload does NOT define is supplied here.
|
||||
//
|
||||
// **What it is careful not to do is guess at domain meaning.** There is no table
|
||||
// mapping `threadTitle` onto `title`, and there will not be one: every such
|
||||
// mapping is a piece of one game's vocabulary compiled into core, and it is wrong
|
||||
// the first time a module names the same thing differently. The three fallbacks
|
||||
// below are all derived from the DECLARATION — a trigger's own label, its own
|
||||
// description, its own first declared url — which every trigger has by
|
||||
// construction because `registerEventTriggers` refuses one without them.
|
||||
//
|
||||
// The consequence, stated plainly: an unauthored mail for `team.forum.post` is
|
||||
// titled "Team — new forum post" rather than the thread's title. That is a plain
|
||||
// mail, not a wrong one, and the operator's answer is the bespoke template that
|
||||
// ships beside it (`notify.team-post` reads the payload's own names). A projection
|
||||
// clever enough to do better would be a projection that is confidently wrong on
|
||||
// the first module that does not follow core's naming.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
|
||||
/**
|
||||
* The values a template renders with, for one event and one recipient.
|
||||
*
|
||||
* @param {string} triggerId
|
||||
* @param {Record<string, unknown>} payload the outbox row's snapshot — already
|
||||
* validated at emit, so it holds declared variables and nothing else
|
||||
* @param {Record<string, unknown>} [extra] per-recipient additions the channel
|
||||
* computes (`unsubscribeUrl`), merged LAST because they are facts about
|
||||
* the delivery rather than about the event
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
function project(triggerId, payload = {}, extra = {}) {
|
||||
const declaration = registries.eventTrigger(triggerId)
|
||||
const values = { ...payload }
|
||||
|
||||
// A dormant trigger still has an outbox row to deliver — the module was
|
||||
// uninstalled between enqueue and now. The payload is intact and the template
|
||||
// may well only reference payload names, so the mail goes out with whatever the
|
||||
// snapshot holds rather than being refused for want of a label.
|
||||
if (declaration) {
|
||||
if (values.title === undefined) values.title = declaration.label
|
||||
if (values.intro === undefined) values.intro = declaration.description || ''
|
||||
if (values.actionUrl === undefined) {
|
||||
const url = (declaration.variables || []).find(
|
||||
(v) => v.type === 'url' && typeof payload[v.name] === 'string' && payload[v.name],
|
||||
)
|
||||
if (url) values.actionUrl = payload[url.name]
|
||||
}
|
||||
}
|
||||
|
||||
// Set rather than left absent, so a generic template's item list renders as
|
||||
// nothing instead of reporting `items` as a missing variable. `missing` is what
|
||||
// the editor's preview shows an operator, and a name no trigger was ever going
|
||||
// to supply is noise in it.
|
||||
if (values.items === undefined) values.items = []
|
||||
|
||||
return { ...values, ...extra }
|
||||
}
|
||||
|
||||
module.exports = { project }
|
||||
91
server/src/engagement/pushChannel.js
Normal file
91
server/src/engagement/pushChannel.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// ── The push DeliveryChannel: addressFor + deliver ─────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 7. Push is the channel that has existed longest and had a
|
||||
// `deliver` last, because until this phase there was nothing for a tickle to
|
||||
// point AT: `{ stream, ref }` carries no content by design, so a rule firing on
|
||||
// push before the inbox existed would have woken a phone to pull a screen that
|
||||
// had nothing on it.
|
||||
//
|
||||
// **The tickle invariant is the whole of this file's security posture.** What
|
||||
// leaves the server is the stream id and an opaque ref, never a title, never a
|
||||
// body, never the payload — `carriesContent: false` on the registration is the
|
||||
// declaration and this is the implementation. ntfy is treated as an untrusted
|
||||
// relay, so a leaked topic must reveal nothing but that *something* happened;
|
||||
// the app then pulls the real item over the authenticated, ownership-checked
|
||||
// inbox API. Every claim in that paragraph is one `pushDispatch` already makes,
|
||||
// which is why delivery here is a call into it rather than a second publisher.
|
||||
//
|
||||
// **`ref` points at the inbox row when there is one, and is null otherwise.**
|
||||
// A rule spanning `inapp` and `push` enqueues both, and `liveChannels` orders
|
||||
// `inapp` first precisely so the row exists by the time this runs — but that is
|
||||
// an optimisation, not a guarantee: the two rows are independent, either can be
|
||||
// retried, and a push-only rule has no inbox row at all. So the ref is a HINT.
|
||||
// The app's contract (docs/android/PLAN.md §11, Phase 8) is wake-and-pull; a
|
||||
// client that renders the ref instead of pulling is a client that will show
|
||||
// nothing the first time a retry reorders these two rows.
|
||||
|
||||
const inbox = require('../model/userNotifications/userNotifications.db')
|
||||
const recipients = require('../model/engagement/engagementRecipients.db')
|
||||
const pushDispatch = require('../utils/pushDispatch')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
/**
|
||||
* Can this channel reach `userId`?
|
||||
*
|
||||
* Active account only, the same re-check `emailChannel` and `inappChannel` make
|
||||
* for the same reason (a row can sit through a `delay_seconds` window). It does
|
||||
* NOT check for a registered device: whether any endpoint is subscribed is the
|
||||
* question `publishToUsers` answers in its own query, and asking it twice would
|
||||
* mean two different definitions of "reachable" that could disagree.
|
||||
*/
|
||||
const addressFor = async (userId) => {
|
||||
const active = await recipients.filterActive([userId])
|
||||
return active.length ? { address: String(active[0]) } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one claimed outbox row.
|
||||
*
|
||||
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
|
||||
*/
|
||||
async function deliver(row) {
|
||||
try {
|
||||
if (!(await addressFor(row.user_id))) {
|
||||
return { ok: false, detail: 'this user can no longer be reached' }
|
||||
}
|
||||
|
||||
// Best effort, and it fails to null rather than to an error: no dedupe key,
|
||||
// no in-app row for it, or an inapp row this rule never enqueued all mean
|
||||
// the same thing to the app — wake up and pull.
|
||||
let ref = null
|
||||
try {
|
||||
const item = await inbox.findByDedupe(row.user_id, row.dedupe_key)
|
||||
if (item) ref = `notification:${item.id}`
|
||||
} catch (err) {
|
||||
log.debug('could not resolve a push ref', { outbox: row.id, message: err.message })
|
||||
}
|
||||
|
||||
// The stream id IS the trigger id — §7.2's one namespace, settled in Phase 2.
|
||||
// A push stream and an event trigger share a name space, so the app's
|
||||
// existing `{ stream }` switch keeps working for an engagement rule without
|
||||
// learning a second vocabulary.
|
||||
await pushDispatch.publishToUsers(row.trigger_id, { ref, userIds: [row.user_id] })
|
||||
|
||||
// **Success here means "handed to the relay", and the send log must not
|
||||
// claim more than that.** `publishToUsers` resolves whether it found a
|
||||
// subscribed device or none at all, and a tickle is fire-and-forget over
|
||||
// HTTP to a relay that owes us no receipt. Retrying on "we are not sure"
|
||||
// would mean five wakeups for one event, which is worse than one uncertain
|
||||
// log line — so this is the one channel whose 'sent' is weaker than email's,
|
||||
// and saying so in the detail is how an operator reading G15 finds that out.
|
||||
return { ok: true, transport: 'unifiedpush', detail: 'tickle published' }
|
||||
} catch (err) {
|
||||
// pushDispatch never throws, so reaching here is a programming error rather
|
||||
// than a relay being down. Terminal for that reason: retrying a bug is five
|
||||
// identical rows in the send log.
|
||||
log.error('push delivery failed', { outbox: row.id, message: err.message })
|
||||
return { ok: false, detail: `delivery error: ${err.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { addressFor, deliver }
|
||||
122
server/src/engagement/scopedPrefs.js
Normal file
122
server/src/engagement/scopedPrefs.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// ── Scoped preferences: "this channel, for this one Team" ──────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 6, decision 4. `notification_channel_prefs` is keyed
|
||||
// (user, stream, channel) and has no scope column; `team_notification_prefs` is
|
||||
// keyed (user, Team) and is the preference people actually hold today — someone
|
||||
// in six Teams silences one. Migrating the second into the first would mean a
|
||||
// live migration of user data, a wire-shape change on two clients, and the loss
|
||||
// of the granularity in between. The org lead's decision was to keep the Team
|
||||
// table and have the engine consult it; this file is the seam that lets it,
|
||||
// without core's engine learning what a Team is.
|
||||
//
|
||||
// A registrant claims a scope PREFIX — the part of a scope key before the colon,
|
||||
// `team` in `team:12` — and answers, for a set of users and one channel, what
|
||||
// that scope says their mode is.
|
||||
//
|
||||
// **Where a scope answers, its answer REPLACES the stream-level preference; it
|
||||
// does not intersect with it.** The decision was phrased as "a suppression below
|
||||
// the channel preference", and building it showed that reading is the one that
|
||||
// cannot ship: `notification_channel_prefs` holds a row only where a user has
|
||||
// expressed something, absence means the channel's `defaultMode`, and email's is
|
||||
// `off`. Nobody has ever expressed a stream-level opinion about `team.forum.post`
|
||||
// — the screen that would let them is Phase 3's and the preference predates it —
|
||||
// so intersecting would resolve every existing Team-email subscriber to `off` and
|
||||
// silence the entire live pipeline on the deploy that migrated it. That is the
|
||||
// G22 failure mode with a different cause. Replacement keeps today's behaviour
|
||||
// byte-for-byte: for a Team-scoped event, `team_notification_prefs` is the
|
||||
// preference, exactly as it has been since Teams shipped.
|
||||
//
|
||||
// The cost, stated so nobody has to rediscover it: a user cannot turn Team email
|
||||
// off for every Team at once from the channels screen. That control lives on the
|
||||
// per-Team screen, which is where it has always lived and where the unsubscribe
|
||||
// link points.
|
||||
//
|
||||
// Nothing here caches. A preference read is one indexed query per (event,
|
||||
// channel), against a table the user can change between two events.
|
||||
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
// prefix → provider
|
||||
const providers = new Map()
|
||||
|
||||
const PREFIX_RE = /^[a-z][a-z0-9_-]*$/
|
||||
|
||||
/**
|
||||
* Parse a scope key into its prefix and id. `''` and anything malformed are
|
||||
* `null` — an unparseable scope must read as "no scope", never as some other
|
||||
* scope's.
|
||||
*
|
||||
* @returns {{ prefix: string, id: string }|null}
|
||||
*/
|
||||
function parse(scopeKey) {
|
||||
const raw = String(scopeKey || '')
|
||||
const at = raw.indexOf(':')
|
||||
if (at < 1 || at === raw.length - 1) return null
|
||||
const prefix = raw.slice(0, at)
|
||||
if (!PREFIX_RE.test(prefix)) return null
|
||||
return { prefix, id: raw.slice(at + 1) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a scope-preference provider.
|
||||
*
|
||||
* Validate-then-commit, the same discipline the transport and channel registries
|
||||
* use: every check runs before the map is touched.
|
||||
*
|
||||
* @param {object} def
|
||||
* @param {string} def.prefix the scope-key prefix this provider owns, e.g. 'team'
|
||||
* @param {string} def.label operator-facing, for the send log and admin copy
|
||||
* @param {(userIds: number[], channel: string, scopeId: string) => Promise<Map<number, string>>} def.modesFor
|
||||
* A mode per user for the users this scope has an opinion about. A user
|
||||
* left OUT of the map defers to the stream-level preference; a user in it
|
||||
* is answered by the scope. Must not throw — see `resolve`.
|
||||
*/
|
||||
function registerScopePreference(def) {
|
||||
if (!def || typeof def !== 'object') throw new Error('registerScopePreference: definition required')
|
||||
const { prefix, label, modesFor } = def
|
||||
if (typeof prefix !== 'string' || !PREFIX_RE.test(prefix)) {
|
||||
throw new Error(`registerScopePreference: invalid prefix ${JSON.stringify(prefix)}`)
|
||||
}
|
||||
if (providers.has(prefix)) throw new Error(`registerScopePreference: ${prefix} is already registered`)
|
||||
if (typeof label !== 'string' || !label) throw new Error(`registerScopePreference(${prefix}): label required`)
|
||||
if (typeof modesFor !== 'function') throw new Error(`registerScopePreference(${prefix}): modesFor required`)
|
||||
providers.set(prefix, { prefix, label, modesFor })
|
||||
return prefix
|
||||
}
|
||||
|
||||
/**
|
||||
* What does this scope say about these users on this channel?
|
||||
*
|
||||
* @returns {Promise<Map<number, string>>} empty when the scope is absent,
|
||||
* unparseable, or owned by nobody — all three of which mean "this event
|
||||
* is not scoped as far as preferences are concerned", which is the right
|
||||
* answer for a module whose scope provider has been uninstalled.
|
||||
*/
|
||||
async function resolve(userIds, channel, scopeKey) {
|
||||
const parsed = parse(scopeKey)
|
||||
if (!parsed || !userIds.length) return new Map()
|
||||
const provider = providers.get(parsed.prefix)
|
||||
if (!provider) return new Map()
|
||||
try {
|
||||
const modes = await provider.modesFor(userIds, channel, parsed.id)
|
||||
return modes instanceof Map ? modes : new Map()
|
||||
} catch (err) {
|
||||
// **Fails OPEN, and that is the uncomfortable choice made deliberately.** A
|
||||
// provider that throws leaves the stream-level preference in charge, which
|
||||
// for every core channel is `off` — so the practical effect of a failure is
|
||||
// that nothing is sent, not that everybody is mailed. Failing closed by
|
||||
// refusing the whole event would instead drop an IDOC warning because a Team
|
||||
// preference query timed out.
|
||||
log.error('scope preference lookup failed', { scope: scopeKey, channel, message: err.message })
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
const has = (prefix) => providers.has(prefix)
|
||||
|
||||
// Test-only: the registry is module-level state.
|
||||
function _reset() {
|
||||
providers.clear()
|
||||
}
|
||||
|
||||
module.exports = { registerScopePreference, resolve, parse, has, _reset }
|
||||
258
server/src/engagement/segments.js
Normal file
258
server/src/engagement/segments.js
Normal file
@@ -0,0 +1,258 @@
|
||||
// ── Audience segments — operator composition over module-declared audiences ──
|
||||
//
|
||||
// ENGAGEMENT.md §5.1a, Phase 4a. A module declares named audiences over its own
|
||||
// data ("members of a Team", "the governors"); an operator combines them with
|
||||
// and/or/not into a saved segment; a rule points at the segment. This file is the
|
||||
// two halves of that: derive the segment's ceiling at save time, and resolve the
|
||||
// expression to user ids at send time.
|
||||
//
|
||||
// **Composition must NARROW, never widen** (§5.1a rule 3), and that is the whole
|
||||
// security content of this file. `A OR B` takes the TIGHTER of the two ceilings,
|
||||
// not the looser - a ceiling states what an expression is *allowed* to reach, not
|
||||
// what it will resolve to, so the direction of the boolean operator is
|
||||
// irrelevant. Union-widens is the intuitive implementation and it is the wrong
|
||||
// one; `ceilings.meetAll` is the arithmetic, settled in Phase 2, and this is its
|
||||
// first consumer.
|
||||
//
|
||||
// The second rule that shows up in both halves is **dormancy** (§5.1a rule 4).
|
||||
// An audience whose module has been uninstalled resolves to the EMPTY set and
|
||||
// flags itself, never to an error and never to some other set of people. A
|
||||
// segment containing one is dormant, and a rule using a dormant segment does not
|
||||
// send. Resolving the rest of the tree instead would mail a DIFFERENT population
|
||||
// than the one the operator composed.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const ceilings = require('../modules/ceilings')
|
||||
|
||||
const BOOLEAN_OPS = ['and', 'or', 'not']
|
||||
// Same bounds and the same reason as conditions.js: this tree comes out of a JSON
|
||||
// column an admin can write, and it is walked on the emit path.
|
||||
const MAX_DEPTH = 5
|
||||
const MAX_NODES = 50
|
||||
|
||||
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
const isNot = (node) => isPlainObject(node) && node.op === 'not'
|
||||
|
||||
/** Check one audience's declared params against what the operator supplied. */
|
||||
function checkParams(declaration, raw, path, errors) {
|
||||
const params = {}
|
||||
const supplied = isPlainObject(raw) ? raw : {}
|
||||
for (const p of declaration.params || []) {
|
||||
const value = supplied[p.id]
|
||||
if (value === undefined || value === null || value === '') {
|
||||
if (p.required) errors.push(`${path}: "${p.id}" is required`)
|
||||
continue
|
||||
}
|
||||
if (p.type === 'int') {
|
||||
const n = Number(value)
|
||||
if (!Number.isInteger(n)) {
|
||||
errors.push(`${path}: "${p.id}" expected an integer`)
|
||||
continue
|
||||
}
|
||||
params[p.id] = n
|
||||
} else if (p.type === 'boolean') {
|
||||
if (typeof value !== 'boolean') {
|
||||
errors.push(`${path}: "${p.id}" expected a boolean`)
|
||||
continue
|
||||
}
|
||||
params[p.id] = value
|
||||
} else {
|
||||
if (typeof value !== 'string') {
|
||||
errors.push(`${path}: "${p.id}" expected a string`)
|
||||
continue
|
||||
}
|
||||
params[p.id] = value
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an expression and derive its ceiling in one walk.
|
||||
*
|
||||
* Returns `{ ok: true, expression, ceiling }` with a normalised tree, or
|
||||
* `{ ok: false, errors }`.
|
||||
*
|
||||
* **`not` is legal only as a child of `and`**, and that restriction is what makes
|
||||
* a complement mean something. A complement needs a universe, and the only
|
||||
* universe available here that does not widen is the set its siblings already
|
||||
* produced: `A AND NOT B` is "A, less B", which is exactly what an operator
|
||||
* wants and cannot be composed into a broadcast. A bare `NOT B`, or `A OR NOT B`,
|
||||
* would have to mean "everyone except..." - a way to build the whole deployment
|
||||
* out of one narrow audience, which is the widening rule 3 forbids. Refusing it
|
||||
* at save is better than a semantics nobody can predict from the screen.
|
||||
*
|
||||
* Two failure modes, and they are different:
|
||||
*
|
||||
* - a leaf naming an audience nobody registers is refused AT SAVE, because an
|
||||
* operator composing a segment out of a typo should hear about it now rather
|
||||
* than discovering a permanently-empty rule later. (A segment that was VALID
|
||||
* when saved and whose module has since gone is a different case - that is
|
||||
* dormancy, handled in `resolve`, and it is not refused.)
|
||||
* - two incomparable ceilings have NO meet, so the composition is refused rather
|
||||
* than resolved to a guess. `staff AND owner` is not `owner`; it is a question
|
||||
* the lattice declines to answer, and picking a side would be a widening.
|
||||
*/
|
||||
function validate(raw) {
|
||||
const errors = []
|
||||
let nodes = 0
|
||||
|
||||
// `underAnd` is the only context in which a `not` is legal.
|
||||
function walk(node, depth, path, underAnd) {
|
||||
if (++nodes > MAX_NODES) {
|
||||
errors.push(`${path}: expression has more than ${MAX_NODES} nodes`)
|
||||
return null
|
||||
}
|
||||
if (depth > MAX_DEPTH) {
|
||||
errors.push(`${path}: nested deeper than ${MAX_DEPTH}`)
|
||||
return null
|
||||
}
|
||||
if (!isPlainObject(node)) {
|
||||
errors.push(`${path}: expected an object`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (node.op === 'not') {
|
||||
if (!underAnd) {
|
||||
errors.push(`${path}: "not" is only allowed inside an "and" - a complement needs a set to take it from`)
|
||||
return null
|
||||
}
|
||||
const children = Array.isArray(node.nodes) ? node.nodes : []
|
||||
if (children.length !== 1) {
|
||||
errors.push(`${path}: "not" takes exactly one node`)
|
||||
return null
|
||||
}
|
||||
const inner = walk(children[0], depth + 1, `${path}.nodes[0]`, false)
|
||||
if (!inner) return null
|
||||
// A `not` contributes NO ceiling. Excluding people cannot widen who the
|
||||
// expression reaches, so folding the excluded audience's ceiling into the
|
||||
// meet would refuse perfectly safe segments: `members AND NOT staff` would
|
||||
// hit meet('members','staff') = null and be rejected, even though it
|
||||
// reaches strictly fewer people than `members` alone.
|
||||
return { node: { op: 'not', nodes: [inner.node] }, ceiling: null, complement: true }
|
||||
}
|
||||
|
||||
if (node.op === 'and' || node.op === 'or') {
|
||||
const children = Array.isArray(node.nodes) ? node.nodes : []
|
||||
if (!children.length) {
|
||||
errors.push(`${path}: "${node.op}" has no nodes`)
|
||||
return null
|
||||
}
|
||||
const walked = children.map((c, i) => walk(c, depth + 1, `${path}.nodes[${i}]`, node.op === 'and'))
|
||||
if (walked.some((w) => w === null)) return null
|
||||
const positives = walked.filter((w) => !w.complement)
|
||||
if (!positives.length) {
|
||||
errors.push(`${path}: "${node.op}" has nothing but complements - there is no set to exclude from`)
|
||||
return null
|
||||
}
|
||||
return {
|
||||
node: { op: node.op, nodes: walked.map((w) => w.node) },
|
||||
ceiling: ceilings.meetAll(positives.map((w) => w.ceiling)),
|
||||
}
|
||||
}
|
||||
|
||||
if (node.op !== undefined) {
|
||||
errors.push(`${path}: unknown operator "${node.op}"`)
|
||||
return null
|
||||
}
|
||||
|
||||
const declaration = registries.audience(node.audienceId)
|
||||
if (!declaration) {
|
||||
errors.push(`${path}: no audience "${node.audienceId}" is registered`)
|
||||
return null
|
||||
}
|
||||
const params = checkParams(declaration, node.params, path, errors)
|
||||
return { node: { audienceId: declaration.id, params }, ceiling: declaration.ceiling }
|
||||
}
|
||||
|
||||
if (!isPlainObject(raw)) return { ok: false, errors: ['expression: expected an object'] }
|
||||
const walked = walk(raw, 0, 'expression', false)
|
||||
if (errors.length || !walked) return { ok: false, errors: errors.length ? errors : ['expression: invalid'] }
|
||||
if (!walked.ceiling) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
'expression: the audiences combined here have no common ceiling, so there is no bound this segment could be given',
|
||||
],
|
||||
}
|
||||
}
|
||||
return { ok: true, expression: walked.node, ceiling: walked.ceiling }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a validated expression to a set of user ids.
|
||||
*
|
||||
* Returns `{ dormant, userIds }`. `dormant` is true the moment ANY leaf names an
|
||||
* audience that is no longer registered, and when it is true the caller must not
|
||||
* send: `userIds` is empty, because the tree it would have come from is not the
|
||||
* tree the operator composed.
|
||||
*
|
||||
* `and` is the intersection of its positive children, less the union of its
|
||||
* complements. `or` is the union of its children, which are all positive because
|
||||
* `validate` refused any other shape.
|
||||
*/
|
||||
async function resolve(expression) {
|
||||
let dormant = false
|
||||
|
||||
async function walk(node) {
|
||||
if (!isPlainObject(node)) return new Set()
|
||||
|
||||
if (node.op === 'and' || node.op === 'or') {
|
||||
const children = Array.isArray(node.nodes) ? node.nodes : []
|
||||
const positives = children.filter((c) => !isNot(c))
|
||||
const complements = children.filter(isNot)
|
||||
|
||||
let out = new Set()
|
||||
for (let i = 0; i < positives.length; i += 1) {
|
||||
const set = await walk(positives[i])
|
||||
if (i === 0) out = set
|
||||
else if (node.op === 'and') out = new Set([...out].filter((id) => set.has(id)))
|
||||
else for (const id of set) out.add(id)
|
||||
}
|
||||
for (const c of complements) {
|
||||
const excluded = await walk((c.nodes || [])[0])
|
||||
out = new Set([...out].filter((id) => !excluded.has(id)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A `not` reached directly (never produced by validate, but a stored row
|
||||
// predates nothing and this must not throw): no universe, so no members.
|
||||
if (node.op !== undefined) return new Set()
|
||||
|
||||
const { dormant: gone, userIds } = await registries.resolveAudience(node.audienceId, node.params || {})
|
||||
if (gone) dormant = true
|
||||
return new Set(userIds)
|
||||
}
|
||||
|
||||
const set = await walk(expression)
|
||||
return { dormant, userIds: dormant ? [] : [...set] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Which audience ids in this expression nobody registers right now?
|
||||
*
|
||||
* The static half of the dormancy answer `resolve` gives at send time, and it
|
||||
* lives here so the two cannot disagree. Two callers need it and neither may
|
||||
* require the other: the segment list annotates itself with it, and the RULE
|
||||
* list needs it to say that a rule pointing at a dormant segment is itself
|
||||
* dormant — which is §5.1a rule 4, and which the first version of the rule
|
||||
* annotation missed by asking only whether the segment ROW still existed.
|
||||
*
|
||||
* The difference is the whole point. A deleted segment and a segment whose
|
||||
* module is gone both leave the rule reaching nobody; only one of them leaves a
|
||||
* row behind. A screen that reports the first and not the second shows an
|
||||
* enabled, healthy-looking rule that cannot fire.
|
||||
*/
|
||||
function missingAudiences(expression) {
|
||||
const missing = []
|
||||
const walk = (node) => {
|
||||
if (!node || typeof node !== 'object') return
|
||||
if (node.op) (node.nodes || []).forEach(walk)
|
||||
else if (!registries.audience(node.audienceId)) missing.push(node.audienceId)
|
||||
}
|
||||
walk(expression)
|
||||
return [...new Set(missing)]
|
||||
}
|
||||
|
||||
module.exports = { validate, resolve, missingAudiences, MAX_DEPTH, MAX_NODES }
|
||||
160
server/src/engagement/suppressions.js
Normal file
160
server/src/engagement/suppressions.js
Normal file
@@ -0,0 +1,160 @@
|
||||
// ── The suppression list ───────────────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md G16, Phase 9. Addresses this deployment has stopped mailing,
|
||||
// and the two questions asked of them: "may I send to this one?" at delivery
|
||||
// time, and "why did this one stop?" on the admin screen.
|
||||
//
|
||||
// **Scope: the engagement email channel only** (Phase 9 decision 2). A password
|
||||
// reset, an invite, a verification mail and the contact form are all
|
||||
// user-INITIATED and still attempt, exactly as they still attempt to an
|
||||
// unverified address (`passwordReset.controller.js`). The posture is the same one
|
||||
// that file already states: a background system's opinion about an address must
|
||||
// not be able to lock somebody out of their own account. One reset to a dead
|
||||
// mailbox is not a reputation problem; a rule mailing three thousand people every
|
||||
// week is, and that is what this list guards.
|
||||
//
|
||||
// **The table holds a hash and a mask, never an address.** The hash is what
|
||||
// correlates a bounce back to an `engagement_sends` row (Phase 6 was already
|
||||
// writing `address_hash` on every outcome for this). The mask —
|
||||
// `d***@example.com` — is Phase 9's one addition to §4.5's DDL and exists because
|
||||
// a screen of sha256 digests cannot be operated: an operator has to be able to
|
||||
// see that a whole domain is refusing mail, and to find the person who fixed
|
||||
// their mailbox and let them back in. The local part is DESTROYED rather than
|
||||
// shortened, so the column cannot be turned back into an address book.
|
||||
//
|
||||
// **Nothing here writes a suppression from "the send failed".** What may write
|
||||
// one is `bounceClassify.classify`, which is a much narrower question — see that
|
||||
// file's header for why reusing `mailer.PERMANENT_CODES` would have suppressed
|
||||
// every address the moment an SMTP password went stale.
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const db = require('../model/engagement/engagementSuppressions.db')
|
||||
const bounceClassify = require('./bounceClassify')
|
||||
const log = require('../utils/logger')('engagement')
|
||||
|
||||
const REASONS = ['bounce', 'complaint', 'manual', 'unverified']
|
||||
|
||||
/**
|
||||
* The key an address is stored under.
|
||||
*
|
||||
* Lower-cased first, and that matters more here than anywhere else in the
|
||||
* subsystem: a bounce reported for `Darrow@example.com` has to find the row
|
||||
* written for `darrow@example.com`, and a hash of two spellings is two rows that
|
||||
* never meet. RFC 5321 says the local part is technically case-sensitive; no
|
||||
* relay anybody deploys treats it that way.
|
||||
*/
|
||||
const hashAddress = (address) =>
|
||||
crypto.createHash('sha256').update(String(address).trim().toLowerCase()).digest('hex')
|
||||
|
||||
/**
|
||||
* `darrow@example.com` → `d***@example.com`. Null for anything that is not an
|
||||
* address.
|
||||
*
|
||||
* The domain survives intact because domain-level patterns are the signal an
|
||||
* operator is actually looking for — "everything to this company is bouncing" is
|
||||
* a different problem from three people mistyping their own address, and only the
|
||||
* domain distinguishes them.
|
||||
*
|
||||
* The first character of the local part survives only when there are at least
|
||||
* three, which is not fussiness: for a two-letter local part, one revealed
|
||||
* character plus the domain is most of the address.
|
||||
*/
|
||||
function maskAddress(address) {
|
||||
const s = String(address || '').trim()
|
||||
const at = s.lastIndexOf('@')
|
||||
if (at <= 0 || at === s.length - 1) return null
|
||||
const local = s.slice(0, at)
|
||||
const domain = s.slice(at + 1).toLowerCase()
|
||||
const head = local.length >= 3 ? local[0].toLowerCase() : ''
|
||||
return `${head}***@${domain}`.slice(0, 190)
|
||||
}
|
||||
|
||||
/** Is this address suppressed on this channel? */
|
||||
async function isSuppressed(address, channel = 'email') {
|
||||
if (!address) return null
|
||||
try {
|
||||
return await db.get(hashAddress(address), channel)
|
||||
} catch (err) {
|
||||
// Fail OPEN, and the direction is deliberate. A database that cannot answer
|
||||
// "is this suppressed" must not stop the deployment's mail; the failure mode
|
||||
// it would otherwise produce is total silence with a clean send log, which is
|
||||
// exactly G22's shape. Mailing one dead address during an outage is the
|
||||
// cheaper mistake.
|
||||
log.error('suppression check failed; sending anyway', { message: err.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress an address. Returns true when this call created the row.
|
||||
*
|
||||
* `reason` is validated rather than trusted: it is an ENUM in the schema, so an
|
||||
* unknown value is a 500 from the driver at the worst possible moment (inside a
|
||||
* failure handler), and the callers include an admin route.
|
||||
*/
|
||||
async function suppress({ address, reason, detail = null, channel = 'email', createdBy = null }) {
|
||||
if (!address) return false
|
||||
if (!REASONS.includes(reason)) throw new Error(`suppress: unknown reason "${reason}"`)
|
||||
const created = await db.add({
|
||||
address_hash: hashAddress(address),
|
||||
address_masked: maskAddress(address),
|
||||
channel,
|
||||
reason,
|
||||
detail,
|
||||
created_by: createdBy,
|
||||
})
|
||||
if (created) {
|
||||
// Masked, never the address — the same rule every other log line in this
|
||||
// subsystem follows. It is logged at all because an address dropping off the
|
||||
// mailing list is the kind of change an operator finds out about weeks later
|
||||
// otherwise.
|
||||
log.info('address suppressed', { address: maskAddress(address), reason, channel })
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
/** Un-suppress. Returns true when a row was removed. */
|
||||
async function unsuppress(address, channel = 'email') {
|
||||
if (!address) return false
|
||||
const removed = await db.remove(hashAddress(address), channel)
|
||||
if (removed) log.info('suppression lifted', { address: maskAddress(address), channel })
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Consider a failed send for suppression, and say what was decided.
|
||||
*
|
||||
* The seam between a delivery failure and this list, and the only one — nothing
|
||||
* else in the codebase writes a `bounce` row. Called from `emailChannel.deliver`
|
||||
* with the error the transport threw.
|
||||
*
|
||||
* @returns {Promise<{suppressed: boolean, note: string}>} `note` goes into the
|
||||
* send log's detail, on both outcomes.
|
||||
*/
|
||||
async function considerFailure({ address, error, channel = 'email' }) {
|
||||
const verdict = bounceClassify.classify(error)
|
||||
if (!verdict.suppress) {
|
||||
return { suppressed: false, note: `not suppressed (${verdict.reason})` }
|
||||
}
|
||||
try {
|
||||
const detail = verdict.evidence ? `hard bounce: ${verdict.evidence}` : 'hard bounce'
|
||||
await suppress({ address, reason: 'bounce', detail, channel })
|
||||
return { suppressed: true, note: detail }
|
||||
} catch (err) {
|
||||
// A failure to record the suppression must not change how the send itself is
|
||||
// reported. The mail failed either way, and that is the row the log owes.
|
||||
log.error('could not record a suppression', { message: err.message })
|
||||
return { suppressed: false, note: `hard bounce, not recorded: ${err.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hashAddress,
|
||||
maskAddress,
|
||||
isSuppressed,
|
||||
suppress,
|
||||
unsuppress,
|
||||
considerFailure,
|
||||
REASONS,
|
||||
}
|
||||
320
server/src/engagement/templateSeeds.js
Normal file
320
server/src/engagement/templateSeeds.js
Normal file
@@ -0,0 +1,320 @@
|
||||
// ── The shipped template set (§4.6.1) ──────────────────────────────────────
|
||||
//
|
||||
// "A fresh deployment mails correctly before anyone opens the editor." Every body
|
||||
// that used to be a template literal inside `utils/mailer.js` is a row here, so
|
||||
// Phase 5 is a RELOCATION rather than a regression: nothing that sends mail today
|
||||
// starts depending on an operator authoring something first.
|
||||
//
|
||||
// **Nine seeds, six of them wired in this phase.** The five transactional bodies
|
||||
// plus `auth.email-verify` (which §4.6.1 lists as "new — Phase 9" and which Phase
|
||||
// 1b in fact already shipped) are rendered by `mailer` from this moment. The three
|
||||
// notification seeds are seeded but not yet rendered by anything: `notify.digest`
|
||||
// and `notify.team-post` belong to `teamNotify`/`teamDigestWorker`, which Phase 6
|
||||
// rewrites onto the engine, and `inapp.event` to the channel Phase 7 builds.
|
||||
// Settled with the org lead: seed all nine now so those phases open something
|
||||
// rather than shipping seeds of their own — a seeder bump is the mechanism of last
|
||||
// resort (property 3 below), not a per-phase routine.
|
||||
//
|
||||
// **`seedVersion` is the whole "improve a default without stealing an operator's
|
||||
// work" mechanism.** Bump it when a body changes; the seeder updates rows where
|
||||
// `customized = 0` and skips rows where it is 1. Do NOT bump it for a comment.
|
||||
//
|
||||
// ── Two conventions the bodies follow, both of which are visible to operators ──
|
||||
//
|
||||
// **1. Presentational fragments are variables, because templates have no logic.**
|
||||
// `mailer` used to build ` for the account “Darrow”` with a ternary. A template
|
||||
// cannot, by design (interpolate.js: no conditionals). So the ternary stays at the
|
||||
// call site and its RESULT arrives as a variable — `forWhom` — whose `example`
|
||||
// shows exactly what it produces, leading space and quotes included. That is the
|
||||
// price of a logic-free template language, and it is paid here rather than by
|
||||
// giving operator-authored data a conditional to get wrong.
|
||||
//
|
||||
// **2. Ambient brand variables are supplied by the renderer, not by the caller.**
|
||||
// `siteName`, `siteUrl`, `logoUrl` and `year` are available to every template and
|
||||
// cannot be overridden by whatever a caller passes (`engagement/templates.js`).
|
||||
// §4.6.1 property 2: "no template contains a literal hex code or a logo URL", so
|
||||
// one prebuilt image running as any shard mails in that shard's identity.
|
||||
|
||||
// The ambient set, declared once so the editor's palette (Phase 5b) can offer them
|
||||
// on EVERY template rather than each seed having to list them.
|
||||
const AMBIENT_VARIABLES = Object.freeze([
|
||||
{ name: 'siteName', type: 'string', required: true, example: 'UOMysticmoon' },
|
||||
{ name: 'siteUrl', type: 'string', required: false, example: 'https://example.com' },
|
||||
{ name: 'logoUrl', type: 'string', required: false, example: 'https://example.com/brand/logo.png' },
|
||||
{ name: 'year', type: 'string', required: true, example: '2026' },
|
||||
])
|
||||
|
||||
|
||||
// The per-DELIVERY additions, which are a different thing from the ambient set
|
||||
// above and are declared separately because they apply to a different set of
|
||||
// templates.
|
||||
//
|
||||
// `emailChannel.deliver` computes an unsubscribe token per recipient and merges
|
||||
// it LAST over the projection, so a body may always reference it — but a template
|
||||
// bound to a TRIGGER takes its variable list from that trigger's declaration
|
||||
// (`variablesFor`), and a trigger has no business declaring a fact about how the
|
||||
// mail was delivered. Without these, `{{unsubscribeUrl}}` renders correctly and
|
||||
// then the save-time undeclared-variable check refuses the first operator who
|
||||
// tries to EDIT the body around it.
|
||||
//
|
||||
// Found in Phase 11b, where module-uo's sixteen in-universe bodies are the first
|
||||
// trigger-bound templates in the system to carry an unsubscribe line of their
|
||||
// own: core's generic `notify.event` declares it in its own seed and is bound to
|
||||
// no trigger, so nothing had ever taken this path.
|
||||
const DELIVERY_VARIABLES = Object.freeze([
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
])
|
||||
|
||||
// A tiny helper so the block arrays below read as content rather than as JSON.
|
||||
const text = (id, body, opts = {}) => ({
|
||||
id,
|
||||
type: 'email.text',
|
||||
props: opts.muted ? { text: body, muted: true } : { text: body },
|
||||
})
|
||||
const heading = (id, body, level = 'h1') => ({
|
||||
id,
|
||||
type: 'email.heading',
|
||||
props: { level, text: body },
|
||||
})
|
||||
const button = (id, label, url, textLead) => ({
|
||||
id,
|
||||
type: 'email.button',
|
||||
props: textLead ? { label, url, textLead } : { label, url },
|
||||
})
|
||||
const itemList = (id, variable, emptyText) => ({
|
||||
id,
|
||||
type: 'email.itemList',
|
||||
props: emptyText ? { variable, emptyText } : { variable },
|
||||
})
|
||||
const divider = (id) => ({ id, type: 'email.divider', props: {} })
|
||||
|
||||
const SEEDS = [
|
||||
// ── Transactional: protected = 1, editable but not deletable ─────────────
|
||||
{
|
||||
key: 'auth.password-reset',
|
||||
name: 'Password reset',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
subject: 'Reset your {{siteName}} password',
|
||||
variables: [
|
||||
{ name: 'username', type: 'string', required: false, example: 'Darrow' },
|
||||
{ name: 'forWhom', type: 'string', required: false, example: ' for the account “Darrow”' },
|
||||
{ name: 'resetUrl', type: 'string', required: true, example: 'https://example.com/reset/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'We received a request to reset the password{{forWhom}} at {{siteName}}.'),
|
||||
button('cta', 'Choose a new password', '{{resetUrl}}', 'Choose a new password here:'),
|
||||
text(
|
||||
'p2',
|
||||
'This link is single-use and expires in about an hour. If you didn\'t request this, ' +
|
||||
'you can safely ignore this email — your password won\'t change.',
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'auth.invite',
|
||||
name: 'Account invite',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
subject: 'Your {{siteName}} invitation',
|
||||
variables: [
|
||||
{ name: 'acceptUrl', type: 'string', required: true, example: 'https://example.com/invite/abc123' },
|
||||
{ name: 'roleLabel', type: 'string', required: false, example: ' as moderator' },
|
||||
{ name: 'invitedBy', type: 'string', required: false, example: ' by Aldric' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'You have been invited{{invitedBy}} to join {{siteName}}{{roleLabel}}.'),
|
||||
button('cta', 'Accept your invitation', '{{acceptUrl}}', 'Accept your invitation and set up your account here:'),
|
||||
text('p2', 'This link is single-use and will expire. If you weren\'t expecting this, you can ignore it.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'auth.email-verify',
|
||||
name: 'Email address confirmation',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
subject: 'Confirm your email address for {{siteName}}',
|
||||
variables: [
|
||||
{ name: 'username', type: 'string', required: false, example: 'Darrow' },
|
||||
{ name: 'forWhom', type: 'string', required: false, example: ' “Darrow”' },
|
||||
{ name: 'verifyUrl', type: 'string', required: true, example: 'https://example.com/verify/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'The {{siteName}} account{{forWhom}} asked to use this address for contact and account recovery.'),
|
||||
button('cta', 'Confirm this address', '{{verifyUrl}}', 'Confirm it here:'),
|
||||
text(
|
||||
'p2',
|
||||
'This link is single-use and expires in about a day. Until it is used, nothing changes — ' +
|
||||
'the account keeps whatever address it had.',
|
||||
),
|
||||
text(
|
||||
'p3',
|
||||
'If you did not ask for this, you can ignore this email. Someone may have mistyped their ' +
|
||||
'own address; no account of yours is affected and this link grants no access to anything.',
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'admin.contact-message',
|
||||
name: 'Contact form message',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
// `fromLabel` and `fromName` are the SAME missing name with two different
|
||||
// fallbacks — 'a visitor' in the subject, 'unknown' in the body. That
|
||||
// divergence is inherited from the literal this replaces, and the template is
|
||||
// where it becomes visible and fixable: an operator who wants one word can now
|
||||
// edit the subject line instead of a source file.
|
||||
subject: '{{siteName}} contact from {{fromLabel}}',
|
||||
variables: [
|
||||
{ name: 'fromLabel', type: 'string', required: true, example: 'a visitor' },
|
||||
{ name: 'fromName', type: 'string', required: true, example: 'unknown' },
|
||||
{ name: 'fromEmail', type: 'string', required: true, example: 'ann@example.com' },
|
||||
{ name: 'message', type: 'string', required: true, example: 'Is the shard open to new players?' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'From: {{fromName}} <{{fromEmail}}>'),
|
||||
text('p2', '{{message}}'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'admin.test',
|
||||
name: 'Delivery test',
|
||||
channel: 'email',
|
||||
protected: true,
|
||||
seedVersion: 1,
|
||||
subject: '{{siteName}} email test',
|
||||
variables: [
|
||||
{ name: 'transport', type: 'string', required: true, example: 'smtp' },
|
||||
{ name: 'sentAt', type: 'string', required: false, example: '2026-08-29 18:04 UTC' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', 'This is a test message confirming {{transport}} email delivery is working.'),
|
||||
],
|
||||
},
|
||||
|
||||
// ── Notification: protected = 0, replaceable ─────────────────────────────
|
||||
//
|
||||
// **`notify.event` and `notify.digest` are generic on purpose** (§4.6.1 property
|
||||
// 1): their variables are structural — `title`, `intro`, `items[]` — rather than
|
||||
// domain-specific, so a trigger from core or from any module renders through
|
||||
// them with NO authoring at all. This is what stops "add a trigger" from meaning
|
||||
// "and now write a template".
|
||||
{
|
||||
key: 'notify.event',
|
||||
name: 'Notification (single event)',
|
||||
channel: 'email',
|
||||
protected: false,
|
||||
seedVersion: 1,
|
||||
subject: '{{title}}',
|
||||
variables: [
|
||||
{ name: 'title', type: 'string', required: true, example: 'Your house is close to collapsing' },
|
||||
{ name: 'intro', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
|
||||
{ name: 'items', type: 'list', required: false, example: [{ heading: 'The Silver Anvil', excerpt: 'Britain, Trammel (1119, 1794)' }] },
|
||||
{ name: 'actionUrl', type: 'string', required: false, example: 'https://example.com/houses' },
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
heading('h', '{{title}}'),
|
||||
text('intro', '{{intro}}'),
|
||||
itemList('items', 'items'),
|
||||
button('cta', 'Open {{siteName}}', '{{actionUrl}}'),
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'notify.digest',
|
||||
name: 'Notification digest',
|
||||
channel: 'email',
|
||||
protected: false,
|
||||
seedVersion: 1,
|
||||
subject: '{{siteName}}: {{periodLabel}}',
|
||||
variables: [
|
||||
{ name: 'periodLabel', type: 'string', required: true, example: 'your daily summary' },
|
||||
{ name: 'intro', type: 'string', required: false, example: 'Here is what happened while you were away.' },
|
||||
{ name: 'items', type: 'list', required: false, example: [{ heading: 'New thread in Guild Hall', excerpt: 'Meeting moved to Friday', url: 'https://example.com/teams/1?thread=9' }] },
|
||||
// Precomputed for the same reason `forWhom` is: "and 3 more" needs a
|
||||
// conditional and a plural, and a template has neither.
|
||||
{ name: 'moreNote', type: 'string', required: false, example: 'and 3 more.' },
|
||||
{ name: 'scopeUrl', type: 'string', required: false, example: 'https://example.com/teams/1' },
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
text('intro', '{{intro}}'),
|
||||
itemList('items', 'items'),
|
||||
text('more', '{{moreNote}}', { muted: true }),
|
||||
button('cta', 'Open {{siteName}}', '{{scopeUrl}}'),
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'notify.team-post',
|
||||
name: 'Team post notification',
|
||||
channel: 'email',
|
||||
protected: false,
|
||||
seedVersion: 2,
|
||||
subject: '{{teamName}}: {{threadTitle}}',
|
||||
variables: [
|
||||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil' },
|
||||
{ name: 'authorName', type: 'string', required: true, example: 'Aldric' },
|
||||
{ name: 'threadTitle', type: 'string', required: true, example: 'Meeting moved to Friday' },
|
||||
{ name: 'excerpt', type: 'string', required: false, example: 'We are pushing this week back a day so more people can make it.' },
|
||||
{ name: 'postUrl', type: 'string', required: false, example: '/guilds/the-silver-anvil/forum/412' },
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
text('p1', '{{authorName}} posted in {{teamName}}.'),
|
||||
heading('h', '{{threadTitle}}', 'h2'),
|
||||
text('excerpt', '{{excerpt}}', { muted: true }),
|
||||
button('cta', 'Read the thread', '{{postUrl}}'),
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails for this team, use this link:'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'inapp.event',
|
||||
name: 'On-site notification',
|
||||
channel: 'inapp',
|
||||
protected: false,
|
||||
// **seedVersion 2, and the bump is a correction rather than an improvement.**
|
||||
// Phase 5a wrote this template before the channel that renders it existed, and
|
||||
// named its variables `body` and `url` — names NOTHING supplies. A trigger
|
||||
// declares domain names (`teamName`, `threadTitle`), and `projection.project`
|
||||
// fills the gaps with the STRUCTURAL ones the generic seeds use: `title`,
|
||||
// `intro`, `actionUrl`. So every rendering of this template would have found
|
||||
// `body` and `url` missing and produced a title and nothing else. Renamed to
|
||||
// the vocabulary `notify.event` uses, which is the same property stated once:
|
||||
// a new trigger must render with no authoring at all.
|
||||
seedVersion: 2,
|
||||
// No subject: an inbox row has a title, and the title is a block. The column
|
||||
// is email's, and leaving it NULL is how a non-email template says so.
|
||||
subject: null,
|
||||
variables: [
|
||||
{ name: 'title', type: 'string', required: true, example: 'Your house is close to collapsing' },
|
||||
{ name: 'intro', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
|
||||
{ name: 'actionUrl', type: 'string', required: false, example: '/player/uo/houses' },
|
||||
],
|
||||
// The three blocks map onto the three columns of `user_notifications` by ROLE
|
||||
// (templates.js `renderInappByKey`): the heading is the item's title, the
|
||||
// button is its one action, and everything else is the body. There is no
|
||||
// unsubscribe line — an inbox item has nowhere to send someone that the
|
||||
// preferences screen it links to from does not already reach.
|
||||
blocks: [
|
||||
heading('h', '{{title}}', 'h3'),
|
||||
text('intro', '{{intro}}'),
|
||||
button('cta', 'Open', '{{actionUrl}}'),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** @returns {object|null} the seed definition for `key`. */
|
||||
function seedByKey(key) {
|
||||
return SEEDS.find((s) => s.key === key) || null
|
||||
}
|
||||
|
||||
module.exports = { SEEDS, AMBIENT_VARIABLES, DELIVERY_VARIABLES, seedByKey }
|
||||
343
server/src/engagement/templates.js
Normal file
343
server/src/engagement/templates.js
Normal file
@@ -0,0 +1,343 @@
|
||||
// ── Templates: resolve, render, seed ───────────────────────────────────────
|
||||
//
|
||||
// The seam between a stored `engagement_templates` row and the two body parts a
|
||||
// transport sends. Everything that needs a database happens here; `emailBlocks/`
|
||||
// stays pure and synchronous below it.
|
||||
//
|
||||
// **A missing row renders the shipped default rather than nothing.** `renderByKey`
|
||||
// falls back to `templateSeeds.js` whenever the row is absent or its blocks will
|
||||
// not parse. This is not defensive padding — it is what makes it safe for
|
||||
// `mailer` to depend on the database for a password-reset body at all. Before the
|
||||
// first seed runs, after a restore that dropped the table, on a deployment whose
|
||||
// operator deleted a row by hand: the mail still goes out, in the shipped wording,
|
||||
// and the `protected` flag stops the last of those from being reachable through
|
||||
// the API. The same posture `settingsJson` and `resolveThemeTokens` take — a
|
||||
// stored value that is unusable is treated as absent, never as an error.
|
||||
|
||||
const templatesDb = require('../model/engagement/engagementTemplates.db')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const brand = require('../config/brand')
|
||||
const emailBlocks = require('../emailBlocks')
|
||||
const { SEEDS, AMBIENT_VARIABLES, DELIVERY_VARIABLES, seedByKey } = require('./templateSeeds')
|
||||
// The trigger registry lives with the module registries, not here — a trigger is
|
||||
// something a MODULE declares (see engagement/index.js's header).
|
||||
const { eventTrigger } = require('../modules/registries')
|
||||
const log = require('../utils/logger')('templates')
|
||||
|
||||
const baseUrl = () => (process.env.APP_BASE_URL || brand.url || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* The brand values every template may reference, resolved from the same places
|
||||
* the site's own chrome resolves them (§4.6.1 property 2).
|
||||
*
|
||||
* **They are merged OVER the caller's values, not under.** A caller supplies the
|
||||
* message; the deployment supplies its identity. Letting a caller pass its own
|
||||
* `siteName` would mean a module — or a bug — could send mail that claims to be
|
||||
* from somewhere else, which is precisely the thing a recipient cannot check.
|
||||
*
|
||||
* Never throws: a settings read that fails degrades to the BRAND_* env values, so
|
||||
* mail is branded slightly less specifically rather than not sent.
|
||||
*/
|
||||
async function ambient() {
|
||||
let name = brand.name
|
||||
let logo = brand.logo
|
||||
let theme = null
|
||||
try {
|
||||
name = await settings.getInstanceName()
|
||||
const shell = await settings.getShellBrand()
|
||||
logo = shell.logo || brand.logo
|
||||
theme = shell.theme
|
||||
} catch (err) {
|
||||
log.warn('brand resolution failed; falling back to BRAND_* env', { message: err.message })
|
||||
}
|
||||
const base = baseUrl()
|
||||
const absLogo = logo && logo.startsWith('/') ? `${base}${logo}` : logo || ''
|
||||
return {
|
||||
values: {
|
||||
siteName: name,
|
||||
siteUrl: base,
|
||||
logoUrl: absLogo,
|
||||
year: String(new Date().getUTCFullYear()),
|
||||
},
|
||||
// resolveThemeTokens speaks CSS custom properties; the renderer speaks colour
|
||||
// names. One mapping, here, rather than the renderer knowing about CSS.
|
||||
theme: { accent: theme ? theme['--accent'] : undefined },
|
||||
baseUrl: base,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which variables a template may reference — the input to Phase 5b's palette and
|
||||
* to its save-time "undeclared variable" refusal.
|
||||
*
|
||||
* Two sources, because a template has two possible origins. One tied to a trigger
|
||||
* reads §4.3's declaration, which is the authority for anything a module emits.
|
||||
* One with no trigger — every transactional seed is one; `mailer` renders them by
|
||||
* key with no rule involved — has no trigger to ask, so its shipped definition
|
||||
* carries the list. Ambient brand variables are appended to both.
|
||||
*
|
||||
* @param {{ trigger_id?: string|null, seed_key?: string|null }} template
|
||||
* @returns {Array<{name: string, type: string, required: boolean, example: unknown}>}
|
||||
*/
|
||||
function variablesFor(template) {
|
||||
const own = []
|
||||
if (template && template.trigger_id) {
|
||||
const declared = eventTrigger(template.trigger_id)
|
||||
if (declared && Array.isArray(declared.variables)) own.push(...declared.variables)
|
||||
// A trigger-bound body is engagement mail, and engagement mail always carries
|
||||
// an unsubscribe the channel computes per recipient. A trigger declares what
|
||||
// HAPPENED and has no business declaring how the mail was sent, so the
|
||||
// delivery facts are added here rather than to every declaration.
|
||||
own.push(...DELIVERY_VARIABLES)
|
||||
} else if (template && template.seed_key) {
|
||||
const seed = seedByKey(template.seed_key)
|
||||
if (seed) own.push(...seed.variables)
|
||||
}
|
||||
const names = new Set(own.map((v) => v.name))
|
||||
return [...own, ...AMBIENT_VARIABLES.filter((v) => !names.has(v.name))]
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one template into its two body parts.
|
||||
*
|
||||
* @param {object} template a row, or a seed definition
|
||||
* @param {Record<string, unknown>} values
|
||||
* @param {object} resolved the result of ambient()
|
||||
* @returns {{ subject: string, html: string, text: string, missing: string[] }}
|
||||
*/
|
||||
function renderTemplate(template, values, resolved) {
|
||||
const merged = { ...values, ...resolved.values }
|
||||
const missing = new Set()
|
||||
const ctx = emailBlocks.buildContext({
|
||||
values: merged,
|
||||
theme: resolved.theme,
|
||||
baseUrl: resolved.baseUrl,
|
||||
missing,
|
||||
})
|
||||
const rendered = emailBlocks.renderBlocks(template.blocks, ctx)
|
||||
const subject = template.subject ? ctx.t(template.subject) : ''
|
||||
// An authored `text_body` REPLACES the generated one (§4.4), and is interpolated
|
||||
// like any other authored string. It is a per-template override, not an addition.
|
||||
const text = template.text_body ? ctx.t(template.text_body) : rendered.text
|
||||
return {
|
||||
subject,
|
||||
html: emailBlocks.renderDocument(rendered.html, ctx, subject),
|
||||
text,
|
||||
missing: [...missing],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The template `key` should actually render through, or null.
|
||||
*
|
||||
* Extracted from `renderByKey` in Phase 7 rather than duplicated into the in-app
|
||||
* channel: the fallback chain below is a policy about what this deployment sends
|
||||
* when its own table is in a bad state, and a second channel resolving templates
|
||||
* by its own rules would be a second answer to that. `renderInappByKey` takes the
|
||||
* same rows, the same seeds and the same three refusals.
|
||||
*
|
||||
* @returns {Promise<{subject: string|null, blocks: object[], text_body: string|null}|null>}
|
||||
*/
|
||||
async function resolveTemplate(key) {
|
||||
let template = null
|
||||
try {
|
||||
template = await templatesDb.getByKey(key)
|
||||
} catch (err) {
|
||||
log.warn('template read failed; using the shipped default', { key, message: err.message })
|
||||
}
|
||||
// Three ways a row is not the thing to send, and they are one branch on purpose:
|
||||
// whether the row is absent, structurally unusable, or deliberately unpublished,
|
||||
// the answer is the shipped default rather than a failed message.
|
||||
//
|
||||
// **The `status` arm is the one with teeth** (Phase 5b, decision 3). `status`
|
||||
// has existed since 5a and nothing read it, so an operator who saved a template
|
||||
// as a draft kept mailing it — the editor offered a working state that did not
|
||||
// work. A draft is now exactly what the word means: not what goes out. It falls
|
||||
// back rather than refusing, for the same reason the other two arms do — no
|
||||
// state of this table may stop a password reset.
|
||||
let unusable = null
|
||||
if (!template) unusable = null
|
||||
else if (!Array.isArray(template.blocks) || template.blocks.length === 0) unusable = 'unusable'
|
||||
else if (template.status !== 'published') unusable = 'unpublished'
|
||||
|
||||
if (!template || unusable) {
|
||||
const seed = seedByKey(key)
|
||||
if (!seed) return null
|
||||
if (unusable === 'unusable') log.warn('stored template is unusable; using the shipped default', { key })
|
||||
if (unusable === 'unpublished') log.warn('stored template is a draft; using the shipped default', { key })
|
||||
template = { subject: seed.subject, blocks: seed.blocks, text_body: null }
|
||||
}
|
||||
return template
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the template stored under `key`, falling back to its shipped default.
|
||||
* @returns {Promise<{subject: string, html: string, text: string, missing: string[]}|null>}
|
||||
* null when `key` names no usable row AND no seed — which now includes a
|
||||
* duplicated (seedless) template still in draft.
|
||||
*/
|
||||
async function renderByKey(key, values = {}) {
|
||||
const resolved = await ambient()
|
||||
const template = await resolveTemplate(key)
|
||||
if (!template) return null
|
||||
return renderTemplate(template, values, resolved)
|
||||
}
|
||||
|
||||
// ── The in-app projection (Phase 7) ────────────────────────────────────────
|
||||
//
|
||||
// `user_notifications` has three columns — title, body, url — where email has a
|
||||
// subject and a document, so the in-app channel needs the template rendered into
|
||||
// those three rather than into a mail. **The mapping is by block ROLE**, and it
|
||||
// is here rather than in the channel because it is a statement about what the
|
||||
// block registry means, not about how a row gets written:
|
||||
//
|
||||
// - the first `email.heading` → `title` (a heading IS the item's headline)
|
||||
// - the first `email.button` → `url` (a button IS the item's one action)
|
||||
// - everything else, as TEXT → `body`
|
||||
//
|
||||
// **Text, not the email HTML, and that is the load-bearing choice.** The block
|
||||
// renderer's HTML is built for mail clients: table rows, inline hex colours, a
|
||||
// light-only palette declared with `color-scheme`. Dropped into a page that
|
||||
// follows the viewer's theme it renders as a pale card floating in a dark one.
|
||||
// `toText` is the same content with none of that, and it is the part the block
|
||||
// contract already promises every block can produce.
|
||||
//
|
||||
// The three refusals a mail can afford and an inbox row cannot are handled here
|
||||
// too: a title is NOT NULL, so an empty one falls back to the projected `title`
|
||||
// and then to the trigger id; and a url that is not site-relative is dropped
|
||||
// rather than stored, because the column's whole contract is that a template
|
||||
// cannot aim a signed-in user's click off-site.
|
||||
|
||||
const HEADING = 'email.heading'
|
||||
const BUTTON = 'email.button'
|
||||
|
||||
// user_notifications.title / .url. Truncated rather than refused: a long title is
|
||||
// a cosmetic problem and a dropped notification is not.
|
||||
const MAX_TITLE = 300
|
||||
const MAX_URL = 500
|
||||
|
||||
// The same character class `pageUrlTemplate` and the engine's `url` variables
|
||||
// use (registries.js, engagementEmit.js). Duplicated as a literal rather than
|
||||
// imported from `engagementEmit`, which would be a cycle through the engine.
|
||||
const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/
|
||||
|
||||
/**
|
||||
* Site-relative form of `raw`, or null.
|
||||
*
|
||||
* An absolute url on this deployment's own base is accepted and reduced — a
|
||||
* template that writes `{{siteUrl}}/guilds/4` is saying the same thing as
|
||||
* `/guilds/4`, and refusing it would make the ambient `siteUrl` variable a trap
|
||||
* in the one channel where the link never leaves the site.
|
||||
*/
|
||||
function relativeUrl(raw, base) {
|
||||
const value = String(raw || '').trim()
|
||||
if (!value) return null
|
||||
const stripped = base && value.startsWith(`${base}/`) ? value.slice(base.length) : value
|
||||
if (!RELATIVE_URL.test(stripped)) return null
|
||||
return stripped.slice(0, MAX_URL)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one template into an inbox item.
|
||||
*
|
||||
* @returns {Promise<{title: string, body: string|null, url: string|null, missing: string[]}|null>}
|
||||
* null when `key` names no usable row and no seed — the caller reports a
|
||||
* terminal failure, exactly as the email channel does.
|
||||
*/
|
||||
async function renderInappByKey(key, values = {}) {
|
||||
const resolved = await ambient()
|
||||
const template = await resolveTemplate(key)
|
||||
if (!template) return null
|
||||
|
||||
const merged = { ...values, ...resolved.values }
|
||||
const missing = new Set()
|
||||
const ctx = emailBlocks.buildContext({
|
||||
values: merged,
|
||||
theme: resolved.theme,
|
||||
baseUrl: resolved.baseUrl,
|
||||
missing,
|
||||
})
|
||||
|
||||
const blocks = Array.isArray(template.blocks) ? template.blocks : []
|
||||
const visible = blocks.filter((b) => b && b.visible !== false)
|
||||
const heading = visible.find((b) => b.type === HEADING)
|
||||
const button = visible.find((b) => b.type === BUTTON)
|
||||
// Only the FIRST of each is consumed; a second heading or button is ordinary
|
||||
// body content, which is what an operator who added one meant.
|
||||
const rest = visible.filter((b) => b !== heading && b !== button)
|
||||
|
||||
const headingText = heading ? ctx.t((heading.props || {}).text || '').trim() : ''
|
||||
const title = (headingText || String(merged.title || '').trim() || key).slice(0, MAX_TITLE)
|
||||
const url = button ? relativeUrl(ctx.t((button.props || {}).url || ''), resolved.baseUrl) : null
|
||||
const body = emailBlocks.renderBlocks(rest, ctx).text.trim()
|
||||
|
||||
return { title, body: body || null, url, missing: [...missing] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure every shipped template exists, and bring un-customized rows up to the
|
||||
* current seed. Idempotent: a second run reports nine skips and writes nothing.
|
||||
*
|
||||
* Never throws — it is called from `seedDefaults()` on the boot path, and a
|
||||
* template that failed to seed costs the shipped default (see the header note),
|
||||
* not the deployment.
|
||||
*/
|
||||
async function seedTemplates() {
|
||||
const counts = { inserted: 0, updated: 0, skipped: 0, invalid: 0 }
|
||||
for (const seed of SEEDS) {
|
||||
// Validated against the registry before it is stored, even though a seed is
|
||||
// code rather than input. The alternative is a shipped block array that no
|
||||
// renderer understands sitting in the table, which reads to an operator as
|
||||
// their deployment being broken; refusing to write it leaves `renderByKey`'s
|
||||
// fallback in charge and puts the reason in the boot log.
|
||||
const { valid, errors } = emailBlocks.validateEmailBlocks(seed.blocks)
|
||||
if (!valid) {
|
||||
log.error('shipped template is invalid and was not seeded', { key: seed.key, errors })
|
||||
counts.invalid += 1
|
||||
continue
|
||||
}
|
||||
try {
|
||||
counts[await templatesDb.seedOne(seed)] += 1
|
||||
} catch (err) {
|
||||
log.error('template seed failed', { key: seed.key, message: err.message })
|
||||
}
|
||||
}
|
||||
// The third arm of §4.6.1 property 3: a customized row is never touched, and the
|
||||
// fact that a better default now exists is surfaced instead of applied.
|
||||
let stale = []
|
||||
try {
|
||||
stale = await templatesDb.staleCustomized(SEEDS.map((s) => ({ key: s.key, seedVersion: s.seedVersion })))
|
||||
} catch {
|
||||
stale = []
|
||||
}
|
||||
if (stale.length) {
|
||||
log.info('customized templates have a newer shipped default', { keys: stale.map((t) => t.key) })
|
||||
}
|
||||
log.info('engagement templates ensured', counts)
|
||||
return { ...counts, stale: stale.map((t) => t.key) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of a template key, defined HERE rather than in the templates model
|
||||
* because two unrelated callers need it and only one of them should own it:
|
||||
* `engagementTemplates.model` checks it when a duplicate names a new key, and
|
||||
* `engagementRules.model` checks it when a rule points at one. Phase 4a had its
|
||||
* own pattern with no dot in it, which could not match any key this system
|
||||
* actually uses; one definition is what stops that recurring.
|
||||
*/
|
||||
const KEY_RE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
|
||||
const MAX_KEY = 96
|
||||
|
||||
module.exports = {
|
||||
ambient,
|
||||
variablesFor,
|
||||
renderTemplate,
|
||||
resolveTemplate,
|
||||
renderByKey,
|
||||
renderInappByKey,
|
||||
relativeUrl,
|
||||
seedTemplates,
|
||||
baseUrl,
|
||||
KEY_RE,
|
||||
MAX_KEY,
|
||||
}
|
||||
198
server/src/engagement/transports/index.js
Normal file
198
server/src/engagement/transports/index.js
Normal file
@@ -0,0 +1,198 @@
|
||||
// ── The mail transport registry ────────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §3.1, Phase 1. A **channel** is what kind of sink this is (email,
|
||||
// push, in-app); a **transport** is how one channel actually delivers. This file
|
||||
// is the second half only; the channel half is `../channels.js`, which Phase 3
|
||||
// added when `notification_channel_prefs` needed a single place for `defaultMode`
|
||||
// to live. Its render/deliver functions are still deferred to the phases that can
|
||||
// exercise them, for the reason this comment used to give about the whole file:
|
||||
// registering a function nothing calls freezes a signature before anything has
|
||||
// tried to use it.
|
||||
//
|
||||
// What this replaces: `mailer.buildTransport()` had Gmail's host, port and
|
||||
// OAuth2 auth type as literals, so "which provider" was a code edit. Now the
|
||||
// stored `email_config.transport` names a registration, and the registration
|
||||
// declares its own credential fields — which drives the admin form, the encrypted
|
||||
// blob's shape and the validation, from one place.
|
||||
//
|
||||
// **`credentialFields` is the contract.** It is read by three consumers that
|
||||
// would otherwise drift: the admin form renders it, `sanitizeCredential()` below
|
||||
// filters a submitted body through it, and `describe()` tells the client which
|
||||
// values are secret so they are never sent back. Adding a field to a transport is
|
||||
// therefore one edit, not four.
|
||||
//
|
||||
// **No transport may carry a default host, endpoint or sender** (§3.2 rule 1). A
|
||||
// transport with no operator configuration is `unconfigured` and its channel is
|
||||
// off — it never falls back to somewhere we chose. `scripts/checkNoExternalHosts.js`
|
||||
// is the CI backstop for that rule; this file is where it would be broken first.
|
||||
//
|
||||
// Nothing here touches the database or the network at require time.
|
||||
|
||||
const log = require('../../utils/logger')('mailer')
|
||||
|
||||
// id → transport definition
|
||||
const transports = new Map()
|
||||
|
||||
// Field kinds the admin form knows how to render. `secret` is the only one that
|
||||
// changes behaviour server-side: it is write-only, so an unchanged value arrives
|
||||
// as '' and must be read from the stored credential rather than overwritten.
|
||||
const FIELD_KINDS = new Set(['text', 'number', 'secret', 'boolean'])
|
||||
|
||||
/**
|
||||
* Register a mail transport. Shape-checked at the call and collision-checked
|
||||
* here, the same validate-then-commit discipline `modules/registries.js` uses.
|
||||
*
|
||||
* @param {object} def
|
||||
* @param {string} def.id stable id stored in email_config.transport
|
||||
* @param {string} def.label human name for the admin form
|
||||
* @param {Array} def.credentialFields [{ key, label, kind, required, help, default }]
|
||||
* @param {Function} def.build (credential, config) → a nodemailer-shaped transport
|
||||
* @param {Function} def.isComplete (credential) → boolean; are the required fields present
|
||||
*/
|
||||
function registerMailTransport(def) {
|
||||
if (!def || typeof def !== 'object') throw new Error('registerMailTransport: definition required')
|
||||
const { id, label, credentialFields, build, isComplete } = def
|
||||
if (typeof id !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(id)) {
|
||||
throw new Error(`registerMailTransport: invalid id ${JSON.stringify(id)}`)
|
||||
}
|
||||
if (transports.has(id)) throw new Error(`registerMailTransport: ${id} is already registered`)
|
||||
if (typeof label !== 'string' || !label) throw new Error(`registerMailTransport(${id}): label required`)
|
||||
if (!Array.isArray(credentialFields) || credentialFields.length === 0) {
|
||||
throw new Error(`registerMailTransport(${id}): credentialFields required`)
|
||||
}
|
||||
for (const f of credentialFields) {
|
||||
if (!f || typeof f.key !== 'string' || !f.key) {
|
||||
throw new Error(`registerMailTransport(${id}): every credential field needs a key`)
|
||||
}
|
||||
if (!FIELD_KINDS.has(f.kind)) {
|
||||
throw new Error(`registerMailTransport(${id}): field ${f.key} has unknown kind ${f.kind}`)
|
||||
}
|
||||
}
|
||||
if (typeof build !== 'function') throw new Error(`registerMailTransport(${id}): build() required`)
|
||||
if (typeof isComplete !== 'function') throw new Error(`registerMailTransport(${id}): isComplete() required`)
|
||||
|
||||
transports.set(id, { ...def, credentialFields: credentialFields.map((f) => ({ ...f })) })
|
||||
return id
|
||||
}
|
||||
|
||||
/** The registered transport, or null. Callers must handle null — a stored id can
|
||||
* name a transport that no longer exists (a downgrade, a removed provider), and
|
||||
* that must degrade to "unconfigured", never throw at send time. */
|
||||
function get(id) {
|
||||
return transports.get(id) || null
|
||||
}
|
||||
|
||||
function has(id) {
|
||||
return transports.has(id)
|
||||
}
|
||||
|
||||
/** Every transport, as the admin form needs it: no functions, secrets flagged. */
|
||||
function describe() {
|
||||
return [...transports.values()].map((t) => ({
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
help: t.help || null,
|
||||
credentialFields: t.credentialFields.map((f) => ({
|
||||
key: f.key,
|
||||
label: f.label || f.key,
|
||||
kind: f.kind,
|
||||
required: Boolean(f.required),
|
||||
help: f.help || null,
|
||||
default: f.default === undefined ? null : f.default,
|
||||
placeholder: f.placeholder || null,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a submitted credential body down to the transport's declared fields,
|
||||
* coercing each to its declared kind. Anything not declared is dropped — the
|
||||
* blob that reaches `secretBox.encrypt` only ever holds fields a transport asked
|
||||
* for, so a client cannot smuggle extra keys into stored ciphertext.
|
||||
*
|
||||
* `secret` fields submitted empty are OMITTED rather than blanked, which is the
|
||||
* "leave the existing one alone" convention `botConfig.save`/`emailConfig.save`
|
||||
* already use; `mergeCredential()` is what puts the stored value back.
|
||||
*/
|
||||
function sanitizeCredential(id, body) {
|
||||
const t = get(id)
|
||||
if (!t) return {}
|
||||
const out = {}
|
||||
for (const f of t.credentialFields) {
|
||||
if (!(f.key in (body || {}))) continue
|
||||
const raw = body[f.key]
|
||||
if (f.kind === 'secret') {
|
||||
if (raw === undefined || raw === null || raw === '') continue
|
||||
out[f.key] = String(raw)
|
||||
} else if (f.kind === 'number') {
|
||||
const n = Number(raw)
|
||||
if (Number.isFinite(n)) out[f.key] = n
|
||||
} else if (f.kind === 'boolean') {
|
||||
out[f.key] = Boolean(raw)
|
||||
} else {
|
||||
out[f.key] = raw === null || raw === undefined ? '' : String(raw)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Stored credential + the submitted patch. Omitted secrets keep their stored value. */
|
||||
function mergeCredential(id, stored, patch) {
|
||||
return { ...(stored || {}), ...(patch || {}) }
|
||||
}
|
||||
|
||||
/** Non-secret fields only — safe to return over the admin API. */
|
||||
function publicCredential(id, credential) {
|
||||
const t = get(id)
|
||||
if (!t || !credential) return {}
|
||||
const out = {}
|
||||
for (const f of t.credentialFields) {
|
||||
if (f.kind === 'secret') continue
|
||||
if (credential[f.key] !== undefined) out[f.key] = credential[f.key]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Which declared secrets are actually held, so the form can say "set" without
|
||||
* ever returning the value. */
|
||||
function secretsPresent(id, credential) {
|
||||
const t = get(id)
|
||||
if (!t) return {}
|
||||
const out = {}
|
||||
for (const f of t.credentialFields) {
|
||||
if (f.kind !== 'secret') continue
|
||||
out[f.key] = Boolean(credential && credential[f.key])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Does this credential have everything its transport needs to send? */
|
||||
function isComplete(id, credential) {
|
||||
const t = get(id)
|
||||
if (!t) return false
|
||||
try {
|
||||
return Boolean(t.isComplete(credential || {}))
|
||||
} catch (err) {
|
||||
log.warn('transport isComplete threw', { transport: id, message: err.message })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only: the registry is module-level state and a suite that registers a
|
||||
// fake transport must be able to undo it.
|
||||
function _reset() {
|
||||
transports.clear()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerMailTransport,
|
||||
get,
|
||||
has,
|
||||
describe,
|
||||
sanitizeCredential,
|
||||
mergeCredential,
|
||||
publicCredential,
|
||||
secretsPresent,
|
||||
isComplete,
|
||||
_reset,
|
||||
}
|
||||
96
server/src/engagement/transports/smtp.js
Normal file
96
server/src/engagement/transports/smtp.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// ── SMTP — the baseline mail transport ─────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md decision 4: Gmail OAuth2 is removed, SMTP is the baseline. This
|
||||
// is the only registered transport, and it is deliberately plain SMTP rather than
|
||||
// anything provider-shaped — a relay (Mailgun, SES, Postmark), a self-hosted MTA
|
||||
// and Gmail-with-an-app-password are all reachable through these five fields, so
|
||||
// one transport covers all three postures §7.1 Q5 asks to document.
|
||||
//
|
||||
// **No defaults for host, port, user or sender.** §3.2 rule 1: a transport with
|
||||
// no operator configuration is unconfigured, never pointed at somewhere we chose.
|
||||
// `secure` gets a default because it is a protocol choice, not a destination — and
|
||||
// even that is only a form default, not a fallback applied to a stored blank.
|
||||
//
|
||||
// **`secure` is the field operators get wrong**, so its help text says which port
|
||||
// each setting means: `secure: true` is implicit TLS on 465, `secure: false` is
|
||||
// plaintext-then-STARTTLS on 587 (which nodemailer upgrades automatically). The
|
||||
// combination that silently fails is 587 with secure on — the handshake hangs
|
||||
// rather than erroring cleanly — which is exactly why "Send test" is the real
|
||||
// verification path now (§1.2a consequence 2).
|
||||
|
||||
const nodemailer = require('nodemailer')
|
||||
|
||||
const registry = require('./index')
|
||||
|
||||
const CREDENTIAL_FIELDS = [
|
||||
{
|
||||
key: 'host',
|
||||
label: 'SMTP host',
|
||||
kind: 'text',
|
||||
required: true,
|
||||
placeholder: 'smtp.example.com',
|
||||
help: 'Your relay or mail server. No default — nothing is sent until you set this.',
|
||||
},
|
||||
{
|
||||
key: 'port',
|
||||
label: 'Port',
|
||||
kind: 'number',
|
||||
required: true,
|
||||
default: 587,
|
||||
help: '587 for STARTTLS (most relays), 465 for implicit TLS, 25 for an unauthenticated local MTA.',
|
||||
},
|
||||
{
|
||||
key: 'secure',
|
||||
label: 'Implicit TLS',
|
||||
kind: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
help: 'On for port 465. Leave off for 587 — the connection still upgrades to TLS via STARTTLS.',
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: 'Username',
|
||||
kind: 'text',
|
||||
required: false,
|
||||
help: 'Leave blank for an unauthenticated local relay.',
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: 'Password / API key',
|
||||
kind: 'secret',
|
||||
required: false,
|
||||
help: 'Stored encrypted and never returned. For Gmail this is an app password, not the account password.',
|
||||
},
|
||||
]
|
||||
|
||||
// Authentication is optional (a local MTA on port 25 needs none), so the only
|
||||
// hard requirement is a destination. A username without a password is not
|
||||
// "complete" — that combination authenticates as nobody and fails at the server.
|
||||
function isComplete(credential) {
|
||||
const c = credential || {}
|
||||
if (!c.host || !Number(c.port)) return false
|
||||
if (c.user && !c.password) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function build(credential) {
|
||||
const c = credential || {}
|
||||
const options = {
|
||||
host: String(c.host),
|
||||
port: Number(c.port),
|
||||
secure: Boolean(c.secure),
|
||||
}
|
||||
if (c.user) options.auth = { user: String(c.user), pass: String(c.password || '') }
|
||||
return nodemailer.createTransport(options)
|
||||
}
|
||||
|
||||
registry.registerMailTransport({
|
||||
id: 'smtp',
|
||||
label: 'SMTP',
|
||||
help: 'Any SMTP relay or mail server. See the operator guide for the three supported postures.',
|
||||
credentialFields: CREDENTIAL_FIELDS,
|
||||
isComplete,
|
||||
build,
|
||||
})
|
||||
|
||||
module.exports = { CREDENTIAL_FIELDS, isComplete, build }
|
||||
@@ -118,6 +118,19 @@ const passwordResetConfirmLimiter = makeLimiter({
|
||||
message: 'Too many attempts. Please try again later.',
|
||||
})
|
||||
|
||||
// Email-verification confirmations (engagement Phase 1b). Same reasoning as the
|
||||
// password-reset confirm limiter: the token is 256-bit random, but an
|
||||
// unauthenticated token-bearing endpoint should not be free to hammer. The
|
||||
// REQUEST side is authenticated and limited separately — accountChangeLimiter per
|
||||
// IP, plus a per-user ceiling in the model, because the mail goes to an address
|
||||
// its recipient did not ask to hear from.
|
||||
const emailVerifyConfirmLimiter = makeLimiter({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 15,
|
||||
label: 'email-verify-confirm',
|
||||
message: 'Too many attempts. Please try again later.',
|
||||
})
|
||||
|
||||
// CSP violation reports. Unauthenticated by necessity (browsers send them with no
|
||||
// session), and every accepted report writes a log line — so an attacker who can get
|
||||
// a victim to load a page could otherwise use it as a log-flood amplifier. Generous
|
||||
@@ -149,5 +162,6 @@ module.exports = {
|
||||
mobileSsoExchangeLimiter,
|
||||
passwordResetRequestLimiter,
|
||||
passwordResetConfirmLimiter,
|
||||
emailVerifyConfirmLimiter,
|
||||
cspReportLimiter,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const singletonConfigDb = require('../singletonConfigDb')
|
||||
|
||||
const COLS =
|
||||
'id, provider, enabled, sender_email, sender_name, refresh_token_enc, status, status_detail, last_verified_at, updated_by, created_at, updated_at'
|
||||
'id, provider, transport, enabled, sender_email, sender_name, reply_to, credential_enc, refresh_token_enc, ' +
|
||||
'status, status_detail, last_verified_at, updated_by, created_at, updated_at'
|
||||
|
||||
// Singleton row (id = 1). See ../singletonConfigDb for the get/upsert contract.
|
||||
module.exports = singletonConfigDb('email_config', COLS)
|
||||
|
||||
@@ -1,30 +1,69 @@
|
||||
// Outbound email config store (Gmail OAuth2). Mirrors the botConfig model split:
|
||||
// the DB layer only ever sees ciphertext, and only getWithSecret() (used by the
|
||||
// mailer at send time) decrypts the refresh token. The admin-facing getSafe()
|
||||
// never includes it — callers see only `hasRefreshToken`.
|
||||
// Outbound email config store. Mirrors the botConfig model split: the DB layer
|
||||
// only ever sees ciphertext, and only getWithSecret() (used by the mailer at send
|
||||
// time) decrypts the credential. The admin-facing getSafe() never includes it —
|
||||
// callers see the non-secret fields plus which secrets are set.
|
||||
//
|
||||
// The credential is ONE encrypted JSON blob, not a column per field, because the
|
||||
// field list belongs to the transport (ENGAGEMENT.md §3.1). `credential_enc`
|
||||
// holds `{ host, port, secure, user, password }` for `smtp`; a future relay's
|
||||
// blob would hold different keys against the same column.
|
||||
//
|
||||
// `provider` and `refresh_token_enc` are the removed Gmail OAuth2 connection
|
||||
// (§1.2a). They are no longer read as configuration — `hadLegacyConnection`
|
||||
// exposes the token column's presence for one purpose only: telling an upgraded
|
||||
// deployment that its mail just stopped.
|
||||
|
||||
const db = require('./emailConfig.db')
|
||||
const secretBox = require('../../utils/secretBox')
|
||||
const { transports } = require('../../engagement')
|
||||
|
||||
function toSafe(row) {
|
||||
const DEFAULT_TRANSPORT = 'smtp'
|
||||
|
||||
// A stored blob that will not parse is treated as ABSENT, never as an error —
|
||||
// the same fail-safe rule utils/settingsJson.js applies. A deployment whose
|
||||
// SECRET_ENC_KEY was rotated must degrade to "unconfigured" and say so on the
|
||||
// admin screen, not 500 the settings page and the contact form with it.
|
||||
function readCredential(row) {
|
||||
if (!row || !row.credential_enc) return null
|
||||
try {
|
||||
const parsed = JSON.parse(secretBox.decrypt(row.credential_enc))
|
||||
return parsed && typeof parsed === 'object' ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function toSafe(row, credential) {
|
||||
const transport = (row && row.transport) || DEFAULT_TRANSPORT
|
||||
if (!row) {
|
||||
return {
|
||||
provider: 'gmail_oauth2',
|
||||
transport: DEFAULT_TRANSPORT,
|
||||
enabled: false,
|
||||
senderEmail: null,
|
||||
senderName: null,
|
||||
hasRefreshToken: false,
|
||||
replyTo: null,
|
||||
credential: {},
|
||||
secretsSet: transports.secretsPresent(DEFAULT_TRANSPORT, null),
|
||||
hasCredential: false,
|
||||
hadLegacyConnection: false,
|
||||
status: 'unconfigured',
|
||||
statusDetail: null,
|
||||
lastVerifiedAt: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider: row.provider || 'gmail_oauth2',
|
||||
transport,
|
||||
enabled: Boolean(row.enabled),
|
||||
senderEmail: row.sender_email || null,
|
||||
senderName: row.sender_name || null,
|
||||
hasRefreshToken: Boolean(row.refresh_token_enc),
|
||||
replyTo: row.reply_to || null,
|
||||
credential: transports.publicCredential(transport, credential),
|
||||
secretsSet: transports.secretsPresent(transport, credential),
|
||||
hasCredential: transports.isComplete(transport, credential),
|
||||
// Deliberately the raw column, not "is Gmail configured": nothing reads the
|
||||
// token any more. It answers "did this deployment have working mail before
|
||||
// the upgrade?", which is the G22 warning's whole condition.
|
||||
hadLegacyConnection: Boolean(row.refresh_token_enc),
|
||||
status: row.status || 'unconfigured',
|
||||
statusDetail: row.status_detail || null,
|
||||
lastVerifiedAt: row.last_verified_at || null,
|
||||
@@ -32,38 +71,53 @@ function toSafe(row) {
|
||||
}
|
||||
|
||||
async function getSafe() {
|
||||
return toSafe(await db.get())
|
||||
const row = await db.get()
|
||||
return toSafe(row, readCredential(row))
|
||||
}
|
||||
|
||||
// Decrypted refresh token included — server-side only (building the mailer's
|
||||
// OAuth2 transport). Returns null when no row exists yet.
|
||||
// Decrypted credential included — server-side only (building the transport at
|
||||
// send time). Returns null when no row exists yet.
|
||||
async function getWithSecret() {
|
||||
const row = await db.get()
|
||||
if (!row) return null
|
||||
return {
|
||||
...toSafe(row),
|
||||
refreshToken: row.refresh_token_enc ? secretBox.decrypt(row.refresh_token_enc) : null,
|
||||
}
|
||||
const credential = readCredential(row)
|
||||
return { ...toSafe(row, credential), credentialSecret: credential || {} }
|
||||
}
|
||||
|
||||
// Save admin-supplied / connect-flow config. `refreshToken` undefined or '' means
|
||||
// "leave the existing token unchanged" (same convention as botConfig.save).
|
||||
async function save({ senderEmail, senderName, refreshToken, enabled, status, statusDetail, updatedBy }) {
|
||||
// Save admin-supplied config. `credential` is a PATCH, merged over the stored
|
||||
// blob: a secret field submitted empty is omitted by the registry's sanitizer and
|
||||
// therefore keeps its stored value (same convention as botConfig.save).
|
||||
async function save({ transport, senderEmail, senderName, replyTo, credential, enabled, status, statusDetail, updatedBy }) {
|
||||
const row = await db.get()
|
||||
const fields = {}
|
||||
const nextTransport = transport !== undefined ? transport : (row && row.transport) || DEFAULT_TRANSPORT
|
||||
if (transport !== undefined) fields.transport = transport
|
||||
if (senderEmail !== undefined) fields.sender_email = senderEmail
|
||||
if (senderName !== undefined) fields.sender_name = senderName
|
||||
if (refreshToken) fields.refresh_token_enc = secretBox.encrypt(refreshToken)
|
||||
if (replyTo !== undefined) fields.reply_to = replyTo
|
||||
if (credential !== undefined) {
|
||||
// Changing transport starts from an empty credential rather than merging one
|
||||
// transport's fields into another's — an SMTP password left inside a relay's
|
||||
// blob is a stored secret nobody can see and nothing will ever use.
|
||||
const base = row && row.transport === nextTransport ? readCredential(row) : null
|
||||
const merged = transports.mergeCredential(nextTransport, base, transports.sanitizeCredential(nextTransport, credential))
|
||||
fields.credential_enc = Object.keys(merged).length ? secretBox.encrypt(JSON.stringify(merged)) : null
|
||||
}
|
||||
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
|
||||
if (status !== undefined) fields.status = status
|
||||
if (statusDetail !== undefined) fields.status_detail = statusDetail
|
||||
if (updatedBy !== undefined) fields.updated_by = updatedBy
|
||||
const row = await db.upsert(fields)
|
||||
return toSafe(row)
|
||||
const saved = await db.upsert(fields)
|
||||
return toSafe(saved, readCredential(saved))
|
||||
}
|
||||
|
||||
// Clear the stored credential and disable sending (admin "Disconnect").
|
||||
// Clear the stored credential and disable sending (admin "Clear credentials").
|
||||
// The legacy Gmail token goes too: this is the operator saying "there is no
|
||||
// mailbox here", and leaving the deprecated column set would keep the G22 warning
|
||||
// on screen for a deployment that has deliberately turned mail off.
|
||||
async function disconnect(updatedBy) {
|
||||
const row = await db.upsert({
|
||||
credential_enc: null,
|
||||
refresh_token_enc: null,
|
||||
sender_email: null,
|
||||
enabled: 0,
|
||||
@@ -72,7 +126,7 @@ async function disconnect(updatedBy) {
|
||||
last_verified_at: null,
|
||||
updated_by: updatedBy ?? null,
|
||||
})
|
||||
return toSafe(row)
|
||||
return toSafe(row, readCredential(row))
|
||||
}
|
||||
|
||||
// Record the outcome of the last send / verification so the admin panel has
|
||||
@@ -86,7 +140,7 @@ async function recordStatus({ status, statusDetail, lastVerifiedAt } = {}) {
|
||||
}
|
||||
if (Object.keys(fields).length === 0) return getSafe()
|
||||
const row = await db.upsert(fields)
|
||||
return toSafe(row)
|
||||
return toSafe(row, readCredential(row))
|
||||
}
|
||||
|
||||
module.exports = { getSafe, getWithSecret, save, disconnect, recordStatus }
|
||||
module.exports = { getSafe, getWithSecret, save, disconnect, recordStatus, DEFAULT_TRANSPORT }
|
||||
|
||||
22
server/src/model/emailDedupe/emailDedupe.db.js
Normal file
22
server/src/model/emailDedupe/emailDedupe.db.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS = 'id, user_id, username, lost_address, cleared_at, acknowledged_at'
|
||||
|
||||
// Accounts cleared by the Phase 1b de-duplication, newest first.
|
||||
async function list() {
|
||||
return query(`SELECT ${COLS} FROM email_dedupe_report ORDER BY cleared_at DESC, id DESC`)
|
||||
}
|
||||
|
||||
async function countUnacknowledged() {
|
||||
const rows = await query('SELECT COUNT(*) AS n FROM email_dedupe_report WHERE acknowledged_at IS NULL')
|
||||
return Number(rows[0] ? rows[0].n : 0)
|
||||
}
|
||||
|
||||
// Dismiss the whole report. Idempotent — an already-acknowledged row is skipped
|
||||
// so a second dismissal cannot rewrite when it happened.
|
||||
async function acknowledgeAll() {
|
||||
const res = await query('UPDATE email_dedupe_report SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL')
|
||||
return res.affectedRows || 0
|
||||
}
|
||||
|
||||
module.exports = { list, countUnacknowledged, acknowledgeAll }
|
||||
18
server/src/model/emailDedupe/emailDedupe.model.js
Normal file
18
server/src/model/emailDedupe/emailDedupe.model.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// The Phase 1b de-duplication report: who lost an email address when the UNIQUE
|
||||
// index went on, and what they lost.
|
||||
//
|
||||
// The rows are written by schema.sql's migration in pure SQL — ensureSchema()
|
||||
// executes that file statement-by-statement and there is no JS migration hook —
|
||||
// so this model only ever READS and acknowledges. Nothing here creates a row.
|
||||
//
|
||||
// It matters because these accounts are exactly the ones an operator must
|
||||
// contact: each can still log in, but has no contact address, so password-reset
|
||||
// and engagement mail have nowhere to go until its owner sets a new one.
|
||||
|
||||
const db = require('./emailDedupe.db')
|
||||
|
||||
const list = () => db.list()
|
||||
const countUnacknowledged = () => db.countUnacknowledged()
|
||||
const acknowledgeAll = () => db.acknowledgeAll()
|
||||
|
||||
module.exports = { list, countUnacknowledged, acknowledgeAll }
|
||||
52
server/src/model/emailVerifications/emailVerifications.db.js
Normal file
52
server/src/model/emailVerifications/emailVerifications.db.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS = 'id, token_hash, user_id, email, status, requested_ip, expires_at, created_at, used_at'
|
||||
|
||||
async function insert({ tokenHash, userId, email, requestedIp, expiresAt }) {
|
||||
const res = await query(
|
||||
`INSERT INTO email_verifications (token_hash, user_id, email, requested_ip, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[tokenHash, userId, email, requestedIp ?? null, expiresAt],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function findByTokenHash(tokenHash) {
|
||||
const rows = await query(`SELECT ${COLS} FROM email_verifications WHERE token_hash = ? LIMIT 1`, [tokenHash])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Mark used only if still pending (atomic guard against a double-use race).
|
||||
// Returns rows changed (1 = we won, 0 = already used).
|
||||
async function markUsed(id) {
|
||||
const res = await query(
|
||||
`UPDATE email_verifications SET status = 'used', used_at = NOW()
|
||||
WHERE id = ? AND status = 'pending'`,
|
||||
[id],
|
||||
)
|
||||
return res.affectedRows || 0
|
||||
}
|
||||
|
||||
// Retire every still-pending verification for a user. Called when a fresh request
|
||||
// supersedes older links and after a successful verification, so an address the
|
||||
// user changed their mind about can never be installed by an old email.
|
||||
async function invalidatePendingForUser(userId) {
|
||||
const res = await query(
|
||||
`UPDATE email_verifications SET status = 'used', used_at = NOW()
|
||||
WHERE user_id = ? AND status = 'pending'`,
|
||||
[userId],
|
||||
)
|
||||
return res.affectedRows || 0
|
||||
}
|
||||
|
||||
// How many verification mails this user has asked for since `since`. Backs the
|
||||
// per-user resend ceiling, which the IP rate limiter cannot provide on its own.
|
||||
async function countRecentForUser(userId, since) {
|
||||
const rows = await query(
|
||||
'SELECT COUNT(*) AS n FROM email_verifications WHERE user_id = ? AND created_at >= ?',
|
||||
[userId, since],
|
||||
)
|
||||
return Number(rows[0] ? rows[0].n : 0)
|
||||
}
|
||||
|
||||
module.exports = { insert, findByTokenHash, markUsed, invalidatePendingForUser, countRecentForUser }
|
||||
@@ -0,0 +1,76 @@
|
||||
// Self-service email verification (engagement Phase 1b). A user asks to set or
|
||||
// change their address; a tokened link goes to the address they typed, and only
|
||||
// opening that link installs it. The opaque token lives only in the emailed link —
|
||||
// the DB stores its sha256 — so a DB read never yields a usable link. Same shape
|
||||
// as password_resets and user_invites, deliberately: the design of record calls
|
||||
// this link "signed", but every comparable flow here uses a hashed random token,
|
||||
// and matching them beats adding a second token mechanism for one caller.
|
||||
//
|
||||
// The address is stored ON THE ROW rather than read from the user at confirm
|
||||
// time, because a token proves control of the address it was mailed to and
|
||||
// nothing else.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const db = require('./emailVerifications.db')
|
||||
|
||||
// A day, not an hour. Unlike a password reset this is not a live credential-reset
|
||||
// capability — the worst a leaked token does is attach an address its holder
|
||||
// already controls — and a verification mail is routinely opened on another
|
||||
// device, hours later.
|
||||
const DEFAULT_TTL_MINUTES = 24 * 60
|
||||
|
||||
// Per-user ceiling on verification sends, independent of the per-IP limiter: the
|
||||
// mail goes to an address the RECIPIENT did not choose to hear from, so an
|
||||
// attacker with one account must not be able to use it to pester a mailbox.
|
||||
const MAX_SENDS_PER_WINDOW = 5
|
||||
const SEND_WINDOW_MINUTES = 60
|
||||
|
||||
function hashToken(raw) {
|
||||
return crypto.createHash('sha256').update(String(raw)).digest('hex')
|
||||
}
|
||||
|
||||
// Create a verification for one user + address. Returns { id, token } — the
|
||||
// plaintext token is returned ONCE, for the link, and is never recoverable after.
|
||||
async function create({ userId, email, requestedIp, ttlMinutes = DEFAULT_TTL_MINUTES }) {
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000)
|
||||
const id = await db.insert({ tokenHash: hashToken(token), userId, email, requestedIp, expiresAt })
|
||||
return { id, token }
|
||||
}
|
||||
|
||||
// Resolve a pending, unexpired verification from its plaintext token, else null.
|
||||
// Returns the RAW row (incl. user_id and the address it proves).
|
||||
async function findValidByToken(token) {
|
||||
if (!token) return null
|
||||
const row = await db.findByTokenHash(hashToken(token))
|
||||
if (!row || row.status !== 'pending') return null
|
||||
if (new Date(row.expires_at).getTime() < Date.now()) return null
|
||||
return row
|
||||
}
|
||||
|
||||
// Atomically consume a pending verification (double-use-safe). True if this call
|
||||
// won the race.
|
||||
async function consume(id) {
|
||||
return (await db.markUsed(id)) === 1
|
||||
}
|
||||
|
||||
const invalidatePendingForUser = (userId) => db.invalidatePendingForUser(userId)
|
||||
|
||||
// True when this user has already asked for as many verification mails as the
|
||||
// window allows.
|
||||
async function sendQuotaExhausted(userId) {
|
||||
const since = new Date(Date.now() - SEND_WINDOW_MINUTES * 60 * 1000)
|
||||
return (await db.countRecentForUser(userId, since)) >= MAX_SENDS_PER_WINDOW
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
create,
|
||||
findValidByToken,
|
||||
consume,
|
||||
invalidatePendingForUser,
|
||||
sendQuotaExhausted,
|
||||
hashToken,
|
||||
DEFAULT_TTL_MINUTES,
|
||||
MAX_SENDS_PER_WINDOW,
|
||||
SEND_WINDOW_MINUTES,
|
||||
}
|
||||
85
server/src/model/engagement/engagementCooldowns.db.js
Normal file
85
server/src/model/engagement/engagementCooldowns.db.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* Claim a fire for (rule, user, subject, channel), or refuse it because that
|
||||
* delivery is still cooling. ENGAGEMENT.md §4.1.
|
||||
*
|
||||
* **`channel` is part of the key, and Phase 11b is where that was settled.** The
|
||||
* engine claims inside its per-channel loop, so a key without the channel means
|
||||
* the first channel of a two-channel rule claims the cooldown and every later one
|
||||
* is refused as cooling - which made every in-universe email body of Phase 11b
|
||||
* unreachable behind the in-app one. A cooldown is per delivery.
|
||||
*
|
||||
* **Two statements, each of which is its own atomic decision** - and it is worth
|
||||
* saying why it is not the single `INSERT ... ON DUPLICATE KEY UPDATE` §4.1
|
||||
* describes, because that version was written, tested green against an in-memory
|
||||
* stub, and disproved by the first run against a real MariaDB.
|
||||
*
|
||||
* The one-statement form reads its answer out of `affectedRows`, on the usual
|
||||
* contract: 1 for an insert, 2 for an update that changed something, and 0 for a
|
||||
* duplicate key whose update changed nothing - that 0 being "the guard failed, so
|
||||
* this pair is still cooling". **The mariadb Node connector sets `foundRows: true`
|
||||
* by default**, which makes `affectedRows` report rows MATCHED rather than rows
|
||||
* CHANGED, and `utils/db.js` does not override it. Under that pool the no-op case
|
||||
* returns 1, indistinguishable from a fresh insert: every cooldown would have
|
||||
* passed, always, and nothing in a stubbed test could have noticed.
|
||||
*
|
||||
* So the guard moves into a WHERE clause, where a row either matches or does not
|
||||
* and `foundRows` has nothing to fold together:
|
||||
*
|
||||
* 1. UPDATE the row, guarded on the interval. `affectedRows = 1` means this
|
||||
* caller moved it and owns the fire.
|
||||
* 2. If that matched nothing, the row either does not exist yet or is still
|
||||
* cooling. `INSERT IGNORE` separates the two: 1 means we inserted the first
|
||||
* fire, 0 means the row was there and step 1 already said it is cooling.
|
||||
*
|
||||
* It is still race-free, and each race resolves the right way:
|
||||
* - two concurrent first fires: neither UPDATEs, both INSERT IGNORE, exactly
|
||||
* one gets 1 (the primary key decides). The loser is treated as cooling.
|
||||
* - two concurrent fires after expiry: the row is locked by the first UPDATE,
|
||||
* and the second re-evaluates its guard against the committed row - which now
|
||||
* holds `now`, so it fails and is refused.
|
||||
*
|
||||
* `cooldown_seconds = 0` always passes, which is the documented meaning of a rule
|
||||
* with no cooldown: the guard becomes `last_fired_at <= now`, and it is.
|
||||
*/
|
||||
async function claim(ruleId, userId, subjectKey, channel, cooldownSeconds, now = new Date()) {
|
||||
const moved = await query(
|
||||
`UPDATE engagement_cooldowns
|
||||
SET last_fired_at = ?, fire_count = fire_count + 1
|
||||
WHERE rule_id = ? AND user_id = ? AND subject_key = ? AND channel = ?
|
||||
AND last_fired_at <= ? - INTERVAL ? SECOND`,
|
||||
[now, ruleId, userId, subjectKey, channel, now, cooldownSeconds],
|
||||
)
|
||||
if (Number(moved?.affectedRows || 0) === 1) return true
|
||||
|
||||
const inserted = await query(
|
||||
`INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, channel, last_fired_at, fire_count)
|
||||
VALUES (?, ?, ?, ?, ?, 1)`,
|
||||
[ruleId, userId, subjectKey, channel, now],
|
||||
)
|
||||
return Number(inserted?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
const get = async (ruleId, userId, subjectKey, channel) => {
|
||||
const [row] = await query(
|
||||
`SELECT * FROM engagement_cooldowns
|
||||
WHERE rule_id = ? AND user_id = ? AND subject_key = ? AND channel = ?`,
|
||||
[ruleId, userId, subjectKey, channel],
|
||||
)
|
||||
return row || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop cooldown rows older than `olderThan`.
|
||||
*
|
||||
* `idx_engc_sweep (last_fired_at)` exists for this: the table is written on every
|
||||
* fire and read once per fire, so without a prune it is the unbounded-growth
|
||||
* failure `teamActivityPrune` was written for. A dropped row means the next fire
|
||||
* is treated as a first fire, which is correct as long as the retention window is
|
||||
* longer than the longest configured cooldown - the caller's job, not this one's.
|
||||
*/
|
||||
const prune = (olderThan) =>
|
||||
query('DELETE FROM engagement_cooldowns WHERE last_fired_at < ?', [olderThan])
|
||||
|
||||
module.exports = { claim, get, prune }
|
||||
62
server/src/model/engagement/engagementDigest.db.js
Normal file
62
server/src/model/engagement/engagementDigest.db.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// ── engagement_digest_state (ENGAGEMENT.md §4.2b, Phase 6) ─────────────────
|
||||
//
|
||||
// The state a digest keeps, and deliberately the ONLY state a digest keeps. What
|
||||
// goes IN a digest is re-derived from the source tables when the mail is about to
|
||||
// go out; this table answers one question — "what window does this person's next
|
||||
// digest cover?" — and nothing else.
|
||||
//
|
||||
// Lifted out of `team_notification_prefs.last_digest_at`, where it was a worker's
|
||||
// column sitting on a user's preferences row. Keyed (user, channel, scope) so a
|
||||
// second digest — on another channel, or over another scope — needs no second
|
||||
// column on somebody else's table.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* The stamps for a set of users in one scope, as a Map.
|
||||
*
|
||||
* Returns only the rows that exist. Absence is the CALLER's to interpret, and it
|
||||
* matters that it is: `clampSince` treats a missing row and a NULL stamp
|
||||
* identically (reach back one interval, not to the seven-day floor), so a person
|
||||
* who has never had a digest and a person whose row was written by the backfill
|
||||
* get the same first window.
|
||||
*/
|
||||
async function stampsFor(userIds, channel, scopeKey = '') {
|
||||
const ids = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
if (!ids.length) return new Map()
|
||||
const rows = await query(
|
||||
`SELECT user_id, last_digest_at FROM engagement_digest_state
|
||||
WHERE channel = ? AND scope_key = ? AND user_id IN (${ids.map(() => '?').join(',')})`,
|
||||
[channel, scopeKey, ...ids],
|
||||
)
|
||||
return new Map(rows.map((r) => [Number(r.user_id), r.last_digest_at]))
|
||||
}
|
||||
|
||||
/** One user's stamp, or undefined. */
|
||||
async function stampFor(userId, channel, scopeKey = '') {
|
||||
const rows = await query(
|
||||
`SELECT last_digest_at FROM engagement_digest_state
|
||||
WHERE user_id = ? AND channel = ? AND scope_key = ?`,
|
||||
[Number(userId), channel, scopeKey],
|
||||
)
|
||||
return rows.length ? rows[0].last_digest_at : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a digest as delivered.
|
||||
*
|
||||
* Written ONLY after a successful send, which is the property the old
|
||||
* `stampDigest` had and the one worth restating: stamping first would silently
|
||||
* eat a day of somebody's notifications every time the mail provider has a bad
|
||||
* minute.
|
||||
*/
|
||||
async function stamp(userId, channel, scopeKey, at) {
|
||||
await query(
|
||||
`INSERT INTO engagement_digest_state (user_id, channel, scope_key, last_digest_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE last_digest_at = VALUES(last_digest_at)`,
|
||||
[Number(userId), channel, scopeKey || '', at],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { stampsFor, stampFor, stamp }
|
||||
162
server/src/model/engagement/engagementOutbox.db.js
Normal file
162
server/src/model/engagement/engagementOutbox.db.js
Normal file
@@ -0,0 +1,162 @@
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./engagementRules.db')
|
||||
|
||||
const hydrate = (row) => row && { ...row, payload: parseJson(row.payload, {}) }
|
||||
|
||||
/**
|
||||
* Enqueue one (rule, user, channel) row, idempotently.
|
||||
*
|
||||
* `INSERT IGNORE` rather than a plain INSERT, because `uq_engo_dedupe` is the
|
||||
* replay guard (§4.2a): the sidecar feed is at-least-once and a reconnect
|
||||
* backfills, so the same event arriving twice must produce one row and not two
|
||||
* mails. IGNORE turns that into a silent no-op, which is what a replay should be.
|
||||
*
|
||||
* Returns the new id, or null when the row already existed. A null is a
|
||||
* SUCCESSFUL duplicate, not a failure - the caller counts it as such.
|
||||
*
|
||||
* A NULL dedupe_key never collides (multiple NULLs are legal under a UNIQUE
|
||||
* index), so an emit that carries no key always enqueues. That is the right
|
||||
* default: dedupe is something the emitter opts into by naming a key, and core
|
||||
* cannot invent one that means anything.
|
||||
*/
|
||||
async function enqueue(row) {
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO engagement_outbox
|
||||
(rule_id, trigger_id, user_id, channel, subject_key, scope_key, payload, dedupe_key, due_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
row.rule_id,
|
||||
row.trigger_id,
|
||||
row.user_id,
|
||||
row.channel,
|
||||
row.subject_key || '',
|
||||
// NULL, not '', for an unscoped event: '' is a scope key that means
|
||||
// "deployment-wide" in engagement_digest_state, and this column has to be
|
||||
// able to say "no scope at all" as well.
|
||||
row.scope_key ?? null,
|
||||
JSON.stringify(row.payload || {}),
|
||||
row.dedupe_key ?? null,
|
||||
row.due_at,
|
||||
],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1 ? result.insertId : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows that are due. `idx_engo_due (status, due_at)` is this query.
|
||||
*
|
||||
* It selects rather than claims - claiming is `claim()` below, one row at a
|
||||
* time - so two instances sweeping at once both see the same candidates and then
|
||||
* disagree, harmlessly, about which of them owns each.
|
||||
*/
|
||||
const findDue = async (now, limit = 100) =>
|
||||
(
|
||||
await query(
|
||||
"SELECT * FROM engagement_outbox WHERE status = 'scheduled' AND due_at <= ? ORDER BY due_at, id LIMIT ?",
|
||||
[now, limit],
|
||||
)
|
||||
).map(hydrate)
|
||||
|
||||
/**
|
||||
* Take ownership of one due row: a compare-and-set from 'scheduled' to 'sending'.
|
||||
*
|
||||
* **This is §7.1 Q2's answer** (settled by the org lead 2026-08-29, over
|
||||
* `SELECT ... FOR UPDATE SKIP LOCKED`). The winner is whoever the server reports
|
||||
* `affectedRows = 1` to; every other sweeper gets 0 and moves on. No explicit
|
||||
* transaction, no MariaDB version floor, and it uses a status the ENUM already
|
||||
* carried for exactly this.
|
||||
*
|
||||
* What it makes safe is the OUTBOX and only the outbox. `announceWorker`,
|
||||
* `teamDigestWorker`, `teamForumUploadSweep` and `teamActivityPrune` are all
|
||||
* still written for a single instance, so this does not make the deployment
|
||||
* multi-instance - it makes the one table that will carry mail ready for the day
|
||||
* it is, which is cheap now and expensive after mail has doubled once.
|
||||
*/
|
||||
async function claim(id) {
|
||||
const result = await query(
|
||||
`UPDATE engagement_outbox
|
||||
SET status = 'sending', attempts = attempts + 1
|
||||
WHERE id = ? AND status = 'scheduled'`,
|
||||
[id],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a claimed row back to 'scheduled' with a later `due_at` - a transient
|
||||
* failure that should be retried. The mirror of announceJobs' backoff.
|
||||
*/
|
||||
const reschedule = (id, dueAt, error) =>
|
||||
query(
|
||||
"UPDATE engagement_outbox SET status = 'scheduled', due_at = ?, last_error = ? WHERE id = ? AND status = 'sending'",
|
||||
[dueAt, error ? String(error).slice(0, 2000) : null, id],
|
||||
)
|
||||
|
||||
/** A terminal outcome: 'sent', 'failed' or 'suppressed'. */
|
||||
const finish = (id, status, error) =>
|
||||
query(
|
||||
`UPDATE engagement_outbox
|
||||
SET status = ?, last_error = ?, sent_at = IF(? = 'sent', NOW(), sent_at)
|
||||
WHERE id = ?`,
|
||||
[status, error ? String(error).slice(0, 2000) : null, status, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* Cancel every still-scheduled row for a (rule, subject) - the point of the
|
||||
* grace window (§4.2a). `userId` narrows it to one recipient when the resolving
|
||||
* event names one; a resolving event with no owner cancels for everyone the
|
||||
* original event was queued for, which is the house-repaired case.
|
||||
*
|
||||
* Only 'scheduled' rows are touched: a row already claimed into 'sending' is
|
||||
* somebody's in-flight send and cancelling it would leave two workers writing
|
||||
* one row's outcome.
|
||||
*/
|
||||
async function cancel(ruleId, subjectKey, userId = null) {
|
||||
const params = [ruleId, subjectKey]
|
||||
let sql = "UPDATE engagement_outbox SET status = 'cancelled' WHERE rule_id = ? AND subject_key = ? AND status = 'scheduled'"
|
||||
if (userId !== null && userId !== undefined) {
|
||||
sql += ' AND user_id = ?'
|
||||
params.push(userId)
|
||||
}
|
||||
const result = await query(sql, params)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover rows stranded in 'sending' by a crash between the claim and the
|
||||
* outcome.
|
||||
*
|
||||
* Without this the CAS claim leaks: the claiming process died, no other sweeper
|
||||
* will ever match `status = 'scheduled'`, and the row sits in 'sending' forever.
|
||||
* `updated_at` is the clock (it is ON UPDATE CURRENT_TIMESTAMP, so the claim
|
||||
* stamped it), and the window has to be comfortably longer than the slowest
|
||||
* legitimate send or this reclaims rows that are merely slow.
|
||||
*/
|
||||
const reclaimStale = (before) =>
|
||||
query(
|
||||
"UPDATE engagement_outbox SET status = 'scheduled' WHERE status = 'sending' AND updated_at < ?",
|
||||
[before],
|
||||
)
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM engagement_outbox WHERE id = ?', [id])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/** Admin/read surfaces (Phase 4b) and tests. */
|
||||
const listForRule = async (ruleId, limit = 100) =>
|
||||
(
|
||||
await query('SELECT * FROM engagement_outbox WHERE rule_id = ? ORDER BY id DESC LIMIT ?', [ruleId, limit])
|
||||
).map(hydrate)
|
||||
|
||||
module.exports = {
|
||||
enqueue,
|
||||
findDue,
|
||||
claim,
|
||||
reschedule,
|
||||
finish,
|
||||
cancel,
|
||||
reclaimStale,
|
||||
getById,
|
||||
listForRule,
|
||||
}
|
||||
212
server/src/model/engagement/engagementRecipients.db.js
Normal file
212
server/src/model/engagement/engagementRecipients.db.js
Normal file
@@ -0,0 +1,212 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// A bound on every "resolve an audience" query. `authenticated` on a large
|
||||
// deployment is the whole user table, and the engine turns each id into an
|
||||
// outbox row - so the read that feeds it has to have a ceiling of its own. The
|
||||
// per-rule hourly cap (§7.1 Q3) is the operator-facing limit; this is the one
|
||||
// that keeps a single emit from loading a hundred thousand rows into memory.
|
||||
const MAX_AUDIENCE = 5000
|
||||
|
||||
const ids = (rows) => rows.map((r) => Number(r.id)).filter((n) => Number.isInteger(n) && n > 0)
|
||||
|
||||
const marks = (list) => list.map(() => '?').join(', ')
|
||||
|
||||
/**
|
||||
* Every active user. The `authenticated` audience - and `everyone`, which has no
|
||||
* distinct meaning here: a signed-out visitor has no address, no device and no
|
||||
* inbox, so the widest set the engine can actually deliver to is this one. The
|
||||
* ceiling lattice still distinguishes them (a trigger ceilinged `everyone`
|
||||
* permits an `authenticated` rule and not the reverse); only the resolution
|
||||
* coincides.
|
||||
*
|
||||
* `status = 'active'` on every query in this file: a banned or disabled account
|
||||
* is refused at login, and mailing it engagement content would be the one
|
||||
* surface that did not get the message.
|
||||
*/
|
||||
const active = async (limit = MAX_AUDIENCE) =>
|
||||
ids(await query("SELECT id FROM users WHERE status = 'active' ORDER BY id LIMIT ?", [limit]))
|
||||
|
||||
/** The `staff` audience. Roles come from `ceilings.STAFF_CEILING_ROLES`. */
|
||||
const staff = async (roles, limit = MAX_AUDIENCE) => {
|
||||
if (!roles.length) return []
|
||||
return ids(
|
||||
await query(
|
||||
`SELECT id FROM users WHERE status = 'active' AND role IN (${marks(roles)}) ORDER BY id LIMIT ?`,
|
||||
[...roles, limit],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `subscribers` audience: active users who have opted into this id on at
|
||||
* least one channel.
|
||||
*
|
||||
* "Opted in" is the EFFECTIVE mode, not the stored one, and that is why this is
|
||||
* not simply `WHERE mode <> 'off'`. A row exists only where a user said
|
||||
* something; absence means the channel's `defaultMode` (§3.1). All three of
|
||||
* core's channels default 'off' today, so the second half of the WHERE matches
|
||||
* nobody - but writing it means the day a channel ships with a non-off default,
|
||||
* this audience is already right rather than silently excluding everyone who
|
||||
* never opened the preferences screen.
|
||||
*
|
||||
* `defaultOnChannels` is the caller's list of channels whose defaultMode is not
|
||||
* 'off'; it comes from the channel registry, so the default lives in exactly one
|
||||
* place here too.
|
||||
*/
|
||||
const subscribers = async (streamId, defaultOnChannels = [], limit = MAX_AUDIENCE) => {
|
||||
const optedIn = `EXISTS (
|
||||
SELECT 1 FROM notification_channel_prefs p
|
||||
WHERE p.user_id = u.id AND p.stream_id = ? AND p.mode <> 'off')`
|
||||
|
||||
if (!defaultOnChannels.length) {
|
||||
return ids(
|
||||
await query(
|
||||
`SELECT u.id FROM users u WHERE u.status = 'active' AND ${optedIn} ORDER BY u.id LIMIT ?`,
|
||||
[streamId, limit],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// "At least one default-on channel has no row for this user" - counted rather
|
||||
// than NOT EXISTS, because NOT EXISTS would mean "none of them has a row".
|
||||
const defaulted = `(
|
||||
SELECT COUNT(*) FROM notification_channel_prefs p2
|
||||
WHERE p2.user_id = u.id AND p2.stream_id = ? AND p2.channel IN (${marks(defaultOnChannels)})
|
||||
) < ?`
|
||||
|
||||
return ids(
|
||||
await query(
|
||||
`SELECT u.id FROM users u
|
||||
WHERE u.status = 'active' AND (${optedIn} OR ${defaulted})
|
||||
ORDER BY u.id LIMIT ?`,
|
||||
[streamId, streamId, ...defaultOnChannels, defaultOnChannels.length, limit],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a set of user ids to the active ones.
|
||||
*
|
||||
* Every audience that does NOT come from a query in this file goes through here:
|
||||
* `owner` is a single id off the event envelope, and a module-declared audience
|
||||
* (§5.1a) is a list of ids a module's own resolver produced. Neither has any
|
||||
* notion of account status, and a module must not be able to mail a banned
|
||||
* account by returning its id.
|
||||
*/
|
||||
const filterActive = async (userIds) => {
|
||||
const wanted = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
if (!wanted.length) return []
|
||||
const capped = wanted.slice(0, MAX_AUDIENCE)
|
||||
return ids(
|
||||
await query(
|
||||
`SELECT id FROM users WHERE status = 'active' AND id IN (${marks(capped)}) ORDER BY id`,
|
||||
capped,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored mode for one (id, channel) across a set of users, as a Map.
|
||||
*
|
||||
* The caller applies the channel's `defaultMode` to anyone missing from the map,
|
||||
* which keeps the defaulting in the one place §3.1 put it. Returning stored rows
|
||||
* rather than a decision is what makes that possible.
|
||||
*/
|
||||
const storedModes = async (userIds, streamId, channel) => {
|
||||
if (!userIds.length) return new Map()
|
||||
const rows = await query(
|
||||
`SELECT user_id, mode FROM notification_channel_prefs
|
||||
WHERE stream_id = ? AND channel = ? AND user_id IN (${marks(userIds)})`,
|
||||
[streamId, channel, ...userIds],
|
||||
)
|
||||
return new Map(rows.map((r) => [Number(r.user_id), r.mode]))
|
||||
}
|
||||
|
||||
/**
|
||||
* One user's mailable address, or null — the email channel's `addressFor`
|
||||
* (Phase 6).
|
||||
*
|
||||
* `status = 'active'` is re-checked here even though every audience query already
|
||||
* filtered on it, and the gap it closes is real rather than theoretical: an
|
||||
* outbox row can sit through a `delay_seconds` grace window, so a user banned
|
||||
* between the emit and the send is exactly the case this catches. The cost is one
|
||||
* primary-key lookup on a path that is about to open an SMTP conversation.
|
||||
*
|
||||
* **It still does not gate on `email_verified`, and Phase 9 kept it that way.**
|
||||
* §7.1 Q1's narrower half was settled by the org lead 2026-08-31: the gate
|
||||
* excludes an unverified user at ENQUEUE, in `emailChannel.eligible`, so no
|
||||
* outbox row is written and the admin reach preview can say how many were
|
||||
* dropped. Adding the same condition here as well would look like defence in
|
||||
* depth and would in fact be a second, invisible answer to the question — this
|
||||
* function is also what a password reset would reach if it ever routed through
|
||||
* the channel, and reset mail is deliberately ungated (`passwordReset.controller.js`).
|
||||
*/
|
||||
const addressFor = async (userId) => {
|
||||
const rows = await query(
|
||||
`SELECT email FROM users
|
||||
WHERE id = ? AND status = 'active' AND email IS NOT NULL AND email <> ''`,
|
||||
[Number(userId)],
|
||||
)
|
||||
return rows.length ? { address: rows[0].email } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of these users hold an UNVERIFIED address - the set the Phase 1b gate
|
||||
* excludes when it is on (Phase 9).
|
||||
*
|
||||
* A user with no address at all is in this set, and that is not incidental: the
|
||||
* gate's question is "may we mail this person", and nowhere to send is a stronger
|
||||
* no than an unconfirmed somewhere. `addressFor` refuses them at delivery either
|
||||
* way; including them here is what stops an outbox row being written for a send
|
||||
* that is already known to be impossible.
|
||||
*
|
||||
* One query for a whole audience. The engine calls it once per rule per event
|
||||
* with up to MAX_AUDIENCE ids, so a per-user lookup would be five thousand round
|
||||
* trips on the path that is supposed to be the cheap one.
|
||||
*/
|
||||
const unverifiedAmong = async (userIds) => {
|
||||
const wanted = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
if (!wanted.length) return new Set()
|
||||
const capped = wanted.slice(0, MAX_AUDIENCE)
|
||||
const rows = await query(
|
||||
`SELECT id FROM users
|
||||
WHERE id IN (${marks(capped)})
|
||||
AND (email_verified = 0 OR email IS NULL OR email = '')`,
|
||||
capped,
|
||||
)
|
||||
return new Set(ids(rows))
|
||||
}
|
||||
|
||||
/**
|
||||
* The addresses for a set of active users, as a Map - what the admin reach
|
||||
* preview hashes to count how many of them are suppressed.
|
||||
*
|
||||
* It returns PLAINTEXT, which is the one thing this subsystem otherwise avoids,
|
||||
* and there is no way around it: a suppression is keyed on the sha256 of an
|
||||
* address, so answering "how many of these people are suppressed" requires
|
||||
* hashing each one. The caller (`engagement.controller`) hashes immediately and
|
||||
* returns only a count - no route ever serializes what this returns.
|
||||
*/
|
||||
const addressesFor = async (userIds) => {
|
||||
const wanted = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
if (!wanted.length) return new Map()
|
||||
const capped = wanted.slice(0, MAX_AUDIENCE)
|
||||
const rows = await query(
|
||||
`SELECT id, email FROM users
|
||||
WHERE id IN (${marks(capped)}) AND status = 'active' AND email IS NOT NULL AND email <> ''`,
|
||||
capped,
|
||||
)
|
||||
return new Map(rows.map((r) => [Number(r.id), r.email]))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
active,
|
||||
staff,
|
||||
subscribers,
|
||||
filterActive,
|
||||
storedModes,
|
||||
addressFor,
|
||||
unverifiedAmong,
|
||||
addressesFor,
|
||||
MAX_AUDIENCE,
|
||||
}
|
||||
149
server/src/model/engagement/engagementRules.db.js
Normal file
149
server/src/model/engagement/engagementRules.db.js
Normal file
@@ -0,0 +1,149 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// JSON columns come back from the driver already parsed on some MariaDB/driver
|
||||
// combinations and as a string on others (it depends on whether the column is a
|
||||
// real JSON type or the LONGTEXT + CHECK alias MariaDB implements it as). Every
|
||||
// read below goes through this, so no caller has to know which it got.
|
||||
function parseJson(value, fallback) {
|
||||
if (value === null || value === undefined) return fallback
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const hydrate = (row) =>
|
||||
row && {
|
||||
...row,
|
||||
enabled: Boolean(row.enabled),
|
||||
channels: parseJson(row.channels, []),
|
||||
template_keys: parseJson(row.template_keys, {}),
|
||||
conditions: parseJson(row.conditions, null),
|
||||
cancel_on: parseJson(row.cancel_on, []),
|
||||
}
|
||||
|
||||
const list = async () =>
|
||||
(await query('SELECT * FROM engagement_rules ORDER BY trigger_id, name, id')).map(hydrate)
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM engagement_rules WHERE id = ?', [id])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every ENABLED rule for one trigger. The engine's hot path: one indexed read
|
||||
* per emit, and `idx_engr_trigger (trigger_id, enabled)` is exactly this query.
|
||||
*/
|
||||
const enabledForTrigger = async (triggerId) =>
|
||||
(await query('SELECT * FROM engagement_rules WHERE trigger_id = ? AND enabled = 1', [triggerId])).map(hydrate)
|
||||
|
||||
/**
|
||||
* Every enabled rule that names `triggerId` in its `cancel_on`.
|
||||
*
|
||||
* A JSON_CONTAINS rather than a scan: `cancel_on` is a small array on a small
|
||||
* table, but this runs on EVERY emit — including the overwhelming majority that
|
||||
* cancel nothing — so it must not be a full table read of the rule set.
|
||||
*/
|
||||
const enabledCancelledBy = async (triggerId) =>
|
||||
(
|
||||
await query(
|
||||
"SELECT * FROM engagement_rules WHERE enabled = 1 AND cancel_on IS NOT NULL AND JSON_CONTAINS(cancel_on, JSON_QUOTE(?))",
|
||||
[triggerId],
|
||||
)
|
||||
).map(hydrate)
|
||||
|
||||
const insert = async (rule) => {
|
||||
const result = await query(
|
||||
`INSERT INTO engagement_rules
|
||||
(trigger_id, name, enabled, audience, audience_segment_id, max_sends_per_hour,
|
||||
channels, template_keys, conditions, cooldown_seconds, delay_seconds, cancel_on, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
rule.trigger_id,
|
||||
rule.name,
|
||||
rule.enabled ? 1 : 0,
|
||||
rule.audience,
|
||||
rule.audience_segment_id,
|
||||
rule.max_sends_per_hour,
|
||||
JSON.stringify(rule.channels),
|
||||
JSON.stringify(rule.template_keys),
|
||||
rule.conditions === null ? null : JSON.stringify(rule.conditions),
|
||||
rule.cooldown_seconds,
|
||||
rule.delay_seconds,
|
||||
JSON.stringify(rule.cancel_on || []),
|
||||
rule.updated_by,
|
||||
],
|
||||
)
|
||||
return result.insertId
|
||||
}
|
||||
|
||||
const update = (id, rule) =>
|
||||
query(
|
||||
`UPDATE engagement_rules
|
||||
SET name = ?, enabled = ?, audience = ?, audience_segment_id = ?, max_sends_per_hour = ?,
|
||||
channels = ?, template_keys = ?, conditions = ?, cooldown_seconds = ?,
|
||||
delay_seconds = ?, cancel_on = ?, updated_by = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
rule.name,
|
||||
rule.enabled ? 1 : 0,
|
||||
rule.audience,
|
||||
rule.audience_segment_id,
|
||||
rule.max_sends_per_hour,
|
||||
JSON.stringify(rule.channels),
|
||||
JSON.stringify(rule.template_keys),
|
||||
rule.conditions === null ? null : JSON.stringify(rule.conditions),
|
||||
rule.cooldown_seconds,
|
||||
rule.delay_seconds,
|
||||
JSON.stringify(rule.cancel_on || []),
|
||||
rule.updated_by,
|
||||
id,
|
||||
],
|
||||
)
|
||||
|
||||
/**
|
||||
* Flip `enabled` and nothing else (Phase 4b).
|
||||
*
|
||||
* Deliberately NOT a call through `validate`: turning a rule OFF is the panic
|
||||
* button, and it has to work on a rule the registries would now refuse — one
|
||||
* whose module was uninstalled, or whose trigger has since narrowed its ceiling
|
||||
* underneath a saved audience. Re-validating on the way to `enabled = 0` would
|
||||
* make exactly the rules an operator most wants to stop the ones they cannot.
|
||||
*
|
||||
* Turning a rule ON is safe without re-validation for a different reason: the
|
||||
* engine re-runs the ceiling check at send time (audiences.permitted), so an
|
||||
* enabled-but-no-longer-permitted rule resolves to nobody rather than to the
|
||||
* wrong people.
|
||||
*/
|
||||
const setEnabled = (id, enabled, updatedBy = null) =>
|
||||
query('UPDATE engagement_rules SET enabled = ?, updated_by = ? WHERE id = ?', [
|
||||
enabled ? 1 : 0,
|
||||
updatedBy,
|
||||
id,
|
||||
])
|
||||
|
||||
const remove = (id) => query('DELETE FROM engagement_rules WHERE id = ?', [id])
|
||||
|
||||
/** Does any rule still point at this segment? The check before a segment delete. */
|
||||
const countUsingSegment = async (segmentId) => {
|
||||
const [row] = await query(
|
||||
'SELECT COUNT(*) AS n FROM engagement_rules WHERE audience_segment_id = ?',
|
||||
[segmentId],
|
||||
)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
getById,
|
||||
enabledForTrigger,
|
||||
enabledCancelledBy,
|
||||
insert,
|
||||
update,
|
||||
setEnabled,
|
||||
remove,
|
||||
countUsingSegment,
|
||||
parseJson,
|
||||
}
|
||||
313
server/src/model/engagement/engagementRules.model.js
Normal file
313
server/src/model/engagement/engagementRules.model.js
Normal file
@@ -0,0 +1,313 @@
|
||||
// ── Engagement rules — the save path ───────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §4.5 / §7.1 Q3, Phase 4a. A rule is **operator-editable data**,
|
||||
// not code, and that was a deliberate choice with a condition attached: it is
|
||||
// safe to choose only because `enabled` defaults to 0 and every rule carries a
|
||||
// hard per-hour send ceiling. Both of those live in this file's validation, not
|
||||
// in the screen that calls it - Phase 4b builds a form over this, and a rule that
|
||||
// arrives by any other route (a restore, a fixture, a future import) gets the
|
||||
// same answer.
|
||||
//
|
||||
// **Every check here is a boundary, not a convenience.** The rule editor will
|
||||
// re-implement some of them for the sake of a good error message, and that
|
||||
// second copy is expected to drift - so this one is the one that decides.
|
||||
//
|
||||
// The check with teeth is the ceiling (G24): an operator may narrow a rule's
|
||||
// audience as much as they like and may never widen it past what the trigger
|
||||
// declared. `ceilings.permits` is that arithmetic, `segments.validate` derives
|
||||
// it for a composed audience, and the engine re-runs the same check at SEND
|
||||
// time in case a module upgrade narrowed the declaration underneath a saved rule.
|
||||
|
||||
const db = require('./engagementRules.db')
|
||||
const segmentsDb = require('./engagementSegments.db')
|
||||
const registries = require('../../modules/registries')
|
||||
const ceilings = require('../../modules/ceilings')
|
||||
const channels = require('../../engagement/channels')
|
||||
const segmentExpressions = require('../../engagement/segments')
|
||||
const conditions = require('../../engagement/conditions')
|
||||
const templates = require('../../engagement/templates')
|
||||
|
||||
// A day. Longer than this and "cooldown" is really "send once", which a rule
|
||||
// expresses by being disabled rather than by a decade-long interval.
|
||||
const MAX_COOLDOWN_SECONDS = 86_400
|
||||
// The grace window (§4.2a). A delay longer than a day outlives the thing it is
|
||||
// about - and, more practically, a queue row that sits for a week is a row whose
|
||||
// payload no longer describes the world.
|
||||
const MAX_DELAY_SECONDS = 86_400
|
||||
// The upper bound on the operator-set hourly ceiling. It is not "unlimited by
|
||||
// another name": the number exists so that a misconfiguration is a bad hour
|
||||
// rather than an unbounded one, and a ceiling nobody can raise past a bound is
|
||||
// what makes rules-as-data safe (§7.1 Q3).
|
||||
const MAX_SENDS_PER_HOUR = 10_000
|
||||
|
||||
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/**
|
||||
* Validate a rule against the registries and the lattice.
|
||||
*
|
||||
* Returns `{ ok: true, rule }` with a normalised row ready for insert/update, or
|
||||
* `{ ok: false, errors }` listing every problem.
|
||||
*
|
||||
* `triggerId` may name a trigger nobody currently registers ONLY on an update of
|
||||
* an existing rule - a dormant rule must stay editable (its module can come
|
||||
* back), and refusing to save it would make an uninstall destructive after the
|
||||
* fact. A NEW rule must name a live trigger, because there is nothing to
|
||||
* preserve and a typo should be caught now.
|
||||
*/
|
||||
async function validate(input, { existing = null } = {}) {
|
||||
const errors = []
|
||||
const raw = isPlainObject(input) ? input : {}
|
||||
|
||||
const triggerId = typeof raw.triggerId === 'string' ? raw.triggerId : existing?.trigger_id
|
||||
const declaration = triggerId ? registries.eventTrigger(triggerId) : null
|
||||
if (!triggerId) errors.push('triggerId is required')
|
||||
else if (!declaration && !existing) errors.push(`no trigger "${triggerId}" is registered`)
|
||||
|
||||
const name = typeof raw.name === 'string' ? raw.name.trim() : ''
|
||||
if (!name) errors.push('name is required')
|
||||
else if (name.length > 160) errors.push('name is longer than 160 characters')
|
||||
|
||||
// Channels are stored as data and checked against the registry, so a rule
|
||||
// cannot name a sink that does not exist. Phase 4b's form offers the registered
|
||||
// set; this is what makes that an affordance rather than the rule.
|
||||
const wanted = Array.isArray(raw.channels) ? [...new Set(raw.channels)] : []
|
||||
if (!wanted.length) errors.push('at least one channel is required')
|
||||
for (const c of wanted) if (!channels.has(c)) errors.push(`no channel "${c}" is registered`)
|
||||
|
||||
// `template_keys` is { channel: templateKey }. Phase 5 owns templates, so the
|
||||
// KEYS are checked for shape and not for existence - a rule may legitimately
|
||||
// name a template that has not been authored yet, and Phase 5's editor is where
|
||||
// that becomes resolvable.
|
||||
//
|
||||
// **The shape check was wrong until Phase 5b, and wrong in the way that matters:**
|
||||
// it required `/^[a-z0-9][a-z0-9-]{0,63}$/`, which has no dot, while every
|
||||
// template key that exists is dotted (`notify.event`, `auth.password-reset`).
|
||||
// Written before templates existed, it could not match one, so no rule could name
|
||||
// any real template - which is precisely the workflow S4.6.2's duplicate action
|
||||
// exists to serve. It now uses the templates model's own pattern, so the two
|
||||
// cannot disagree about what a key is.
|
||||
const templateKeys = {}
|
||||
if (raw.templateKeys !== undefined && !isPlainObject(raw.templateKeys)) {
|
||||
errors.push('templateKeys must be an object of { channel: templateKey }')
|
||||
} else {
|
||||
for (const [channel, key] of Object.entries(raw.templateKeys || {})) {
|
||||
if (!wanted.includes(channel)) {
|
||||
errors.push(`templateKeys names "${channel}", which is not one of this rule's channels`)
|
||||
continue
|
||||
}
|
||||
if (typeof key !== 'string' || key.length > templates.MAX_KEY || !templates.KEY_RE.test(key)) {
|
||||
errors.push(`templateKeys.${channel} is not a valid template key`)
|
||||
continue
|
||||
}
|
||||
templateKeys[channel] = key
|
||||
}
|
||||
}
|
||||
|
||||
const numbers = [
|
||||
['cooldownSeconds', 'cooldown_seconds', MAX_COOLDOWN_SECONDS, 0],
|
||||
['delaySeconds', 'delay_seconds', MAX_DELAY_SECONDS, 0],
|
||||
['maxSendsPerHour', 'max_sends_per_hour', MAX_SENDS_PER_HOUR, 1],
|
||||
]
|
||||
const scalars = {}
|
||||
for (const [key, column, max, min] of numbers) {
|
||||
const supplied = raw[key]
|
||||
const fallback = existing ? existing[column] : column === 'max_sends_per_hour' ? 100 : 0
|
||||
const value = supplied === undefined || supplied === null ? fallback : Number(supplied)
|
||||
if (!Number.isInteger(value) || value < min || value > max) {
|
||||
errors.push(`${key} must be an integer between ${min} and ${max}`)
|
||||
} else scalars[column] = value
|
||||
}
|
||||
|
||||
// `cancel_on` names trigger ids, and they are NOT checked for registration for
|
||||
// the dormancy reason (§7.3): a resolving event whose module is temporarily
|
||||
// absent should stop cancelling, not make the rule unsaveable.
|
||||
const cancelOn = Array.isArray(raw.cancelOn) ? [...new Set(raw.cancelOn.filter((t) => typeof t === 'string'))] : []
|
||||
if (cancelOn.length && !scalars.delay_seconds) {
|
||||
// Not an error - it is a rule that will never cancel anything, because there
|
||||
// is no window in which to do it. Worth saying out loud rather than silently
|
||||
// accepting a setting that cannot take effect.
|
||||
errors.push('cancelOn has no effect without a delaySeconds grace window')
|
||||
}
|
||||
|
||||
const checked = conditions.validate(declaration, raw.conditions === undefined ? existing?.conditions : raw.conditions)
|
||||
if (!checked.ok) errors.push(...checked.errors)
|
||||
|
||||
// ── The audience, and the one check that is a security boundary ──────────
|
||||
let audience = typeof raw.audience === 'string' ? raw.audience : existing?.audience || declaration?.audience
|
||||
let segmentId = raw.audienceSegmentId === undefined ? existing?.audience_segment_id ?? null : raw.audienceSegmentId
|
||||
segmentId = segmentId === null || segmentId === '' ? null : Number(segmentId)
|
||||
|
||||
let effectiveCeiling = null
|
||||
if (segmentId !== null) {
|
||||
if (!Number.isInteger(segmentId)) errors.push('audienceSegmentId must be an integer')
|
||||
else {
|
||||
const segment = await segmentsDb.getById(segmentId)
|
||||
if (!segment) errors.push(`no audience segment ${segmentId} exists`)
|
||||
else {
|
||||
// The segment's STORED ceiling, derived when it was saved by
|
||||
// `segments.validate` from the narrowest audience it contains. A rule
|
||||
// pointing at a segment takes that as its reach; the `audience` column
|
||||
// is retained for display and is not what the engine resolves.
|
||||
effectiveCeiling = segment.ceiling
|
||||
audience = segment.ceiling
|
||||
}
|
||||
}
|
||||
} else if (!ceilings.isCeiling(audience)) {
|
||||
errors.push(`audience must be one of ${ceilings.CEILINGS.join(', ')}`)
|
||||
} else {
|
||||
effectiveCeiling = audience
|
||||
}
|
||||
|
||||
if (declaration && effectiveCeiling && !ceilings.permits(declaration.ceiling, effectiveCeiling)) {
|
||||
errors.push(
|
||||
`audience "${effectiveCeiling}" is wider than trigger "${triggerId}" permits (ceiling "${declaration.ceiling}")`,
|
||||
)
|
||||
}
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
rule: {
|
||||
trigger_id: triggerId,
|
||||
name,
|
||||
enabled: raw.enabled === undefined ? Boolean(existing?.enabled) : Boolean(raw.enabled),
|
||||
audience,
|
||||
audience_segment_id: segmentId,
|
||||
max_sends_per_hour: scalars.max_sends_per_hour,
|
||||
channels: wanted,
|
||||
template_keys: templateKeys,
|
||||
conditions: checked.conditions,
|
||||
cooldown_seconds: scalars.cooldown_seconds,
|
||||
delay_seconds: scalars.delay_seconds,
|
||||
cancel_on: cancelOn,
|
||||
updated_by: Number.isInteger(raw.updatedBy) ? raw.updatedBy : null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function create(input) {
|
||||
const checked = await validate(input)
|
||||
if (!checked.ok) return checked
|
||||
const id = await db.insert(checked.rule)
|
||||
return { ok: true, rule: await db.getById(id) }
|
||||
}
|
||||
|
||||
async function update(id, input) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, errors: [`no rule ${id} exists`], notFound: true }
|
||||
const checked = await validate(input, { existing })
|
||||
if (!checked.ok) return checked
|
||||
await db.update(id, checked.rule)
|
||||
return { ok: true, rule: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this rule cannot currently fire, as a list of sentences. Empty = it can.
|
||||
*
|
||||
* **Three ways, not two.** A rule can be dormant because its trigger is gone,
|
||||
* because a channel it names is gone, or because its AUDIENCE is gone - and the
|
||||
* audience case has two shapes that a screen must not collapse into one:
|
||||
*
|
||||
* • the segment row was deleted out from under it (§7.3), or
|
||||
* • the segment still exists and every audience in it belongs to a module that
|
||||
* has been uninstalled (§5.1a rule 4).
|
||||
*
|
||||
* Both leave the rule reaching nobody. Only the first leaves nothing behind, and
|
||||
* a check that asks only "does the row exist" reports the first and misses the
|
||||
* second - which shows an enabled, healthy-looking rule that cannot fire. Found
|
||||
* by uninstalling a module under a live rule while building Phase 4b's screen.
|
||||
*
|
||||
* @param {Map<number, {expression: object}>} segments every segment, by id
|
||||
*/
|
||||
function dormancyReasons(rule, segments) {
|
||||
const reasons = []
|
||||
if (!registries.eventTrigger(rule.trigger_id)) reasons.push(`trigger "${rule.trigger_id}" is not registered`)
|
||||
if (rule.audience_segment_id) {
|
||||
const segment = segments.get(rule.audience_segment_id)
|
||||
if (!segment) reasons.push('its audience segment no longer exists')
|
||||
else {
|
||||
const missing = segmentExpressions.missingAudiences(segment.expression)
|
||||
if (missing.length) {
|
||||
reasons.push(`its audience "${segment.name}" uses ${missing.join(', ')}, which nothing registers`)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const c of rule.channels || []) if (!channels.has(c)) reasons.push(`channel "${c}" is not registered`)
|
||||
return reasons
|
||||
}
|
||||
|
||||
const annotate = (rule, segments) => {
|
||||
const reasons = dormancyReasons(rule, segments)
|
||||
return { ...rule, dormant: reasons.length > 0, dormantReasons: reasons }
|
||||
}
|
||||
|
||||
const segmentsById = async () => new Map((await segmentsDb.list()).map((s) => [s.id, s]))
|
||||
|
||||
/**
|
||||
* List every rule, each annotated with whether it can currently fire.
|
||||
*
|
||||
* Dormancy is computed rather than stored (§7.3): a rule whose trigger or
|
||||
* segment is not registered right now is listed, flagged, and left alone. The
|
||||
* alternative - deleting or disabling it on uninstall - destroys an operator's
|
||||
* configuration on the strength of a module being temporarily absent.
|
||||
*/
|
||||
async function listAnnotated() {
|
||||
const rows = await db.list()
|
||||
const segments = await segmentsById()
|
||||
return rows.map((rule) => annotate(rule, segments))
|
||||
}
|
||||
|
||||
/** One rule with the same dormancy annotation the list carries, or null. */
|
||||
async function getAnnotated(id) {
|
||||
const rule = await db.getById(id)
|
||||
if (!rule) return null
|
||||
return annotate(rule, await segmentsById())
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn one rule on or off, writing that column and no other (Phase 4b).
|
||||
*
|
||||
* This is the one write path that does NOT go through `validate`, and the
|
||||
* asymmetry is deliberate. Switching a rule OFF must always be possible - a rule
|
||||
* whose module has been uninstalled, or whose trigger has since narrowed its
|
||||
* ceiling under a saved audience, is exactly the rule an operator most urgently
|
||||
* wants stopped, and it is exactly the rule `validate` would now refuse. The
|
||||
* full editor still re-validates on save, and the engine re-checks the ceiling at
|
||||
* send time, so nothing is loosened by having a switch that is only a switch.
|
||||
*/
|
||||
async function setEnabled(id, enabled, updatedBy = null) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, errors: [`no rule ${id} exists`], notFound: true }
|
||||
await db.setEnabled(id, enabled, updatedBy)
|
||||
return { ok: true, rule: await getAnnotated(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a rule.
|
||||
*
|
||||
* Its cooldown rows and any still-pending outbox rows go with it (both carry an
|
||||
* ON DELETE CASCADE), and that is the right blast radius: neither means anything
|
||||
* without the rule. `engagement_sends` deliberately does NOT — its `rule_id`
|
||||
* carries no foreign key — so the send log outlives the rule and the record of
|
||||
* what was actually mailed survives an operator tidying up.
|
||||
*/
|
||||
async function remove(id) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, errors: [`no rule ${id} exists`], notFound: true }
|
||||
await db.remove(id)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
create,
|
||||
update,
|
||||
setEnabled,
|
||||
remove,
|
||||
listAnnotated,
|
||||
getAnnotated,
|
||||
MAX_COOLDOWN_SECONDS,
|
||||
MAX_DELAY_SECONDS,
|
||||
MAX_SENDS_PER_HOUR,
|
||||
}
|
||||
37
server/src/model/engagement/engagementSegments.db.js
Normal file
37
server/src/model/engagement/engagementSegments.db.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./engagementRules.db')
|
||||
|
||||
const hydrate = (row) => row && { ...row, expression: parseJson(row.expression, null) }
|
||||
|
||||
const list = async () =>
|
||||
(await query('SELECT * FROM engagement_audience_segments ORDER BY name, id')).map(hydrate)
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM engagement_audience_segments WHERE id = ?', [id])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* `ceiling` is written by the caller from `segments.deriveCeiling`, never taken
|
||||
* from an operator. It is a stored column rather than a runtime computation so
|
||||
* an audit can read what a rule was ALLOWED to reach without re-resolving it,
|
||||
* and so a module that later widens its own audience's ceiling cannot
|
||||
* retroactively widen a segment that was saved under the old one.
|
||||
*/
|
||||
const insert = async (segment) => {
|
||||
const result = await query(
|
||||
'INSERT INTO engagement_audience_segments (name, expression, ceiling, updated_by) VALUES (?, ?, ?, ?)',
|
||||
[segment.name, JSON.stringify(segment.expression), segment.ceiling, segment.updated_by ?? null],
|
||||
)
|
||||
return result.insertId
|
||||
}
|
||||
|
||||
const update = (id, segment) =>
|
||||
query(
|
||||
'UPDATE engagement_audience_segments SET name = ?, expression = ?, ceiling = ?, updated_by = ? WHERE id = ?',
|
||||
[segment.name, JSON.stringify(segment.expression), segment.ceiling, segment.updated_by ?? null, id],
|
||||
)
|
||||
|
||||
const remove = (id) => query('DELETE FROM engagement_audience_segments WHERE id = ?', [id])
|
||||
|
||||
module.exports = { list, getById, insert, update, remove }
|
||||
83
server/src/model/engagement/engagementSegments.model.js
Normal file
83
server/src/model/engagement/engagementSegments.model.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// ── Audience segments — the save path ──────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §5.1a, Phase 4a. The thin model over `segments.js`: it validates,
|
||||
// derives the ceiling, and writes. The composition UI is Phase 4b's; this is what
|
||||
// it will call, and what any other route in must go through.
|
||||
//
|
||||
// The `ceiling` column is never taken from the caller. It is derived from the
|
||||
// expression by `segments.validate` as the narrowest ceiling in the tree, and
|
||||
// stored so an audit can read what a rule was ALLOWED to reach without
|
||||
// re-resolving it.
|
||||
|
||||
const db = require('./engagementSegments.db')
|
||||
const rulesDb = require('./engagementRules.db')
|
||||
const segments = require('../../engagement/segments')
|
||||
|
||||
async function save(input, { id = null } = {}) {
|
||||
const errors = []
|
||||
const name = typeof input?.name === 'string' ? input.name.trim() : ''
|
||||
if (!name) errors.push('name is required')
|
||||
else if (name.length > 160) errors.push('name is longer than 160 characters')
|
||||
|
||||
const checked = segments.validate(input?.expression)
|
||||
if (!checked.ok) errors.push(...checked.errors)
|
||||
if (errors.length) return { ok: false, errors }
|
||||
|
||||
const row = {
|
||||
name,
|
||||
expression: checked.expression,
|
||||
ceiling: checked.ceiling,
|
||||
updated_by: Number.isInteger(input?.updatedBy) ? input.updatedBy : null,
|
||||
}
|
||||
|
||||
if (id) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, errors: [`no segment ${id} exists`], notFound: true }
|
||||
await db.update(id, row)
|
||||
return { ok: true, segment: await db.getById(id) }
|
||||
}
|
||||
const newId = await db.insert(row)
|
||||
return { ok: true, segment: await db.getById(newId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a segment, refusing while a rule still points at it.
|
||||
*
|
||||
* There is deliberately no foreign key doing this (schema.sql): the database
|
||||
* options are CASCADE, which would delete an operator's rules, and SET NULL,
|
||||
* which would silently fall the rule back to its plain `audience` column and mail
|
||||
* a DIFFERENT set of people. Refusing here, with the count, is the third option
|
||||
* and the only safe one.
|
||||
*/
|
||||
async function remove(id) {
|
||||
const inUse = await rulesDb.countUsingSegment(id)
|
||||
if (inUse > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
inUse,
|
||||
errors: [`${inUse} rule${inUse === 1 ? ' still uses' : 's still use'} this segment`],
|
||||
}
|
||||
}
|
||||
await db.remove(id)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every segment, each annotated with whether it can currently resolve.
|
||||
*
|
||||
* A segment naming an audience whose module has been uninstalled is DORMANT, not
|
||||
* broken: it is listed, it resolves to nobody, and it starts working again when
|
||||
* the module comes back (§5.1a rule 4).
|
||||
*/
|
||||
async function listAnnotated() {
|
||||
const rows = await db.list()
|
||||
return rows.map((segment) => {
|
||||
// The walk lives in segments.js so the rule list can ask the same question:
|
||||
// a rule pointing at a DORMANT segment is dormant too, and asking only
|
||||
// whether the segment row still exists misses that (§5.1a rule 4).
|
||||
const missing = segments.missingAudiences(segment.expression)
|
||||
return { ...segment, dormant: missing.length > 0, missingAudiences: missing }
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { save, remove, listAnnotated }
|
||||
110
server/src/model/engagement/engagementSends.db.js
Normal file
110
server/src/model/engagement/engagementSends.db.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* Record one attempt's outcome. G15: "did user X get the mail?" has never been
|
||||
* answerable on this deployment, and this row is the answer.
|
||||
*
|
||||
* `address_hash` is a sha256 the CALLER computes, never an address. The log has
|
||||
* to correlate a bounce back to a recipient (Phase 9) and it must not become a
|
||||
* second address book, and a hash does the first without the second.
|
||||
*/
|
||||
const record = async (entry) => {
|
||||
const result = await query(
|
||||
`INSERT INTO engagement_sends
|
||||
(outbox_id, rule_id, trigger_id, user_id, channel, transport, address_hash, status, detail)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
entry.outbox_id ?? null,
|
||||
entry.rule_id ?? null,
|
||||
entry.trigger_id,
|
||||
entry.user_id ?? null,
|
||||
entry.channel,
|
||||
entry.transport ?? null,
|
||||
entry.address_hash ?? null,
|
||||
entry.status,
|
||||
entry.detail ? String(entry.detail).slice(0, 500) : null,
|
||||
],
|
||||
)
|
||||
return result.insertId
|
||||
}
|
||||
|
||||
/**
|
||||
* How many sends this rule has made in the last hour - the count the per-rule
|
||||
* ceiling (§7.1 Q3) is enforced against.
|
||||
*
|
||||
* It counts 'sent' only. A refusal that never left the building (`suppressed`)
|
||||
* and an attempt that failed are not sends, and counting them would let a broken
|
||||
* transport silently consume a rule's whole hourly budget and mute it.
|
||||
*
|
||||
* `idx_engs_rule_window (rule_id, created_at)` exists for this: it runs once per
|
||||
* rule per event, so it has to be an index range scan.
|
||||
*/
|
||||
const countSentSince = async (ruleId, since) => {
|
||||
const [row] = await query(
|
||||
"SELECT COUNT(*) AS n FROM engagement_sends WHERE rule_id = ? AND status = 'sent' AND created_at >= ?",
|
||||
[ruleId, since],
|
||||
)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The trigger id a template test send is logged under (Phase 5b, decision 4).
|
||||
*
|
||||
* §4.6.2 asks for a test send "recorded in `engagement_sends` like any other
|
||||
* message", and `trigger_id` is NOT NULL — but a transactional template has no
|
||||
* trigger at all, so there was nothing honest to put there. A synthetic id costs
|
||||
* no schema change and keeps the column meaning one thing: what caused this send.
|
||||
*
|
||||
* It is deliberately NOT a registered trigger. Nothing may point a rule at it, and
|
||||
* the admin list renders it by name rather than by looking it up in a catalog it
|
||||
* will never appear in.
|
||||
*/
|
||||
const TEST_SEND_TRIGGER = 'core.admin.test-send'
|
||||
|
||||
/** WHERE-clause builder shared by `list` and `count`, so the two cannot disagree. */
|
||||
const filters = ({ triggerId = null, userId = null, ruleId = null, status = null } = {}) => {
|
||||
const where = []
|
||||
const params = []
|
||||
if (triggerId) {
|
||||
where.push('trigger_id = ?')
|
||||
params.push(triggerId)
|
||||
}
|
||||
if (userId) {
|
||||
where.push('user_id = ?')
|
||||
params.push(userId)
|
||||
}
|
||||
if (ruleId) {
|
||||
where.push('rule_id = ?')
|
||||
params.push(ruleId)
|
||||
}
|
||||
if (status) {
|
||||
where.push('status = ?')
|
||||
params.push(status)
|
||||
}
|
||||
return { clause: where.length ? `WHERE ${where.join(' AND ')}` : '', params }
|
||||
}
|
||||
|
||||
/** The admin send log (Phase 5b), newest first. */
|
||||
const list = (opts = {}) => {
|
||||
const { clause, params } = filters(opts)
|
||||
return query(`SELECT * FROM engagement_sends ${clause} ORDER BY id DESC LIMIT ? OFFSET ?`, [
|
||||
...params,
|
||||
opts.limit || 50,
|
||||
opts.offset || 0,
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* How many rows match the same filters — the total the paged screen needs.
|
||||
*
|
||||
* Its own query rather than `SQL_CALC_FOUND_ROWS`, which MariaDB has deprecated,
|
||||
* and rather than counting the page, which would report the page size as the total
|
||||
* on every page but the last.
|
||||
*/
|
||||
const count = async (opts = {}) => {
|
||||
const { clause, params } = filters(opts)
|
||||
const [row] = await query(`SELECT COUNT(*) AS n FROM engagement_sends ${clause}`, params)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
module.exports = { record, countSentSince, list, count, TEST_SEND_TRIGGER }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user