Compare commits
55 Commits
e2dad3104f
...
feat/asset
| Author | SHA1 | Date | |
|---|---|---|---|
| 61dc692088 | |||
| baa4f7d5ba | |||
| 7d7840eb6b | |||
| b92b85c3a9 | |||
| 6dd4e5e3eb | |||
| af9f4e191c | |||
| e46842a28c | |||
| 2e9ed50e21 | |||
| eb167558e3 | |||
| 1667e636bd | |||
| 6e6c24065c | |||
| 8453762e3b | |||
| db8e01e868 | |||
| 37f4623068 | |||
| d0178c6419 | |||
| 809426ad73 | |||
| aba8d1e43a | |||
| 7d3d6d5abd | |||
| d4516739b4 | |||
| 82a50e5e04 | |||
| 4a91d74085 | |||
| fdc118166c | |||
| 57d183e921 | |||
| fd9fb50351 | |||
| 429e657239 | |||
| 4077c4e79e | |||
| 4ac917c3a3 | |||
| 9bc0bf5a3d | |||
| 9c23c5fd0e | |||
| 6e73660b52 | |||
| a481248bc0 | |||
| 7b570c8ea1 | |||
| 2ba397eff7 | |||
| 2e964cfeee | |||
| d88906e43c | |||
| 8e03497eb3 | |||
| 6331b36c45 | |||
| 5779d15150 | |||
| e59a68c152 | |||
| eec7dbf785 | |||
| 66bb3b9a3f | |||
| 52eac24d17 | |||
| c8d45733b6 | |||
| c3783f56f1 | |||
| 40ab1ce8d2 | |||
| 0a9149a04f | |||
| cfd1cb3c3c | |||
| 81e0338a69 | |||
| 1d4cd4adae | |||
| 49a61fdafa | |||
| c208543044 | |||
| 87c4e71025 | |||
| 24a3cd85b3 | |||
| 5168446c53 | |||
| 065bec7ad8 |
@@ -17,6 +17,9 @@ import FiveOnFriday from './routes/public/FiveOnFriday.jsx'
|
||||
import Newsletter from './routes/public/Newsletter.jsx'
|
||||
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
||||
import About from './routes/public/About.jsx'
|
||||
import Events from './routes/public/Events.jsx'
|
||||
import EventPage from './routes/public/EventPage.jsx'
|
||||
import EventSeries from './routes/public/EventSeries.jsx'
|
||||
import Status from './routes/public/Status.jsx'
|
||||
import Wiki from './routes/wiki/Wiki.jsx'
|
||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||
@@ -47,6 +50,13 @@ 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 EngagementRetention from './routes/admin/views/EngagementRetention.jsx'
|
||||
import EventsAdmin from './routes/admin/views/EventsAdmin.jsx'
|
||||
import EventsCalendar from './routes/admin/views/EventsCalendar.jsx'
|
||||
import EventEditor from './routes/admin/views/EventEditor.jsx'
|
||||
import EventRun from './routes/admin/views/EventRun.jsx'
|
||||
import EventActions from './routes/admin/views/EventActions.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'
|
||||
@@ -64,8 +74,10 @@ 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'
|
||||
import PlayerEvents from './routes/player/PlayerEvents.jsx'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -99,6 +111,17 @@ export default function App() {
|
||||
<Route path="/site/newsletter" element={<Newsletter />} />
|
||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||
<Route path="/site/about" element={<About />} />
|
||||
{/* Events (Phase 14a). `series/:slug` is declared before `:slug`
|
||||
although it could not be shadowed by it — two segments against
|
||||
one. It stays above because the ranking surprise this feature
|
||||
has already shipped once was exactly here: a static segment
|
||||
outranks a dynamic one whatever the source order, which is what
|
||||
made `/admin/events/new` unreachable from Phase 6 to Phase 13.
|
||||
Nothing static shares a segment with `:slug`, so nothing here
|
||||
repeats it. */}
|
||||
<Route path="/site/events" element={<Events />} />
|
||||
<Route path="/site/events/series/:slug" element={<EventSeries />} />
|
||||
<Route path="/site/events/:slug" element={<EventPage />} />
|
||||
<Route path="/site/status" element={<Status />} />
|
||||
<Route path="/wiki" element={<Wiki />} />
|
||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
@@ -189,6 +212,27 @@ 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 />} />
|
||||
{/* Events (EVENTS.md §I, Phase 3). Staff-wide, unlike Engagement:
|
||||
§K makes every read here `staff`, and the moderator's whole
|
||||
power over this feature is the run console — cancelling a run
|
||||
that is doing something wrong at 2am. The narrower gates are
|
||||
applied per action instead: authoring is admin+editor, publish
|
||||
and start are admin only (§N2), and each button follows the
|
||||
route it calls. `runs/:runId` is declared before `:id` so the
|
||||
literal segment is never read as a definition id. */}
|
||||
<Route path="events" element={<EventsAdmin />} />
|
||||
<Route path="events/calendar" element={<EventsCalendar />} />
|
||||
{/* The switchboard (Phase 6). A literal segment, declared before
|
||||
`events/:id` the way the router declares `/actions` before
|
||||
`/:id` — the same collision, on the other side of the wire. */}
|
||||
<Route path="events/actions" element={<EventActions />} />
|
||||
<Route path="events/runs/:runId" element={<EventRun />} />
|
||||
{/* ONE route, and `new` is a value of `:id` rather than a
|
||||
path beside it. A static `events/new` outranks the dynamic
|
||||
segment in React Router whatever the order, so the editor
|
||||
was handed no `id` at all and asked the API for
|
||||
`/admin/events/undefined`. */}
|
||||
<Route path="events/:id" element={<EventEditor />} />
|
||||
{/* 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
|
||||
@@ -207,8 +251,26 @@ export default function App() {
|
||||
<Route path="templates" element={<EngagementTemplates />} />
|
||||
<Route path="triggers" element={<EngagementTriggers />} />
|
||||
<Route path="sends" element={<EngagementSendLog />} />
|
||||
<Route path="suppressions" element={<EngagementSuppressions />} />
|
||||
<Route path="retention" element={<EngagementRetention />} />
|
||||
</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 />} />
|
||||
{/* And participation history, for the same reason and by the same
|
||||
arrangement (Phase 14a): `/player/events/history` is behind
|
||||
requireAuth alone, so a staff member has one — but
|
||||
`RequirePlayer` sends them out of `/account`. Declared BEFORE
|
||||
`events/:id`, though it need not be: a static segment outranks
|
||||
a dynamic one whatever the order, which is the rule that made
|
||||
`events/new` unreachable for seven phases. Written in the order
|
||||
it resolves. */}
|
||||
<Route path="events/mine" element={<PlayerEvents />} />
|
||||
{/* 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
|
||||
@@ -252,7 +314,19 @@ 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 />} />
|
||||
{/* Participation history (Phase 14a). Under /account rather than
|
||||
/player because it is role-agnostic self-service: staff are a
|
||||
superset of players and an admin reading their own attendance
|
||||
is as ordinary as anyone else doing it. */}
|
||||
<Route path="/account/events" element={<PlayerEvents />} />
|
||||
{/* 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
|
||||
|
||||
@@ -230,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 } }),
|
||||
@@ -237,6 +256,24 @@ export const api = {
|
||||
// their mail, not signed in. Always resolves 200 whatever the token was.
|
||||
unsubscribeTeam: (token) =>
|
||||
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
|
||||
// ----- Events (EVENTS.md § API surface, Phase 14a) -----
|
||||
//
|
||||
// The anonymous surface. `from`/`to` are optional — the server defaults to now
|
||||
// through a month out, so the calendar's first render need not compute a window
|
||||
// before it can ask for anything.
|
||||
publicEvents: ({ from, to, seriesId } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (from) qs.set('from', from)
|
||||
if (to) qs.set('to', to)
|
||||
if (seriesId) qs.set('seriesId', String(seriesId))
|
||||
return req(`/public/events${withQs(qs.toString())}`)
|
||||
},
|
||||
// `run` is what an announcement's link carries, so a mail about last Friday's
|
||||
// occurrence opens last Friday's results rather than next Friday's.
|
||||
publicEvent: (slug, run = null) =>
|
||||
req(`/public/events/${encodeURIComponent(slug)}${run ? `?run=${encodeURIComponent(run)}` : ''}`),
|
||||
publicEventSeries: (slug) => req(`/public/events/series/${encodeURIComponent(slug)}`),
|
||||
|
||||
wikiTags: () => req('/public/wiki/tags'),
|
||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
||||
@@ -415,6 +452,138 @@ export const api = {
|
||||
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.
|
||||
//
|
||||
// **Phase 14 added the second form, and it is the one the row uses.** The
|
||||
// list now returns each row's `address_hash`, so the Lift button on a row
|
||||
// needs no address at all — the operator is looking at a mask and has never
|
||||
// been told the address. `unsuppressAddress` stays for the address the
|
||||
// operator types, which is the only way to reach a row that is not on the
|
||||
// page in front of them.
|
||||
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 } }),
|
||||
unsuppressByHash: (hash, channel) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (channel) qs.set('channel', channel)
|
||||
return req(`/admin/engagement/suppressions/by-hash/${hash}${withQs(qs.toString())}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
|
||||
// Retention (Phase 14). Three horizons, one screen; `engagement_suppressions`
|
||||
// is not among them because a suppression does not expire.
|
||||
getEngagementRetention: () => req('/admin/engagement/retention'),
|
||||
setEngagementRetention: (body) =>
|
||||
req('/admin/engagement/retention', { method: 'PUT', body }),
|
||||
|
||||
// Events (docs/website/EVENTS.md, Phase 3). Reads are staff-wide; authoring
|
||||
// is admin+editor, publish and start are admin ONLY, and the six live
|
||||
// controls are admin+moderator — the one gate in this feature wider than
|
||||
// admin, because stopping a run at 2am is incident response and starting
|
||||
// one is not (§N2). The buttons follow the same split, and the server
|
||||
// re-checks every one of them.
|
||||
listEvents: (state) => req(`/admin/events${state ? `?state=${encodeURIComponent(state)}` : ''}`),
|
||||
getEvent: (id) => req(`/admin/events/${id}`),
|
||||
createEvent: (body) => req('/admin/events', { method: 'POST', body }),
|
||||
updateEvent: (id, body) => req(`/admin/events/${id}`, { method: 'PUT', body }),
|
||||
publishEvent: (id) => req(`/admin/events/${id}/publish`, { method: 'POST' }),
|
||||
archiveEvent: (id) => req(`/admin/events/${id}`, { method: 'DELETE' }),
|
||||
listEventVersions: (id) => req(`/admin/events/${id}/versions`),
|
||||
eventCatalog: () => req('/admin/events/catalog'),
|
||||
// Phase 7. The values behind a param's `source` — resolved by the module that
|
||||
// registered the source, on a request of its own rather than inside the
|
||||
// catalog, because a source can be slow or down and must not take the whole
|
||||
// editor with it. A refusal comes back 200 with `ok: false`, so this never
|
||||
// throws for the case the screen is meant to render: the field degrades to
|
||||
// free text with the reason beside it.
|
||||
// Phase 12b made a source SEARCHABLE and Phase 13 is what asks. `q` is
|
||||
// ignored, never refused, by a source that does not declare itself
|
||||
// searchable — so passing it is always safe and the field decides whether
|
||||
// it is a typeahead by reading `searchable` off the answer.
|
||||
eventOptions: (sourceId, q) => {
|
||||
const qs = q ? `?${new URLSearchParams({ q }).toString()}` : ''
|
||||
return req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}${qs}`)
|
||||
},
|
||||
// Phase 6. The dry run is admin+editor: it dispatches nothing, and the author
|
||||
// who wrote the definition is who should be able to price it against the caps
|
||||
// before asking an admin to publish it. A report with findings comes back 200
|
||||
// — the request succeeded, the plan has problems.
|
||||
verifyEvent: (id) => req(`/admin/events/${id}/verify`, { method: 'POST' }),
|
||||
// Phase 13's live cap meter, and NOT a lighter dry run — it dispatches
|
||||
// nothing, so it knows nothing a module knows. It takes the spec in the
|
||||
// body rather than an id because the plan it prices is the one in the
|
||||
// author's hands, which is unsaved between keystrokes, and it records
|
||||
// nothing, which is what makes it safe to call on a debounce.
|
||||
priceEvent: (body) => req('/admin/events/price', { method: 'POST', body }),
|
||||
// The switchboard, admin only in BOTH directions: reading which actions a
|
||||
// deployment permits is as much configuration as writing it (§K). One action
|
||||
// per write rather than the whole board, so an action that appeared between
|
||||
// the read and the write cannot be overwritten with a default.
|
||||
eventActions: () => req('/admin/events/actions'),
|
||||
saveEventAction: (body) => req('/admin/events/actions', { method: 'PUT', body }),
|
||||
eventSeries: () => req('/admin/events/series'),
|
||||
// Series writes are admin+editor rather than admin: naming an arc is
|
||||
// authoring, and §N2's narrow gate is about committing the deployment to a
|
||||
// run. The delete is a real delete and answers with how many definitions it
|
||||
// detached — `series_id` is ON DELETE SET NULL, so nothing is destroyed.
|
||||
createEventSeries: (body) => req('/admin/events/series', { method: 'POST', body }),
|
||||
updateEventSeries: (id, body) => req(`/admin/events/series/${id}`, { method: 'PUT', body }),
|
||||
deleteEventSeries: (id) => req(`/admin/events/series/${id}`, { method: 'DELETE' }),
|
||||
// The calendar. `from`/`to` are UTC instants the caller computes from the
|
||||
// month it is showing, in the READER's zone — the server never guesses it.
|
||||
// A `status` or `scope` filter suppresses projections, which is why the
|
||||
// month view sends neither.
|
||||
eventCalendar: ({ from, to, status, scope, seriesId } = {}) => {
|
||||
const qs = new URLSearchParams({ from, to })
|
||||
if (status) qs.set('status', status)
|
||||
if (scope) qs.set('scope', scope)
|
||||
if (seriesId) qs.set('seriesId', String(seriesId))
|
||||
return req(`/admin/events/calendar?${qs.toString()}`)
|
||||
},
|
||||
startEventRun: (id, body) => req(`/admin/events/${id}/runs`, { method: 'POST', body }),
|
||||
listEventRuns: ({ definitionId, status, limit } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (definitionId) qs.set('definitionId', String(definitionId))
|
||||
if (status) qs.set('status', status)
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
const suffix = qs.toString()
|
||||
return req(`/admin/events/runs${suffix ? `?${suffix}` : ''}`)
|
||||
},
|
||||
getEventRun: (runId) => req(`/admin/events/runs/${runId}`),
|
||||
getEventRunLog: (runId, limit) =>
|
||||
req(`/admin/events/runs/${runId}/log${limit ? `?limit=${Number(limit)}` : ''}`),
|
||||
pauseEventRun: (runId, reason) =>
|
||||
req(`/admin/events/runs/${runId}/pause`, { method: 'POST', body: { reason } }),
|
||||
resumeEventRun: (runId) => req(`/admin/events/runs/${runId}/resume`, { method: 'POST' }),
|
||||
// `cleanup` defaults to true server-side and has to be asked out of: EVENTS.md
|
||||
// §L makes cancelling WITHOUT cleanup the separate, admin-only, logged action,
|
||||
// so an absent flag means "give back what this run took".
|
||||
cancelEventRun: (runId, reason, cleanup = true) =>
|
||||
req(`/admin/events/runs/${runId}/cancel`, { method: 'POST', body: { reason, cleanup } }),
|
||||
cleanupEventRun: (runId) => req(`/admin/events/runs/${runId}/cleanup`, { method: 'POST' }),
|
||||
advanceEventRun: (runId, reason) =>
|
||||
req(`/admin/events/runs/${runId}/advance`, { method: 'POST', body: { reason } }),
|
||||
confirmEventStep: (runId, stepId, note) =>
|
||||
req(`/admin/events/runs/${runId}/steps/${stepId}/confirm`, { method: 'POST', body: { note } }),
|
||||
skipEventStep: (runId, stepId, reason) =>
|
||||
req(`/admin/events/runs/${runId}/steps/${stepId}/skip`, { method: 'POST', body: { reason } }),
|
||||
retryEventStep: (runId, stepId) =>
|
||||
req(`/admin/events/runs/${runId}/steps/${stepId}/retry`, { method: 'POST' }),
|
||||
|
||||
// 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`.
|
||||
@@ -557,6 +726,18 @@ export const api = {
|
||||
getEligibleAppeals: () => req('/player/appeals/eligible'),
|
||||
submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }),
|
||||
withdrawAppeal: (id) => req(`/player/appeals/${id}/withdraw`, { method: 'POST' }),
|
||||
|
||||
// ----- event participation (Phase 14a) -----
|
||||
//
|
||||
// Self-scoped on the session and nothing else — there is no id to pass.
|
||||
// `before` is a keyset cursor (the last entry's `id`), not an offset: the
|
||||
// list gains a row every time the reader attends something.
|
||||
eventHistory: ({ limit, before } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
if (before) qs.set('before', String(before))
|
||||
return req(`/player/events/history${withQs(qs.toString())}`)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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'
|
||||
@@ -28,6 +29,7 @@ import { useFeatureGate } from '../modules/features.jsx'
|
||||
export const NAV = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'Events', to: '/site/events' },
|
||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||
@@ -107,6 +109,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}
|
||||
|
||||
817
client/src/lib/eventAuthoring.js
Normal file
817
client/src/lib/eventAuthoring.js
Normal file
@@ -0,0 +1,817 @@
|
||||
// ── What the three Events screens say, and what they let staff press ───────
|
||||
//
|
||||
// EVENTS.md §I. None of this is a boundary. `events/spec.js` on the server
|
||||
// decides what may be saved, and the six control statements decide what may
|
||||
// happen to a run — every one of them is a compare-and-set that re-checks the
|
||||
// status this file only *predicted*. What is here is the part that would be
|
||||
// wrong silently: a form that drops an authored step, a params box that posts a
|
||||
// string where the action declared an int, and above all a console that offers a
|
||||
// button the server is going to refuse.
|
||||
//
|
||||
// **The controls are modelled here rather than inline in the console for one
|
||||
// reason: they can be tested against the server's rules.** A button that 409s is
|
||||
// not a bug the way a wrong write is, but it is the failure mode an operator
|
||||
// meets at 2am while the thing they are trying to stop keeps running — so the
|
||||
// guards are written twice on purpose and the copy is checked.
|
||||
|
||||
// **The condition builder is borrowed, not rebuilt.** §I says the step editor
|
||||
// reuses "the condition builder, exactly" — and a phase's advance gate is
|
||||
// literally the engagement grammar, validated on the server by
|
||||
// `engagement/conditions.js`. Importing the row helpers is what keeps this screen
|
||||
// from becoming a second opinion about a grammar core owns.
|
||||
import { conditionRowsFrom, conditionsFromRows, coerceLiteral } from './engagementRules.js'
|
||||
|
||||
// A run that is over. Verbatim `eventRuns.db`'s TERMINAL.
|
||||
export const TERMINAL_RUN_STATUSES = ['completed', 'cancelled', 'failed', 'missed']
|
||||
|
||||
export const isTerminalRun = (status) => TERMINAL_RUN_STATUSES.includes(status)
|
||||
|
||||
/** A step waiting on a human: `running`, with nothing holding it. */
|
||||
export const isParked = (step) => Boolean(step && step.status === 'running' && step.parked)
|
||||
|
||||
/**
|
||||
* The highest `seq` of a step in this phase that is not still `pending` — the
|
||||
* furthest the phase has got — or null when none of it has been attempted.
|
||||
*
|
||||
* The same rule as the server's `lastStartedSeq`, over the step list the console
|
||||
* already has, and used only to decide whether to OFFER retry. The near miss is
|
||||
* worth keeping in view: "the lowest step that is not finished" looks like the
|
||||
* same thing and is not, because the runner steps OVER a failed step. Under that
|
||||
* rule a phase that carried on past an `on_failure: skip` failure and then paused
|
||||
* at a later one would offer retry on the wrong step.
|
||||
*/
|
||||
export function lastStartedSeqOf(steps, phase) {
|
||||
const started = (steps || [])
|
||||
.filter((s) => s.phase === phase && s.status !== 'pending')
|
||||
.map((s) => Number(s.seq))
|
||||
return started.length ? Math.max(...started) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Which run-level controls to offer.
|
||||
*
|
||||
* `pause` is `starting`/`running` only: a `scheduled` occurrence that should not
|
||||
* happen is cancelled, not paused. `cancel` is everything non-terminal — "this
|
||||
* is not happening" is a decision made before a run starts as often as during
|
||||
* one.
|
||||
*
|
||||
* **`advance` is offered only when the phase is genuinely waiting on its gate**,
|
||||
* which is the same test the server makes and is stated here in the same words
|
||||
* on purpose: this decides what is *offered*, the server decides what is
|
||||
* *allowed*, and a button that is present and always refused is the "control
|
||||
* that answers 409 and does nothing" this feature has refused twice. The gate
|
||||
* must be open-and-unsatisfied AND no step of the phase may still be pending or
|
||||
* running — a phase held by a step is held by the step, and skip is its control.
|
||||
*/
|
||||
export function runControlsFor(run, gates = [], steps = []) {
|
||||
if (!run) return { pause: false, resume: false, cancel: false, advance: false }
|
||||
const terminal = isTerminalRun(run.status)
|
||||
const gate = (gates || []).find((g) => g.phase === run.currentPhase)
|
||||
const stepOpen = (steps || []).some(
|
||||
(s) => s.phase === run.currentPhase && ['pending', 'running'].includes(s.status),
|
||||
)
|
||||
return {
|
||||
pause: ['starting', 'running'].includes(run.status),
|
||||
resume: run.status === 'paused',
|
||||
cancel: !terminal,
|
||||
advance: run.status === 'running' && Boolean(gate) && !gate.satisfied && !stepOpen,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which step-level controls to offer, for one step of one run.
|
||||
*
|
||||
* `retry` carries the guard worth restating: only while the run is PAUSED, only
|
||||
* on a `failed` step of the phase the run is currently in, and only when that
|
||||
* step is the furthest one the phase has reached. A failed step under an
|
||||
* `on_failure` of `skip` is one the run has already moved past, and re-queueing
|
||||
* it would put a pending row behind the runner's cursor, where it would sit for
|
||||
* ever.
|
||||
*/
|
||||
export function stepControlsFor(run, step, steps) {
|
||||
const none = { confirm: false, skip: false, retry: false }
|
||||
if (!run || !step) return none
|
||||
if (isTerminalRun(run.status)) return none
|
||||
|
||||
const parked = isParked(step)
|
||||
const furthest = step.phase === run.currentPhase ? lastStartedSeqOf(steps, step.phase) : null
|
||||
|
||||
return {
|
||||
confirm: parked,
|
||||
skip: parked || step.status === 'pending',
|
||||
retry:
|
||||
run.status === 'paused' &&
|
||||
step.status === 'failed' &&
|
||||
step.phase === run.currentPhase &&
|
||||
furthest !== null &&
|
||||
Number(furthest) === Number(step.seq),
|
||||
}
|
||||
}
|
||||
|
||||
// ── The definition form ────────────────────────────────────────────────────
|
||||
|
||||
export const BLANK_PHASE_KEY = 'phase'
|
||||
|
||||
const nextPhaseKey = (phases) => {
|
||||
const used = new Set((phases || []).map((p) => p.key))
|
||||
for (let n = 1; n < 100; n++) {
|
||||
const key = n === 1 ? BLANK_PHASE_KEY : `${BLANK_PHASE_KEY}-${n}`
|
||||
if (!used.has(key)) return key
|
||||
}
|
||||
return `${BLANK_PHASE_KEY}-${Date.now()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A new step, with its params PREFILLED from the action's declared examples.
|
||||
*
|
||||
* Every param carries a required `example` — that requirement is the reason this
|
||||
* works — so a fresh `core.announce` step arrives with the right keys and
|
||||
* plausible values rather than empty. Phase 13 turned the box into a form and
|
||||
* this stayed exactly as it was: a form whose fields start at the declared
|
||||
* example is a step an author edits rather than one they compose.
|
||||
*/
|
||||
export function blankStep(action) {
|
||||
const params = {}
|
||||
for (const p of action?.params || []) {
|
||||
if (p.required || p.example !== undefined) params[p.name] = p.example
|
||||
}
|
||||
return {
|
||||
actionId: action?.id || '',
|
||||
label: action?.label || '',
|
||||
onFailure: '',
|
||||
paramsText: JSON.stringify(params, null, 2),
|
||||
}
|
||||
}
|
||||
|
||||
export function blankPhase(phases) {
|
||||
return { key: nextPhaseKey(phases), label: 'New phase', steps: [], advance: blankAdvance() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The advance gate as the FORM holds it (Phase 5) — three fields that are
|
||||
* always present and mostly empty, rather than a discriminated union the form
|
||||
* has to rebuild every time the dropdown moves.
|
||||
*
|
||||
* `kind: ''` is "no condition", which is what nearly every phase is and what
|
||||
* every phase was before this. The form keeps a half-typed `on` gate's trigger
|
||||
* while the author looks at `after`, because a dropdown that discards what was
|
||||
* typed under the other option is one an operator learns to be afraid of.
|
||||
*/
|
||||
export function blankAdvance() {
|
||||
return { kind: '', after: '30m', on: '', count: 1, ...blankWhere() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The `where` predicate as the BUILDER holds it (Phase 13).
|
||||
*
|
||||
* `whereText` survives beside the rows and is not vestigial: it is what a
|
||||
* predicate the builder cannot render is shown as, and what is posted for one.
|
||||
* See `whereFormFrom`.
|
||||
*/
|
||||
export function blankWhere() {
|
||||
return { whereOp: 'and', whereRows: [], whereEditable: true, whereText: '' }
|
||||
}
|
||||
|
||||
export const ADVANCE_KINDS = [
|
||||
{ value: '', label: 'When its steps are done' },
|
||||
{ value: 'after', label: 'After a fixed delay' },
|
||||
{ value: 'on', label: 'When something happens in the game' },
|
||||
]
|
||||
|
||||
/** The stored gate, as the form's fields. */
|
||||
export function advanceFormFrom(advance) {
|
||||
const blank = blankAdvance()
|
||||
if (!advance) return blank
|
||||
if (advance.after !== undefined) return { ...blank, kind: 'after', after: advance.after }
|
||||
return {
|
||||
...blank,
|
||||
kind: 'on',
|
||||
on: advance.on || '',
|
||||
count: advance.count ?? 1,
|
||||
...whereFormFrom(advance.where),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored `where` tree → the builder's flat rows (Phase 13).
|
||||
*
|
||||
* **This is `conditionRowsFrom` and it is deliberately the same function**, not a
|
||||
* second one shaped like it. The grammar behind a phase gate is the engagement
|
||||
* condition grammar — the server validates it with `engagement/conditions.js`
|
||||
* and renders the diagnosis panel's sentence with the same labels — so an editor
|
||||
* here that re-decided what a tree looks like would be the second implementation
|
||||
* §I refuses on the read side for exactly this reason.
|
||||
*
|
||||
* A tree the flat editor cannot hold (`A and (B or C)`) comes back
|
||||
* `whereEditable: false` and is SHOWN as its JSON rather than silently
|
||||
* flattened: `A and B and C` fires on different events, and an author would have
|
||||
* no way to know the save had done it to them.
|
||||
*/
|
||||
export function whereFormFrom(where) {
|
||||
const blank = blankWhere()
|
||||
if (!where) return blank
|
||||
const rows = conditionRowsFrom(where)
|
||||
return {
|
||||
whereOp: rows.op,
|
||||
whereRows: rows.rows,
|
||||
whereEditable: rows.editable,
|
||||
whereText: JSON.stringify(where, null, 2),
|
||||
}
|
||||
}
|
||||
|
||||
/** The editor's working state, from what `GET /admin/events/:id` returned. */
|
||||
export function formFromDefinition(event) {
|
||||
const spec = event?.spec || {}
|
||||
return {
|
||||
title: event?.title || '',
|
||||
summary: event?.summary || '',
|
||||
body: event?.body || '',
|
||||
imageUrl: event?.imageUrl || '',
|
||||
seriesId: event?.seriesId ? String(event.seriesId) : '',
|
||||
seriesOrder: event?.seriesOrder ?? 0,
|
||||
concurrencyKey: event?.concurrencyKey || '',
|
||||
graceSeconds: event?.graceSeconds ?? 900,
|
||||
timezone: event?.timezone || 'UTC',
|
||||
// Whether the public calendar announces it (Phase 14a). `?? true` rather
|
||||
// than `|| true`: a definition an operator has deliberately unlisted sends
|
||||
// `false`, and `||` would quietly re-list it on the next save.
|
||||
listed: event?.listed ?? true,
|
||||
// Whether the public calendar announces it (Phase 14a). `?? true` rather
|
||||
// than `|| true`: a definition an operator has deliberately unlisted sends
|
||||
// `false`, and `||` would quietly re-list it on the next save.
|
||||
listed: event?.listed ?? true,
|
||||
...scheduleFormFrom(spec.schedule),
|
||||
phases: (spec.phases || []).map((p) => ({
|
||||
key: p.key || '',
|
||||
label: p.label || '',
|
||||
advance: advanceFormFrom(p.advance),
|
||||
steps: (p.steps || []).map((s) => ({
|
||||
actionId: s.actionId || '',
|
||||
label: s.label || '',
|
||||
onFailure: s.onFailure || '',
|
||||
dormant: Boolean(s.dormant),
|
||||
actionVersion: s.actionVersion,
|
||||
paramsText: JSON.stringify(s.params || {}, null, 2),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One phase's advance gate, as the spec shape — or null when it has none.
|
||||
*
|
||||
* **Whether the predicate is VALID is still the server's answer.** The builder
|
||||
* coerces each literal to the type the trigger DECLARED — which is not a second
|
||||
* validator but the thing that makes the first one's error useful: every value
|
||||
* in an HTML input is a string, and `{ cmp: 'gt', value: \"5\" }` against an `int`
|
||||
* variable is refused by `engagement/conditions.js`, rightly, at which point the
|
||||
* author is reading an error about JSON rather than about what they typed.
|
||||
*
|
||||
* A predicate the builder could not render round-trips through `whereText`
|
||||
* unchanged. That is the point of keeping the text: the alternative to posting it
|
||||
* back verbatim is dropping an author's tree because this screen could not draw
|
||||
* it.
|
||||
*/
|
||||
export function advancePayload(advance, where, errors, variables = []) {
|
||||
if (!advance || !advance.kind) return null
|
||||
if (advance.kind === 'after') return { after: advance.after }
|
||||
|
||||
const out = { on: advance.on, count: Number(advance.count) || 1 }
|
||||
if (advance.whereEditable === false) {
|
||||
const text = String(advance.whereText || '').trim()
|
||||
if (text) {
|
||||
try {
|
||||
out.where = JSON.parse(text)
|
||||
} catch (err) {
|
||||
errors.push(`${where}, advance condition: ${err.message}`)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const built = conditionsFromRows(advance.whereOp || 'and', advance.whereRows || [], variables)
|
||||
if (built) out.where = built
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The form, as a request body — or the list of everything wrong with it.
|
||||
*
|
||||
* Only the JSON parse is checked here, and only because a params box whose text
|
||||
* is not JSON cannot be turned into a request at all. **Everything else is left
|
||||
* to the server**: unknown params, wrong types, missing required ones, bad phase
|
||||
* keys and duplicate keys all come back from `POST`/`PUT` as a list, and
|
||||
* re-deciding any of them here would be a second validator drifting from the one
|
||||
* that matters.
|
||||
*
|
||||
* `onFailure` is omitted when the author has not chosen one, so the server
|
||||
* applies the action's risk-class default rather than being told a value the
|
||||
* form invented.
|
||||
*/
|
||||
export function payloadFromForm(form, { triggersById = new Map() } = {}) {
|
||||
const errors = []
|
||||
const phases = (form.phases || []).map((phase, pi) => {
|
||||
const where = advancePayload(
|
||||
phase.advance,
|
||||
`Phase ${pi + 1} "${phase.label || phase.key}"`,
|
||||
errors,
|
||||
// The declared types the builder coerces against. A trigger nothing
|
||||
// registers has none, and every literal then stays the string it was typed
|
||||
// as — which is right: the gate is dormant, the server carries its `where`
|
||||
// through unvalidated, and inventing types for it here would edit a
|
||||
// predicate nobody can currently check.
|
||||
triggersById.get(phase.advance?.on)?.variables || [],
|
||||
)
|
||||
return {
|
||||
key: phase.key,
|
||||
label: phase.label,
|
||||
// Omitted rather than sent as null when there is no gate, which is what
|
||||
// `events/spec.js` stores for the same reason: a spec full of
|
||||
// `"advance": null` makes the first phase to gain one look like an edit to
|
||||
// every phase in the version diff.
|
||||
...(where ? { advance: where } : {}),
|
||||
steps: (phase.steps || []).map((step, si) => {
|
||||
const out = { actionId: step.actionId }
|
||||
if (step.label) out.label = step.label
|
||||
if (step.onFailure) out.onFailure = step.onFailure
|
||||
const parsed = parseParams(step.paramsText)
|
||||
if (parsed.error) {
|
||||
errors.push(`Phase ${pi + 1} "${phase.label || phase.key}", step ${si + 1}: ${parsed.error}`)
|
||||
} else {
|
||||
out.params = parsed.params
|
||||
}
|
||||
return out
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
payload: {
|
||||
title: form.title,
|
||||
summary: form.summary || null,
|
||||
body: form.body || null,
|
||||
imageUrl: form.imageUrl || null,
|
||||
seriesId: form.seriesId ? Number(form.seriesId) : null,
|
||||
seriesOrder: Number(form.seriesOrder) || 0,
|
||||
concurrencyKey: form.concurrencyKey || null,
|
||||
graceSeconds: Number(form.graceSeconds),
|
||||
timezone: form.timezone,
|
||||
listed: Boolean(form.listed),
|
||||
listed: Boolean(form.listed),
|
||||
spec: { schedule: scheduleFromForm(form), phases },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── The schedule (Phase 4) ─────────────────────────────────────────────────
|
||||
//
|
||||
// The four closed shapes of §E, mirrored so the form can render one and the
|
||||
// preview can describe it. `events/spec.js` and `events/recurrence.js` remain
|
||||
// the deciders — this is what makes the form a form rather than a text box, and
|
||||
// it is the whole reason the schedule is not a cron string: a closed set has a
|
||||
// dropdown, and an operator can proofread a dropdown.
|
||||
|
||||
export const WEEKDAYS = [
|
||||
'sunday',
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
]
|
||||
|
||||
export const SCHEDULE_KINDS = [
|
||||
{ value: 'manual', label: 'Started by hand' },
|
||||
{ value: 'once', label: 'Once, at a set time' },
|
||||
{ value: 'weekly', label: 'Weekly, on chosen days' },
|
||||
{ value: 'monthly', label: 'Monthly, on the nth weekday' },
|
||||
]
|
||||
|
||||
// 1..4 and "last". There is no fifth: every month has a first through fourth of
|
||||
// every weekday, and "last" is what a month with five Fridays makes different
|
||||
// from "fourth" (org lead, 2026-09-02).
|
||||
export const MONTHLY_NTHS = [
|
||||
{ value: 1, label: 'First' },
|
||||
{ value: 2, label: 'Second' },
|
||||
{ value: 3, label: 'Third' },
|
||||
{ value: 4, label: 'Fourth' },
|
||||
{ value: -1, label: 'Last' },
|
||||
]
|
||||
|
||||
const capitalise = (s) => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1)
|
||||
|
||||
/**
|
||||
* A schedule in words, in the event's own zone.
|
||||
*
|
||||
* The server says the same thing in `events/recurrence.js#describe`, and the two
|
||||
* are allowed to differ on wording but not on meaning — this one is what an
|
||||
* author reads while they are still typing, before anything has been saved.
|
||||
*/
|
||||
export function describeSchedule(schedule, timezone = 'UTC') {
|
||||
if (!schedule || typeof schedule !== 'object') return 'No schedule'
|
||||
const nth = MONTHLY_NTHS.find((n) => n.value === Number(schedule.nth))
|
||||
switch (schedule.kind) {
|
||||
case 'manual':
|
||||
return 'Started by hand — nothing happens until an admin presses Start'
|
||||
case 'once': {
|
||||
if (!schedule.at) return 'Once — no date chosen yet'
|
||||
return `Once, on ${String(schedule.at).replace('T', ' at ')} (${timezone})`
|
||||
}
|
||||
case 'weekly': {
|
||||
const days = (schedule.days || []).map(capitalise)
|
||||
if (!days.length || !schedule.time) return 'Weekly — choose days and a time'
|
||||
const list =
|
||||
days.length === 1
|
||||
? days[0]
|
||||
: `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}`
|
||||
return `Every ${list} at ${schedule.time} (${timezone})`
|
||||
}
|
||||
case 'monthly': {
|
||||
if (!nth || !schedule.weekday || !schedule.time) {
|
||||
return 'Monthly — choose a week, a weekday and a time'
|
||||
}
|
||||
return `The ${nth.label.toLowerCase()} ${capitalise(schedule.weekday)} of every month at ${schedule.time} (${timezone})`
|
||||
}
|
||||
default:
|
||||
return 'No schedule'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedule half of the editor's working state.
|
||||
*
|
||||
* Every shape's fields are kept side by side rather than cleared when the kind
|
||||
* changes, so an author who clicks Weekly, then Monthly, then back has not lost
|
||||
* the days they picked. `scheduleFromForm` reads only the fields the chosen kind
|
||||
* uses, which is what keeps the request body a clean single shape.
|
||||
*/
|
||||
export function scheduleFormFrom(schedule) {
|
||||
const s = schedule || {}
|
||||
return {
|
||||
scheduleKind: s.kind || 'manual',
|
||||
scheduleAt: s.kind === 'once' ? s.at || '' : '',
|
||||
scheduleDays: s.kind === 'weekly' ? s.days || [] : [],
|
||||
scheduleNth: s.kind === 'monthly' ? String(s.nth) : '1',
|
||||
scheduleWeekday: s.kind === 'monthly' ? s.weekday || 'friday' : 'friday',
|
||||
scheduleTime: s.kind === 'weekly' || s.kind === 'monthly' ? s.time || '20:00' : '20:00',
|
||||
}
|
||||
}
|
||||
|
||||
/** The schedule the form describes, as the spec object the server expects. */
|
||||
export function scheduleFromForm(form) {
|
||||
switch (form.scheduleKind) {
|
||||
case 'once':
|
||||
return { kind: 'once', at: form.scheduleAt }
|
||||
case 'weekly':
|
||||
return { kind: 'weekly', days: form.scheduleDays || [], time: form.scheduleTime }
|
||||
case 'monthly':
|
||||
return {
|
||||
kind: 'monthly',
|
||||
nth: Number(form.scheduleNth),
|
||||
weekday: form.scheduleWeekday,
|
||||
time: form.scheduleTime,
|
||||
}
|
||||
default:
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a calendar entry is, and therefore what may be done with it.
|
||||
*
|
||||
* A `run` is a row: it has a console and somebody can cancel it. A `projected`
|
||||
* entry is arithmetic the runner has not reached yet — there is nothing to open
|
||||
* and nothing to stop, and an operator who treats one as a booking has been
|
||||
* misled by the UI rather than by the server.
|
||||
*/
|
||||
export const isProjected = (entry) => entry?.kind === 'projected'
|
||||
|
||||
/** An empty box is `{}`, not a parse error — a step may legitimately take none. */
|
||||
export function parseParams(text) {
|
||||
const raw = (text || '').trim()
|
||||
if (!raw) return { params: {} }
|
||||
let value
|
||||
try {
|
||||
value = JSON.parse(raw)
|
||||
} catch (err) {
|
||||
return { error: `the params are not valid JSON (${err.message})` }
|
||||
}
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { error: 'the params must be a JSON object' }
|
||||
}
|
||||
return { params: value }
|
||||
}
|
||||
|
||||
// ── Step params, as a form (Phase 13) ─────────────────────────────
|
||||
//
|
||||
// §I: the step editor is *"the condition builder, exactly — core serves a
|
||||
// catalog, the module declared the schema, core renders a form it does not
|
||||
// understand"*. Phase 3 shipped the raw JSON box as an explicit placeholder for
|
||||
// this, and everything the form needs was already in the catalog: a param's
|
||||
// name, type, whether it is required, its description, its example, and the
|
||||
// option source behind it.
|
||||
//
|
||||
// **The JSON stays as the storage and as the escape hatch, and both halves of
|
||||
// that matter.** As storage, because `payloadFromForm` already builds a request
|
||||
// out of it and a second representation would be two things to keep in step. As
|
||||
// an escape hatch, because a form can only render what the declaration
|
||||
// describes — and a step may legitimately hold something it does not.
|
||||
//
|
||||
// The rule for when the form gives way is the CONDITION BUILDER'S rule, which is
|
||||
// the reason this reads as a port of it rather than as a new idea: a value the
|
||||
// editor cannot round-trip is SHOWN rather than silently rewritten. Flattening
|
||||
// `A and (B or C)` there and dropping an undeclared param here are the same
|
||||
// mistake — a save that looks clean and means something else.
|
||||
|
||||
/** The two ways a step's params are edited. */
|
||||
export const PARAM_FORM = 'form'
|
||||
export const PARAM_JSON = 'json'
|
||||
|
||||
/**
|
||||
* Can this step's params be rendered as a form without losing anything?
|
||||
*
|
||||
* `{ ok: true }`, or `{ ok: false, reason }` naming what the form cannot hold.
|
||||
* Three things make one, and none of them is an error — each is a step that has
|
||||
* to be edited as JSON:
|
||||
*
|
||||
* • **the action is dormant.** There is no declaration, so there are no fields.
|
||||
* A form here would render nothing and look like a step with no params.
|
||||
* • **a param the action does not declare.** The save refuses it by name, which
|
||||
* is what the author needs to see — and a form that dropped it would post a
|
||||
* step that saves cleanly having deleted something they typed.
|
||||
* • **a value no single control can hold** — an object or an array against a
|
||||
* scalar declaration.
|
||||
*/
|
||||
export function paramsRenderable(action, params) {
|
||||
if (!action) return { ok: false, reason: 'the module that registered this action is not installed' }
|
||||
const declared = new Map((action.params || []).map((p) => [p.name, p]))
|
||||
for (const [name, value] of Object.entries(params || {})) {
|
||||
if (!declared.has(name)) {
|
||||
return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` }
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return { ok: false, reason: `"${name}" holds a ${Array.isArray(value) ? 'list' : 'structure'}, which no single field can hold` }
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Which mode should this step open in?
|
||||
*
|
||||
* The author's own choice wins whenever the form COULD render the step — an
|
||||
* author who switched to JSON stays in JSON. What they cannot do is stay in a
|
||||
* form that would lose something, so an unrenderable step is forced to JSON
|
||||
* whatever the choice was, and the reason is returned so the screen can say it.
|
||||
*/
|
||||
export function paramsMode(step, action) {
|
||||
const parsed = parseParams(step?.paramsText)
|
||||
if (parsed.error) return { mode: PARAM_JSON, forced: true, reason: parsed.error }
|
||||
const renderable = paramsRenderable(action, parsed.params)
|
||||
if (!renderable.ok) return { mode: PARAM_JSON, forced: true, reason: renderable.reason }
|
||||
return { mode: step?.paramsMode === PARAM_JSON ? PARAM_JSON : PARAM_FORM, forced: false, reason: null }
|
||||
}
|
||||
|
||||
/** One declared param's current value, as the control holds it. */
|
||||
export function paramValue(step, name) {
|
||||
const parsed = parseParams(step?.paramsText)
|
||||
if (parsed.error) return undefined
|
||||
return parsed.params[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one param, and give back the whole box.
|
||||
*
|
||||
* **An empty field REMOVES the key rather than posting an empty string**, and
|
||||
* that is the server's own reading rather than a convenience: `checkParams`
|
||||
* treats `undefined`, `null` and `''` alike — absent — so a required param left
|
||||
* blank comes back as *"is required"*, which is the error the author needs,
|
||||
* instead of as a type complaint about `""`.
|
||||
*
|
||||
* **A value that does not parse is passed through as typed.** `coerceLiteral` is
|
||||
* the engagement builder's, unchanged, and its rule is the one that matters
|
||||
* here too: half of `-` is not a number, and turning it into `NaN` or `0` while
|
||||
* somebody is still typing would either post a value they never wrote or make
|
||||
* the field impossible to type a negative into. The server's type check then
|
||||
* names the param.
|
||||
*
|
||||
* Re-serialising the whole object rather than splicing text, for `pickParam`'s
|
||||
* reason: a string edit that produced valid-looking JSON with a duplicate key
|
||||
* would be a value the editor and the server read differently.
|
||||
*/
|
||||
export function setParam(step, name, raw, type) {
|
||||
const parsed = parseParams(step?.paramsText)
|
||||
if (parsed.error) return step?.paramsText || '{}'
|
||||
const next = { ...parsed.params }
|
||||
if (raw === '' || raw === undefined || raw === null) delete next[name]
|
||||
else next[name] = coerceLiteral(type, raw)
|
||||
return JSON.stringify(next, null, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored `datetime` as a `datetime-local` input wants it, and back.
|
||||
*
|
||||
* The server normalises a datetime param to an ISO string (`conditions.js`
|
||||
* `checkLiteral`), and the input needs `YYYY-MM-DDTHH:mm` with no zone. The
|
||||
* slice is the whole conversion in one direction; in the other the input's own
|
||||
* text is a moment `new Date()` parses, so it is posted as typed and the server
|
||||
* does the normalising — one implementation of what a datetime is, and it is
|
||||
* not this one.
|
||||
*/
|
||||
export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '')
|
||||
|
||||
/**
|
||||
* Everything the meter needs out of the form, and nothing else.
|
||||
*
|
||||
* The price route takes a spec, not a definition: no title, no schedule, no
|
||||
* series. Sending the whole payload would put a document in front of a route
|
||||
* that reads two fields of it — and would fail the moment the rest of the form
|
||||
* is mid-edit, which is exactly when the meter is being read.
|
||||
*
|
||||
* A step whose params do not parse is sent with none rather than dropped, so a
|
||||
* half-typed JSON box costs its own step's draw and not the phase's.
|
||||
*/
|
||||
export function priceBodyFrom(form) {
|
||||
return {
|
||||
phases: (form?.phases || []).map((phase) => ({
|
||||
key: phase.key || null,
|
||||
steps: (phase.steps || []).map((step) => ({
|
||||
actionId: step.actionId || '',
|
||||
params: parseParams(step.paramsText).params || {},
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this plan worth pricing at all?
|
||||
*
|
||||
* A meter that fires on an empty form asks the server what nothing costs, on
|
||||
* every keystroke of the title field. One step with an action chosen is the
|
||||
* threshold, because that is the first moment there is an answer.
|
||||
*/
|
||||
export const worthPricing = (form) =>
|
||||
(form?.phases || []).some((p) => (p.steps || []).some((s) => s.actionId))
|
||||
|
||||
// ── Rendering what happened ────────────────────────────────────────────────
|
||||
|
||||
const STATUS_WORDS = {
|
||||
scheduled: 'Scheduled',
|
||||
starting: 'Starting',
|
||||
running: 'Running',
|
||||
paused: 'Paused',
|
||||
ending: 'Winding down',
|
||||
completed: 'Completed',
|
||||
cancelled: 'Cancelled',
|
||||
failed: 'Failed',
|
||||
missed: 'Missed',
|
||||
}
|
||||
|
||||
export const runStatusWord = (status) => STATUS_WORDS[status] || status || 'unknown'
|
||||
|
||||
const KIND_WORDS = {
|
||||
'run.created': 'Occurrence created',
|
||||
'run.status': 'Run status',
|
||||
'run.health': 'Health',
|
||||
'run.blocked': 'Held off',
|
||||
'phase.entered': 'Phase entered',
|
||||
'phase.completed': 'Phase completed',
|
||||
'step.status': 'Step',
|
||||
'step.retry': 'Step retried',
|
||||
'step.parked': 'Waiting on a human',
|
||||
'phase.gate': 'Advance condition set',
|
||||
'condition.evaluated': 'Condition evaluated',
|
||||
'phase.advanced': 'Phase advanced',
|
||||
// Phase 6. "Refused" reads differently from "Step" on purpose: an operator
|
||||
// scanning a stopped run needs to see that nothing is broken.
|
||||
'step.refused': 'Refused',
|
||||
'run.budget': 'Caps',
|
||||
'version.verified': 'Dry run passed',
|
||||
// Phase 15. "Reported" rather than "Detail": the line is the module talking
|
||||
// about its own verb, and every other word here names something core did.
|
||||
'step.detail': 'Step reported',
|
||||
note: 'Note',
|
||||
}
|
||||
|
||||
// How deep and how long a module's own `detail` value is allowed to render.
|
||||
// The dispatcher already caps the whole object at 4KB, so this is about a line
|
||||
// staying a line — an operator scanning a run's log should not have one row
|
||||
// wrap eight times because a module answered with an array of forty names.
|
||||
const DETAIL_LIST_SHOWN = 5
|
||||
const DETAIL_TEXT_MAX = 80
|
||||
|
||||
/**
|
||||
* One value out of a module's `detail`, as text.
|
||||
*
|
||||
* **Core does not interpret these keys and neither does this.** A module wrote
|
||||
* the object; the console shows it. That is the whole reason the renderer is
|
||||
* generic rather than a switch — a switch would be core learning a module's
|
||||
* vocabulary, which is the thing the module system exists to prevent.
|
||||
*/
|
||||
function detailValue(value) {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (Array.isArray(value)) {
|
||||
const shown = value.slice(0, DETAIL_LIST_SHOWN).map(detailValue).join(', ')
|
||||
return value.length > DETAIL_LIST_SHOWN
|
||||
? `${shown} and ${value.length - DETAIL_LIST_SHOWN} more`
|
||||
: shown
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// A nested object is rendered by its keys rather than as JSON: an operator
|
||||
// reading a log wants "granted: 8, missed: 4", not a brace.
|
||||
return Object.entries(value)
|
||||
.map(([k, v]) => `${k} ${detailValue(v)}`)
|
||||
.join(', ')
|
||||
}
|
||||
const text = String(value)
|
||||
return text.length > DETAIL_TEXT_MAX ? `${text.slice(0, DETAIL_TEXT_MAX - 1)}…` : text
|
||||
}
|
||||
|
||||
export const logKindWord = (kind) => KIND_WORDS[kind] || kind
|
||||
|
||||
/**
|
||||
* One log line as a sentence.
|
||||
*
|
||||
* The `detail` of a human control carries `control` and `by`, which is what
|
||||
* separates "the runner paused this because a world write failed" from "somebody
|
||||
* pressed pause" — the two are the same transition and the console has to be
|
||||
* able to tell them apart at a glance.
|
||||
*/
|
||||
export function describeLogLine(line) {
|
||||
const d = line?.detail || {}
|
||||
const by = d.by ? ' by staff' : ''
|
||||
switch (line?.kind) {
|
||||
case 'run.status':
|
||||
return d.control
|
||||
? `${runStatusWord(d.to)}${by} — ${d.control}${d.reason ? `: ${d.reason}` : ''}`
|
||||
: `${d.from ? `${runStatusWord(d.from)} → ` : ''}${runStatusWord(d.to)}${d.because ? ` (${d.because})` : ''}`
|
||||
case 'run.health':
|
||||
return `Health is now ${d.to}${d.because ? ` (${d.because})` : ''}`
|
||||
case 'run.blocked':
|
||||
return `Held: run ${d.heldBy} has the concurrency key "${d.concurrencyKey}"`
|
||||
case 'phase.entered':
|
||||
return `Entered ${line.phase} (${d.steps ?? '?'} steps)`
|
||||
case 'phase.completed':
|
||||
return `${line.phase} finished`
|
||||
case 'step.parked':
|
||||
return `${d.action} is waiting on a human`
|
||||
case 'step.retry':
|
||||
return `${d.action} failed, attempt ${d.attempt} of ${d.of}${d.error ? `: ${d.error}` : ''}`
|
||||
case 'step.status':
|
||||
return d.control
|
||||
? `${d.action} → ${d.to}${by} — ${d.control}${d.note || d.reason ? `: ${d.note || d.reason}` : ''}`
|
||||
: `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}`
|
||||
case 'run.created':
|
||||
return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}`
|
||||
case 'phase.gate':
|
||||
return d.kind === 'after'
|
||||
? `${line.phase} advances ${d.after} after it started`
|
||||
: `${line.phase} advances on ${d.needed} × ${d.trigger}${d.where ? ` where ${d.where}` : ''}`
|
||||
// Both outcomes are logged, and the near miss is the useful one: it is the
|
||||
// difference between "the boss did spawn, in the wrong region" and "no boss
|
||||
// has spawned", which look identical on every other line of this log.
|
||||
case 'condition.evaluated':
|
||||
return `${d.trigger} ${d.matched ? 'counted' : 'did not count'} — ${d.seen} of ${d.needed}${
|
||||
d.satisfied ? ', condition met' : ''
|
||||
}`
|
||||
case 'phase.advanced':
|
||||
return d.because === 'forced'
|
||||
? `${line.phase} advanced by hand after ${d.waitedSeconds}s${d.reason ? `: ${d.reason}` : ''}`
|
||||
: `${line.phase} advanced on its ${d.because === 'elapsed' ? 'deadline' : 'condition'} after ${d.waitedSeconds}s`
|
||||
// Phase 6. `step.refused` is its own kind rather than a `step.status` for a
|
||||
// reason an operator feels at 2am: a refusal is not a failure, and the line
|
||||
// has to say which deployment rule stopped it -- the answer to "not enabled"
|
||||
// is a switch, and the answer to "over the cap" is a number.
|
||||
case 'step.refused':
|
||||
return `${d.action} refused: ${d.error}`
|
||||
case 'run.budget':
|
||||
return (d.dimensions || [])
|
||||
.map((x) => `${x.dimension} capped at ${x.cap === null ? 'nothing' : x.cap}${x.from ? ` (${x.from})` : ''}`)
|
||||
.join(', ') || 'no caps apply to this run'
|
||||
case 'version.verified':
|
||||
return `Version ${d.version} passed its dry run — scheduled occurrences may start`
|
||||
// Phase 15. The one line whose body core did not compose: a module may answer
|
||||
// a successful step with a `detail` object, and this renders whatever keys it
|
||||
// put there. `action` is core's own and is pulled out to lead the sentence;
|
||||
// everything after it is the module's.
|
||||
//
|
||||
// **Without this case the row would render as the literal string
|
||||
// "step.detail"**, because the default below is a kind word and not a
|
||||
// sentence — which would be the reporting channel existing and showing
|
||||
// nothing, the exact failure it was built to fix.
|
||||
case 'step.detail': {
|
||||
const { action, ...rest } = d
|
||||
const body = Object.entries(rest)
|
||||
.map(([key, value]) => `${key}: ${detailValue(value)}`)
|
||||
.join(', ')
|
||||
return body ? `${action || 'A step'} — ${body}` : `${action || 'A step'} reported nothing`
|
||||
}
|
||||
default:
|
||||
return logKindWord(line?.kind)
|
||||
}
|
||||
}
|
||||
99
client/src/lib/eventCalendar.js
Normal file
99
client/src/lib/eventCalendar.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// Rendering an event's instant, shared by the public event screens.
|
||||
//
|
||||
// **The split these two functions make is EVENTS.md §I's, and it is the one
|
||||
// thing about event times that is easy to get wrong.** The server returns UTC
|
||||
// instants and never guesses the reader's zone. The client places them:
|
||||
//
|
||||
// • the DAY an entry is filed under is the reader's own — "what is on this
|
||||
// month" is a question about the month the person reading is living in;
|
||||
// • the TIME beside it is always the EVENT's zone, carried on the entry —
|
||||
// because every listing this feature replaces is written in the shard's
|
||||
// local zone, and "8pm" means the shard's evening to everyone reading it.
|
||||
//
|
||||
// Rendering the time in the reader's zone instead would be defensible and is
|
||||
// wrong here: a player in Berlin told an American shard's event is at "02:00"
|
||||
// has been told something true and useless, and told it in a way that makes the
|
||||
// shard's own announcement look like a mistake.
|
||||
|
||||
/** The event's own wall clock, with the zone named so it misreads as nothing. */
|
||||
export function eventTime(instant, timezone) {
|
||||
try {
|
||||
const time = new Intl.DateTimeFormat(undefined, {
|
||||
timeZone: timezone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).format(new Date(instant))
|
||||
return `${time} ${shortZone(timezone)}`
|
||||
} catch {
|
||||
// An unknown IANA name throws rather than falling back, and an event whose
|
||||
// timezone column holds a typo must still render. UTC off the instant is the
|
||||
// honest answer when the zone cannot be honoured.
|
||||
return `${new Date(instant).toISOString().slice(11, 16)} UTC`
|
||||
}
|
||||
}
|
||||
|
||||
/** The zone as a reader recognises it: `America/New_York` → `New York`. */
|
||||
function shortZone(timezone) {
|
||||
if (!timezone) return 'UTC'
|
||||
const tail = String(timezone).split('/').pop()
|
||||
return tail.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
/** The reader's own day, for the heading an entry is filed under. */
|
||||
export function readerDayLabel(instant) {
|
||||
const d = new Date(instant)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: d.getFullYear() === new Date().getFullYear() ? undefined : 'numeric',
|
||||
}).format(d)
|
||||
}
|
||||
|
||||
/** The event's own day and time together, for a page that shows one occurrence. */
|
||||
export function eventDateTime(instant, timezone) {
|
||||
const d = new Date(instant)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
try {
|
||||
return `${new Intl.DateTimeFormat(undefined, {
|
||||
timeZone: timezone,
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).format(d)} ${shortZone(timezone)}`
|
||||
} catch {
|
||||
return `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`
|
||||
}
|
||||
}
|
||||
|
||||
// The word beside an entry, for the four public statuses.
|
||||
//
|
||||
// **`cancelled` needs the instant, and that is the whole reason this is a
|
||||
// function rather than a lookup table.** The server publishes `failed` and
|
||||
// `missed` as `cancelled` too — to a visitor those three are one event, and the
|
||||
// difference between them is about the deployment — but the three do not share
|
||||
// one English sentence. "Did not happen" is right for a past occurrence and a
|
||||
// plain falsehood for a future one, and a run four days out that an operator has
|
||||
// called off is exactly the common case: the calendar was saying *did not
|
||||
// happen* about next Friday.
|
||||
//
|
||||
// So the tense follows the clock, not the status. A future call-off reads
|
||||
// **Cancelled**; a past one reads **Did not happen**, which is also the honest
|
||||
// word for the failed and missed runs folded in with it.
|
||||
const WORDS = {
|
||||
live: 'Happening now',
|
||||
scheduled: 'Scheduled',
|
||||
completed: 'Finished',
|
||||
}
|
||||
|
||||
export function statusWord(status, scheduledFor, now = Date.now()) {
|
||||
if (WORDS[status]) return WORDS[status]
|
||||
if (status !== 'cancelled') return status
|
||||
const at = new Date(scheduledFor).getTime()
|
||||
return Number.isNaN(at) || at <= now ? 'Did not happen' : 'Cancelled'
|
||||
}
|
||||
35
client/src/lib/notificationPaths.js
Normal file
35
client/src/lib/notificationPaths.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// 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'
|
||||
|
||||
/**
|
||||
* This account's own event participation (events Phase 14a).
|
||||
*
|
||||
* The third screen to need this mapping, and it needed it for exactly the reason
|
||||
* the two above did: `GET /player/events/history` is behind `requireAuth` alone,
|
||||
* self-scoped on `req.user.id` — a staff member has a participation history like
|
||||
* anyone else, and the group's own header says staff are a superset of players.
|
||||
* The WEB is what disagrees, because `RequirePlayer` sends them to the login
|
||||
* page. Found the same way the notifications pair was: signed in as an admin,
|
||||
* the screen simply redirected.
|
||||
*/
|
||||
export const eventHistoryPath = (user) =>
|
||||
isStaff(user) ? '/admin/events/mine' : '/account/events'
|
||||
@@ -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,27 @@
|
||||
// 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.10.0 — the event contract opens to modules (EVENTS.md §F, EVENTS_PLAN.md
|
||||
// Phase 7): a module may register event actions, budget dimensions, leases and
|
||||
// param option sources. All four are server-side registrations and nothing on
|
||||
// `window.__rg` changed — but what they produce is met on this half, in the step
|
||||
// editor: an option source is what turns a param from a text box into a dropdown
|
||||
// of real values, and a budget's label and unit are what the switchboard's cap
|
||||
// box says beside its number. 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.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,
|
||||
@@ -53,4 +74,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.7.0'
|
||||
export const MODULE_API_VERSION = '1.10.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,6 +44,7 @@ const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18
|
||||
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
|
||||
const 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>
|
||||
@@ -51,6 +53,7 @@ const IconList = () => <Icon><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4"
|
||||
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>
|
||||
const IconCalendar = () => <Icon><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M3 10h18M8 3v4M16 3v4" /><circle cx="12" cy="15" r="1.4" /></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`
|
||||
@@ -96,9 +99,9 @@ export const NAV = [
|
||||
},
|
||||
{
|
||||
// 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 five
|
||||
// screens are two editors, a catalog and a paged table, none of which is a
|
||||
// settings section. Email Delivery stays under Settings: configuring a
|
||||
// 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: [
|
||||
@@ -107,6 +110,44 @@ export const NAV = [
|
||||
{ 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'] },
|
||||
// Last in the group because it is the one screen nobody visits weekly, and
|
||||
// beside Suppressions on purpose: it is where the reader is told that the
|
||||
// fourth engagement table does NOT expire, which is otherwise a silence
|
||||
// that reads as an oversight.
|
||||
{ to: '/admin/engagement/retention', label: 'Retention', icon: IconGear, roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Its own top-level group rather than a row under Content, and staff-wide
|
||||
// rather than admin-only. Both follow EVENTS.md §K: every read here is
|
||||
// `staff`, and the moderator's entire power over this feature is the run
|
||||
// console — the thing they open when an event is doing something wrong at
|
||||
// 2am. Hiding it from them would leave the one role that exists for incident
|
||||
// response unable to see the incident. The narrower gates live on the
|
||||
// actions: authoring is admin+editor and publish/start are admin only, both
|
||||
// enforced server-side and mirrored on the buttons.
|
||||
title: 'Events',
|
||||
items: [
|
||||
{ to: '/admin/events', label: 'Events', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
|
||||
// Phase 4. The same staff gate as the list beside it: a calendar is a read,
|
||||
// and the arcs it manages are authoring gated on the buttons rather than
|
||||
// on the row.
|
||||
{ to: '/admin/events/calendar', label: 'Calendar', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
|
||||
// Phase 6, and the one row in this group that is NOT staff-wide. §K puts
|
||||
// the switchboard in the same row as the world-changing actions it
|
||||
// governs: what a deployment permits at all is configuration, not a read,
|
||||
// and the server gates both the GET and the PUT on `admin`.
|
||||
{ to: '/admin/events/actions', label: 'Actions', icon: IconGear, roles: ['admin'] },
|
||||
// Phase 14a, and the one row here that is not about running the
|
||||
// deployment: it is this staff member's OWN attendance, the same screen
|
||||
// and the same route a player reads at /account/events. It has no `roles`
|
||||
// because it needs none — every account has a participation history, and
|
||||
// the server scopes it to the caller.
|
||||
{ to: '/admin/events/mine', label: 'My participation', icon: IconCalendar },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -129,6 +170,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 },
|
||||
],
|
||||
},
|
||||
@@ -177,11 +223,20 @@ 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',
|
||||
'/admin/engagement/retention': 'Retention',
|
||||
'/admin/events': 'Events',
|
||||
'/admin/events/calendar': 'Event calendar',
|
||||
'/admin/events/actions': 'Event actions',
|
||||
'/admin/events/mine': 'My participation',
|
||||
'/admin/events/new': 'New event',
|
||||
}
|
||||
|
||||
// An installed module's admin pages are not in TITLES and cannot be — core does
|
||||
@@ -202,6 +257,10 @@ function sectionTitle(pathname) {
|
||||
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
|
||||
if (pathname.startsWith('/admin/users/')) return 'User'
|
||||
if (pathname.startsWith('/admin/engagement')) return 'Engagement'
|
||||
// /admin/events/:id and /admin/events/runs/:runId are both dynamic, and both
|
||||
// belong to the same section as far as the page title is concerned.
|
||||
if (pathname.startsWith('/admin/events/runs/')) return 'Event run'
|
||||
if (pathname.startsWith('/admin/events/')) return 'Event'
|
||||
return 'Admin'
|
||||
}
|
||||
|
||||
@@ -443,6 +502,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>
|
||||
|
||||
230
client/src/routes/admin/views/EngagementRetention.jsx
Normal file
230
client/src/routes/admin/views/EngagementRetention.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Engagement → Retention (ENGAGEMENT.md Phase 14).
|
||||
//
|
||||
// Three of the four engagement tables grew on every fire and nothing had ever
|
||||
// deleted from any of them. This screen is the policy: how long the deployment
|
||||
// keeps a cooldown row, a finished outbox row and a send-log entry.
|
||||
//
|
||||
// **Why it is a screen, when the other two retention workers in this codebase
|
||||
// (`team_activity`, `user_notifications`) are invisible settings rows.** The
|
||||
// send-log horizon changes what an operator-facing page is *able to show* — the
|
||||
// Send Log is the only answer to "was this person told" — so an operator has to
|
||||
// be able to see it and set it, not discover it by finding rows missing. Having
|
||||
// made one visible, hiding the other two would be the worse split: "what does
|
||||
// this deployment keep" is one question and deserves one answer.
|
||||
//
|
||||
// **The fourth table is on this page as prose, not as a control.** Suppressions
|
||||
// do not expire (org lead, 2026-09-01), and saying so here is the point: an
|
||||
// operator reading a retention screen that lists three tables would reasonably
|
||||
// assume the fourth was an oversight.
|
||||
|
||||
const FIELDS = [
|
||||
{
|
||||
name: 'sends',
|
||||
label: 'Send log',
|
||||
table: 'engagement_sends',
|
||||
// The one horizon the org lead asked to be pickable rather than typed —
|
||||
// and `custom` stays, because a deployment with a compliance answer to
|
||||
// give should not be limited to three numbers somebody chose.
|
||||
presets: [90, 180, 365],
|
||||
help:
|
||||
'One row per delivery attempt. This is what Admin → Engagement → Send Log reads, so the '
|
||||
+ 'horizon is also how far back "was this person told" can be answered. The per-rule hourly '
|
||||
+ 'ceiling counts this table too, which is why it can never go below a week.',
|
||||
},
|
||||
{
|
||||
name: 'cooldowns',
|
||||
label: 'Cooldowns',
|
||||
table: 'engagement_cooldowns',
|
||||
presets: [7, 30, 90],
|
||||
help:
|
||||
'One row per rule, user, subject and channel, written on every fire. Deleting a row that '
|
||||
+ 'is still in force makes the next fire count as a first fire — that is a duplicate '
|
||||
+ 'message — so this must stay longer than the longest cooldown on any enabled rule.',
|
||||
},
|
||||
{
|
||||
name: 'outbox',
|
||||
label: 'Outbox',
|
||||
table: 'engagement_outbox',
|
||||
presets: [7, 30, 90],
|
||||
help:
|
||||
'Only finished rows are ever removed: sent, failed, cancelled and not-sent. A scheduled '
|
||||
+ 'row is a message this deployment still intends to send and is never swept, however old '
|
||||
+ 'the horizon.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function EngagementRetention() {
|
||||
const [policy, setPolicy] = useState(null)
|
||||
const [limits, setLimits] = useState({})
|
||||
const [warnings, setWarnings] = useState([])
|
||||
const [longestCooldown, setLongestCooldown] = useState(0)
|
||||
const [draft, setDraft] = useState({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [note, setNote] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const apply = useCallback((result) => {
|
||||
setPolicy(result.retention)
|
||||
setDraft(result.retention)
|
||||
setLimits(result.limits || {})
|
||||
setWarnings(result.warnings || [])
|
||||
setLongestCooldown(result.longestCooldownSeconds || 0)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
const result = await api.admin.getEngagementRetention()
|
||||
if (alive) apply(result)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [apply])
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
setNote(null)
|
||||
try {
|
||||
// The whole draft, not the changed field: this screen is the one place the
|
||||
// three are set together, and a partial save would leave the warning line
|
||||
// (which is computed from the cooldown horizon) describing a policy that is
|
||||
// half saved. The route itself is sparse, so sending three is legal.
|
||||
const result = await api.admin.setEngagementRetention(draft)
|
||||
apply(result)
|
||||
setNote('Saved.')
|
||||
} catch (err) {
|
||||
setNote(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const dirty = policy && FIELDS.some((f) => Number(draft[f.name]) !== Number(policy[f.name]))
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p className="sans dim" style={{ fontSize: '0.88rem', maxWidth: 720, marginTop: 0 }}>
|
||||
How long this deployment keeps the engagement system’s own records. A nightly sweep
|
||||
removes anything older, in batches, and skips a table it cannot read rather than failing
|
||||
the run.
|
||||
</p>
|
||||
|
||||
{warnings.map((w) => (
|
||||
<p
|
||||
key={w}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.85rem',
|
||||
maxWidth: 720,
|
||||
padding: '10px 12px',
|
||||
borderLeft: '3px solid #d98b84',
|
||||
background: 'rgba(217, 139, 132, 0.08)',
|
||||
}}
|
||||
>
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'grid', gap: 22, maxWidth: 720, marginTop: 20 }}>
|
||||
{FIELDS.map((f) => {
|
||||
const spec = limits[f.name] || {}
|
||||
const value = draft[f.name] ?? ''
|
||||
const isPreset = f.presets.includes(Number(value))
|
||||
return (
|
||||
<div key={f.name}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<span className="field-label" style={{ fontWeight: 600 }}>{f.label}</span>
|
||||
<code className="dim" style={{ fontSize: '0.74rem' }}>{f.table}</code>
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '4px 0 8px' }}>
|
||||
{f.help}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label>
|
||||
<span className="field-label">Keep for</span>
|
||||
<select
|
||||
className="select"
|
||||
value={isPreset ? String(value) : 'custom'}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value
|
||||
// Choosing "custom" must not blank the field — the number
|
||||
// box below is what the operator is about to edit, and an
|
||||
// empty one would post NaN.
|
||||
if (next === 'custom') return
|
||||
setDraft({ ...draft, [f.name]: Number(next) })
|
||||
}}
|
||||
>
|
||||
{f.presets.map((d) => (
|
||||
<option key={d} value={String(d)}>{d} days</option>
|
||||
))}
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Days</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={spec.min ?? 2}
|
||||
max={spec.max ?? 3650}
|
||||
style={{ width: 110 }}
|
||||
value={value}
|
||||
onChange={(e) => setDraft({ ...draft, [f.name]: e.target.value === '' ? '' : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
{spec.min !== undefined && (
|
||||
<span className="sans dim" style={{ fontSize: '0.78rem', paddingBottom: 8 }}>
|
||||
{spec.min}–{spec.max} days
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 24 }}>
|
||||
<button type="button" className="pill" disabled={!dirty || saving} onClick={save}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{dirty && (
|
||||
<button type="button" className="pill" disabled={saving} onClick={() => setDraft(policy)}>
|
||||
Discard
|
||||
</button>
|
||||
)}
|
||||
{note && <span className="sans" style={{ fontSize: '0.82rem' }}>{note}</span>}
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 720, marginTop: 32 }}>
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', marginBottom: 6 }}>
|
||||
Suppressed addresses do not expire
|
||||
</h3>
|
||||
<p className="sans dim" style={{ fontSize: '0.84rem', margin: 0 }}>
|
||||
A suppression is a standing decision, not a record of something that happened. Ageing one
|
||||
out would re-mail an address that already hard-bounced or asked to be left alone, which is
|
||||
how a sender loses a domain’s reputation. The way out of that list stays a
|
||||
deliberate act:{' '}
|
||||
<strong>Lift</strong> on the row, in Admin → Engagement → Suppressions.
|
||||
</p>
|
||||
{longestCooldown > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.84rem', marginBottom: 0 }}>
|
||||
The longest cooldown on an enabled rule right now is {longestCooldown} seconds.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -482,6 +482,60 @@ function RuleEditor({ catalog, segments, rule, onSaved, onCancel }) {
|
||||
|
||||
// ── 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([])
|
||||
@@ -558,6 +612,25 @@ export default function EngagementRules() {
|
||||
|
||||
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
|
||||
|
||||
@@ -43,6 +43,11 @@ export default function EngagementSendLog() {
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [status, setStatus] = useState('')
|
||||
const [testTrigger, setTestTrigger] = useState('')
|
||||
// Phase 14. `total` is now a truncated number, and a screen that shows a total
|
||||
// without saying so is quietly wrong about the deployment's own history — this
|
||||
// is the fix for that, and the reason the horizon got an operator-facing
|
||||
// control rather than the invisible settings row the other two sweeps use.
|
||||
const [retainDays, setRetainDays] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
@@ -55,6 +60,15 @@ export default function EngagementSendLog() {
|
||||
setRows(result.sends || [])
|
||||
setTotal(result.total || 0)
|
||||
setTestTrigger(result.testSendTrigger || '')
|
||||
// Best-effort and non-blocking: the log is worth showing even if the policy
|
||||
// cannot be read, so a failure here leaves the note off rather than the
|
||||
// screen empty.
|
||||
try {
|
||||
const policy = await api.admin.getEngagementRetention()
|
||||
setRetainDays(policy?.retention?.sends ?? null)
|
||||
} catch {
|
||||
setRetainDays(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -153,6 +167,7 @@ export default function EngagementSendLog() {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
|
||||
{offset + 1}–{to} of {total}
|
||||
{retainDays ? ` · entries older than ${retainDays} days are removed automatically` : ''}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||||
|
||||
294
client/src/routes/admin/views/EngagementSuppressions.jsx
Normal file
294
client/src/routes/admin/views/EngagementSuppressions.jsx
Normal file
@@ -0,0 +1,294 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-row Lift (Phase 14). No address is asked for and none is needed: the
|
||||
* row carries its own `address_hash`, which is the only handle this screen has
|
||||
* ever been able to have — the address itself is stored one-way.
|
||||
*
|
||||
* No confirm dialog, deliberately. Lifting is reversible in one click (the
|
||||
* Suppress field above is right there), and a browser modal blocks the whole
|
||||
* tab, which is the failure mode the automation notes in this repo warn about.
|
||||
*/
|
||||
async function liftRow(row) {
|
||||
setNote(null)
|
||||
try {
|
||||
await api.admin.unsuppressByHash(row.address_hash, row.channel)
|
||||
setNote(`${row.address_masked || 'That address'} 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>
|
||||
<th className="adm-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>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ fontSize: '0.72rem' }}
|
||||
disabled={!r.address_hash}
|
||||
title={r.address_hash
|
||||
? 'Let this address be mailed again'
|
||||
: 'This row has no handle to act on'}
|
||||
onClick={() => liftRow(r)}
|
||||
>
|
||||
Lift
|
||||
</button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const CEILING_NOTE = {
|
||||
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',
|
||||
}
|
||||
|
||||
280
client/src/routes/admin/views/EventActions.jsx
Normal file
280
client/src/routes/admin/views/EventActions.jsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Events → Actions — the deployment's switchboard (EVENTS.md §K, Phase 6).
|
||||
//
|
||||
// **This screen is the whole of the permission model beyond the role.** A module
|
||||
// declaring `uo.creature.spawn` is code the operator installed; it is not a
|
||||
// permission they granted. Enablement is the grant, and the cap is how much of
|
||||
// it — so this is the one screen in the feature where an operator decides what
|
||||
// the deployment *can do at all*, rather than what it is going to do tonight.
|
||||
//
|
||||
// **Nothing above `notify` and `inspect` arrives enabled.** Installing a module
|
||||
// must never start doing things, which is the posture a seeded engagement rule
|
||||
// already takes by arriving `enabled = 0`. The line falls between `inspect` and
|
||||
// `change` (org lead, 2026-09-03): an `inspect` action reads state and writes
|
||||
// nothing, so a deployment gains no risk by having it on, and `core.wait` — which
|
||||
// is `inspect` — arriving off would break every published event that waits.
|
||||
//
|
||||
// **A row with no stored setting is not "off".** It is "the default for its risk
|
||||
// class", computed on the server by the same function the runner asks. The screen
|
||||
// says which it is looking at, because "an admin turned this on" and "this has
|
||||
// always been on" are different facts and only one of them is a decision.
|
||||
//
|
||||
// **Admin only in both directions**, including the read: §K puts the switchboard
|
||||
// in the same row as the world-changing actions it governs, and knowing exactly
|
||||
// what a deployment permits is not a staff-wide read.
|
||||
|
||||
const RISK_WORD = {
|
||||
notify: 'Tells people something',
|
||||
inspect: 'Reads the world',
|
||||
change: 'Changes the world',
|
||||
irreversible: 'Changes the world irreversibly',
|
||||
}
|
||||
|
||||
const RISK_COLOR = {
|
||||
notify: 'var(--muted)',
|
||||
inspect: 'var(--muted)',
|
||||
change: '#d9c184',
|
||||
irreversible: '#d98b84',
|
||||
}
|
||||
|
||||
const REVERSIBLE_WORD = {
|
||||
none: 'nothing to undo',
|
||||
self: 'undoes itself',
|
||||
ledger: 'undone from the ledger at teardown',
|
||||
override: 'restores a baseline',
|
||||
}
|
||||
|
||||
export default function EventActions() {
|
||||
const [actions, setActions] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(null)
|
||||
const [problem, setProblem] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
// Cap edits are held here until they are saved, keyed `actionId:dimension`.
|
||||
// A cap is a number somebody types digit by digit, and writing on every
|
||||
// keystroke would put "3" in the database on the way to "30".
|
||||
const [drafts, setDrafts] = useState({})
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await api.admin.eventActions()
|
||||
setActions(data.actions || [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load()
|
||||
if (alive) setError(null)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
/**
|
||||
* Write one action's row.
|
||||
*
|
||||
* The whole row goes every time — the switch and every cap — because the route
|
||||
* takes one action per request and a sparse write would have to decide what an
|
||||
* omitted cap means. Here it can only mean one thing, so it is sent.
|
||||
*/
|
||||
const save = async (action, { enabled = action.enabled, caps } = {}) => {
|
||||
setBusy(action.id)
|
||||
setProblem(null)
|
||||
setNotice(null)
|
||||
const nextCaps = caps !== undefined ? caps : capsOf(action)
|
||||
try {
|
||||
await api.admin.saveEventAction({ actionId: action.id, enabled, caps: nextCaps })
|
||||
await load()
|
||||
setDrafts((d) => {
|
||||
const next = { ...d }
|
||||
for (const d of action.dimensions) delete next[`${action.id}:${d.id}`]
|
||||
return next
|
||||
})
|
||||
setNotice(`Saved ${action.label}.`)
|
||||
} catch (err) {
|
||||
setProblem(err.message)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
/** The caps this row would save: the drafts on top of what is stored. */
|
||||
const capsOf = (action) => {
|
||||
const out = {}
|
||||
for (const { id: dimension } of action.dimensions) {
|
||||
const draft = drafts[`${action.id}:${dimension}`]
|
||||
const value = draft !== undefined ? draft : action.caps[dimension]
|
||||
if (value === '' || value === undefined || value === null) continue
|
||||
out[dimension] = Number(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const capValue = (action, dimension) => {
|
||||
const draft = drafts[`${action.id}:${dimension}`]
|
||||
if (draft !== undefined) return draft
|
||||
const stored = action.caps[dimension]
|
||||
return stored === undefined || stored === null ? '' : String(stored)
|
||||
}
|
||||
|
||||
const dirty = (action) =>
|
||||
action.dimensions.some((d) => drafts[`${action.id}:${d.id}`] !== undefined)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="sans" style={{ margin: '0 0 4px' }}>Event actions</h2>
|
||||
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: '62ch' }}>
|
||||
What this deployment permits an event to do, and how much of it per run. Anything that changes
|
||||
the world arrives switched off — installing a module declares a verb, it does not grant
|
||||
permission to use it. Caps are copied into a run when the run is created, so moving a switch
|
||||
never changes what a run already in flight is allowed.
|
||||
</p>
|
||||
|
||||
{problem && (
|
||||
<div className="panel-flat" style={{ padding: 10, marginBottom: 12, borderLeft: '3px solid #d98b84' }}>
|
||||
<span className="sans" style={{ fontSize: '0.85rem' }}>{problem}</span>
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="panel-flat" style={{ padding: 10, marginBottom: 12, borderLeft: '3px solid #8fc79a' }}>
|
||||
<span className="sans" style={{ fontSize: '0.85rem' }}>{notice}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions.length === 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
|
||||
No module registers an event action. Core always declares its own three, so an empty list
|
||||
here means the registry did not load.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions.map((action) => (
|
||||
<div
|
||||
key={action.id}
|
||||
className="panel-flat"
|
||||
style={{
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
borderLeft: `3px solid ${action.enabled ? RISK_COLOR[action.risk] || 'var(--rule)' : 'var(--rule)'}`,
|
||||
opacity: action.enabled ? 1 : 0.75,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<strong className="sans" style={{ fontSize: '0.95rem' }}>{action.label}</strong>
|
||||
<code className="dim" style={{ fontSize: '0.78rem' }}>{action.id}</code>
|
||||
</div>
|
||||
{action.description && (
|
||||
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.82rem' }}>{action.description}</p>
|
||||
)}
|
||||
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.78rem' }}>
|
||||
<span style={{ color: RISK_COLOR[action.risk] }}>{RISK_WORD[action.risk] || action.risk}</span>
|
||||
{' · '}
|
||||
{REVERSIBLE_WORD[action.reversible] || action.reversible}
|
||||
{/* Which of the two facts this is. A default is not a decision, and
|
||||
an operator auditing their own deployment needs to see the
|
||||
difference without reading the risk table in their head. */}
|
||||
{' · '}
|
||||
{action.configured
|
||||
? `set by ${action.updatedBy || 'an administrator'}`
|
||||
: 'never configured — showing the default for its risk class'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="sans" style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: '0.85rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={action.enabled}
|
||||
disabled={busy === action.id}
|
||||
onChange={(e) => save(action, { enabled: e.target.checked })}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{action.dimensions.length > 0 && (
|
||||
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--rule)' }}>
|
||||
<p className="sans dim" style={{ margin: '0 0 6px', fontSize: '0.78rem' }}>
|
||||
Per-run caps. Blank is uncapped — the run still counts what it spends, nothing bounds
|
||||
it. Where another enabled action spends the same thing, the tightest cap is the one a
|
||||
run gets.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
{action.dimensions.map((d) => (
|
||||
<label key={d.id} className="sans" style={{ fontSize: '0.8rem' }}>
|
||||
{/*
|
||||
The LABEL, with the unit beside the box — both from the module's
|
||||
`registerEventBudgets` declaration (Phase 7). Before it, this said
|
||||
`uo.creatures` over an unlabelled number, which is ambiguous in exactly
|
||||
the case that matters: 30 of what?
|
||||
*/}
|
||||
<span className="dim" style={{ display: 'block', marginBottom: 2 }}>
|
||||
{d.registered ? d.label : d.id}
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
style={{ width: 110 }}
|
||||
value={capValue(action, d.id)}
|
||||
disabled={busy === action.id || !d.registered}
|
||||
onChange={(e) =>
|
||||
setDrafts((s) => ({ ...s, [`${action.id}:${d.id}`]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
{d.registered && d.unit && (
|
||||
<span className="dim" style={{ fontSize: '0.75rem' }}>{d.unit}</span>
|
||||
)}
|
||||
</span>
|
||||
{/*
|
||||
A dimension nobody declares is SHOWN rather than hidden. The action is
|
||||
refused when it is saved into a step and again if it is ever dispatched,
|
||||
so the operator needs to be told which module is incomplete — hiding the
|
||||
row would make a broken module look like a cheap one.
|
||||
*/}
|
||||
{!d.registered && (
|
||||
<span
|
||||
className="sans"
|
||||
style={{ display: 'block', marginTop: 2, fontSize: '0.72rem', color: '#d98b84' }}
|
||||
>
|
||||
No module declares this as a budget, so a step using this action is
|
||||
refused. It cannot be capped until one does.
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy === action.id || !dirty(action)}
|
||||
onClick={() => save(action)}
|
||||
>
|
||||
Save caps
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1553
client/src/routes/admin/views/EventEditor.jsx
Normal file
1553
client/src/routes/admin/views/EventEditor.jsx
Normal file
File diff suppressed because it is too large
Load Diff
722
client/src/routes/admin/views/EventRun.jsx
Normal file
722
client/src/routes/admin/views/EventRun.jsx
Normal file
@@ -0,0 +1,722 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import {
|
||||
runStatusWord,
|
||||
isTerminalRun,
|
||||
isParked,
|
||||
runControlsFor,
|
||||
stepControlsFor,
|
||||
describeLogLine,
|
||||
} from '../../../lib/eventAuthoring.js'
|
||||
|
||||
// Admin → Events → the run console (EVENTS.md §I, Phase 3).
|
||||
//
|
||||
// One run: where it is, what each of its steps did, what a human can still do
|
||||
// about it, and the diagnostic log underneath. Staff-wide to read; the six
|
||||
// controls are `admin` + `moderator`, and the server re-checks every one of them
|
||||
// against the run's live status — this screen predicts, it does not decide.
|
||||
//
|
||||
// **It polls rather than streaming.** A run changes on the runner's tick, which
|
||||
// is a fifteen-second clock, and a console watched for the length of an event is
|
||||
// a tab left open for two hours: an SSE channel for that is a connection held
|
||||
// per staff member for a screen that could not use the latency. The poll stops
|
||||
// the moment the run reaches a terminal status, because a completed run has
|
||||
// nothing further to say.
|
||||
//
|
||||
// **The parked step is the thing this screen exists to make impossible to
|
||||
// miss.** A run waiting on a GM cue is `running` and healthy-looking, and it will
|
||||
// stay that way for ever unless somebody presses confirm. It is called out above
|
||||
// the step list rather than being one row in it.
|
||||
//
|
||||
// **Phase 5 gave it a second one of those, and the panel is this phase's real
|
||||
// deliverable** (§ Observability): a phase whose steps have all finished and
|
||||
// whose advance condition has not been met is also `running` and also
|
||||
// healthy-looking. *"Why didn't phase 3 start?"* is answered here, above the
|
||||
// steps, in the condition builder's own words — and the sentence is the
|
||||
// SERVER'S. `gates[].where` arrives already rendered, because those labels are
|
||||
// defined in the condition grammar and a second renderer in the browser would
|
||||
// be a second opinion about what `gte` reads as.
|
||||
//
|
||||
// **Phase 8 gave it a third, and it is the one that outlives the event.** The
|
||||
// resource ledger is what this run changed in the world and what became of it,
|
||||
// and its unresolved rows are the reason a `completed` run can still need a
|
||||
// person — EVENTS.md §L: a run reaches `completed` with `cleanup_status =
|
||||
// 'incomplete'` rather than being held open, because a tidy `completed` row over
|
||||
// a shard full of orphaned monsters is the failure that would end this feature's
|
||||
// credibility on its first bad night. The panel is shown on finished runs for
|
||||
// exactly that reason, and it is the only panel here whose empty state matters.
|
||||
|
||||
const POLL_MS = 5000
|
||||
|
||||
const STATUS_COLOR = {
|
||||
failed: '#d98b84',
|
||||
missed: '#d98b84',
|
||||
paused: '#d9c184',
|
||||
cancelled: 'var(--muted)',
|
||||
running: '#8fc79a',
|
||||
completed: '#8fc79a',
|
||||
}
|
||||
|
||||
// The six ledger statuses, in the two groups that matter to a reader: green is
|
||||
// resolved, amber wants a person. `orphaned` and `drifted` are amber rather than
|
||||
// red because neither is a fault — one thing vanished, the other was taken by
|
||||
// somebody with every right to take it — and red is reserved for "this did not
|
||||
// come back and core kept asking".
|
||||
const RESOURCE_COLOR = {
|
||||
reverted: '#8fc79a',
|
||||
confirmed: '#d9c184',
|
||||
pending: '#d9c184',
|
||||
reverting: '#d9c184',
|
||||
drifted: '#d9c184',
|
||||
orphaned: '#d9c184',
|
||||
}
|
||||
|
||||
const RESOURCE_WORD = {
|
||||
pending: 'recorded, unconfirmed',
|
||||
confirmed: 'still out there',
|
||||
reverting: 'being given back',
|
||||
reverted: 'given back',
|
||||
orphaned: 'gone',
|
||||
drifted: 'someone else moved it',
|
||||
}
|
||||
|
||||
const STEP_COLOR = {
|
||||
done: '#8fc79a',
|
||||
failed: '#d98b84',
|
||||
refused: '#d9c184',
|
||||
skipped: 'var(--muted)',
|
||||
cancelled: 'var(--muted)',
|
||||
}
|
||||
|
||||
const when = (v) => (v ? new Date(v).toLocaleString() : '—')
|
||||
const clock = (v) => (v ? new Date(v).toLocaleTimeString() : '')
|
||||
|
||||
// How many participants the console renders before it stops and counts the rest.
|
||||
// A run's participants are people and a busy event has hundreds; this panel is a
|
||||
// check that the collection worked and that the ranking looks right, not the
|
||||
// results page — that is Phase 14's, and it is public.
|
||||
const PARTICIPANTS_SHOWN = 50
|
||||
|
||||
/**
|
||||
* Seconds as an operator reads them — the same vocabulary the spec authors a
|
||||
* gate in, so "28 min" on this screen and `after: '30m'` in the editor are
|
||||
* obviously the same kind of thing.
|
||||
*/
|
||||
function elapsed(seconds) {
|
||||
const s = Math.max(0, Number(seconds) || 0)
|
||||
if (s < 60) return `${s} sec`
|
||||
if (s < 3600) return `${Math.floor(s / 60)} min`
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
return m ? `${h} hr ${m} min` : `${h} hr`
|
||||
}
|
||||
|
||||
/**
|
||||
* One phase gate, as the panel draws it.
|
||||
*
|
||||
* The satisfied ones are drawn too, and dimmed: "phase 2 waited 41 minutes and
|
||||
* was released by the third boss" is the same question as the live one, asked
|
||||
* after the fact, and it is the one an operator asks the morning after.
|
||||
*/
|
||||
function GateRow({ gate, current }) {
|
||||
const colour = gate.satisfied ? 'var(--muted)' : gate.stalled ? '#d98b84' : '#d9c184'
|
||||
return (
|
||||
<div style={{ padding: '8px 0', borderTop: '1px solid var(--rule)' }}>
|
||||
<div className="sans" style={{ fontSize: '0.86rem', color: colour }}>
|
||||
Phase <strong>{gate.phase}</strong>
|
||||
{current && !gate.satisfied ? ' has not started' : ''}
|
||||
{gate.satisfied && ` — released ${gate.satisfiedBy === 'forced' ? 'by hand' : `on its ${gate.satisfiedBy === 'elapsed' ? 'deadline' : 'condition'}`}`}
|
||||
{gate.stalled && ' — STALLED'}
|
||||
</div>
|
||||
<dl className="sans" style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '2px 12px', margin: '6px 0 0', fontSize: '0.8rem' }}>
|
||||
{gate.kind === 'after' ? (
|
||||
<>
|
||||
<dt className="dim">waiting for</dt>
|
||||
<dd style={{ margin: 0 }}>{elapsed(gate.after)} from the start of the phase</dd>
|
||||
<dt className="dim">until</dt>
|
||||
<dd style={{ margin: 0 }}>{when(gate.dueAt)}</dd>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<dt className="dim">waiting on</dt>
|
||||
<dd style={{ margin: 0 }}>
|
||||
<code>{gate.waitingOn}</code>
|
||||
{gate.where ? <> where <em>{gate.where}</em></> : <span className="dim"> — any firing</span>}
|
||||
</dd>
|
||||
<dt className="dim">seen so far</dt>
|
||||
<dd style={{ margin: 0 }}>{gate.seen} of {gate.needed}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="dim">since</dt>
|
||||
<dd style={{ margin: 0 }}>{when(gate.since)} ({elapsed(gate.elapsedSeconds)})</dd>
|
||||
{gate.kind === 'on' && gate.lastEvent && (
|
||||
<>
|
||||
<dt className="dim">last related event</dt>
|
||||
<dd style={{ margin: 0 }}>
|
||||
<code>{gate.lastEvent.trigger}</code> at {clock(gate.lastEventAt)}
|
||||
{' — '}
|
||||
{/* The near miss is the valuable half: "the boss did spawn, in
|
||||
Britain" and "no boss has spawned" are different answers and
|
||||
look identical without this line. */}
|
||||
{gate.lastEvent.matched ? 'counted' : 'did not count'}
|
||||
{Object.keys(gate.lastEvent.variables || {}).length > 0 && (
|
||||
<span className="dim">
|
||||
{' ('}
|
||||
{Object.entries(gate.lastEvent.variables).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')}
|
||||
{')'}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function EventRun() {
|
||||
const { runId } = useParams()
|
||||
const [run, setRun] = useState(null)
|
||||
const [steps, setSteps] = useState([])
|
||||
const [counts, setCounts] = useState({})
|
||||
const [gates, setGates] = useState([])
|
||||
// The caps this run was given and what it has spent of them (Phase 6). Copied
|
||||
// into the run when it was created, so this is what THIS run is allowed rather
|
||||
// than what the switchboard says today.
|
||||
const [budget, setBudget] = useState([])
|
||||
// What this run created or borrowed, and what became of each (Phase 8).
|
||||
const [resources, setResources] = useState([])
|
||||
const [unresolved, setUnresolved] = useState(0)
|
||||
// Who took part, best first (Phase 10). Present whether or not the results
|
||||
// have been published; `run.resultsPublishedAt` is what says which.
|
||||
const [participants, setParticipants] = useState([])
|
||||
const [lines, setLines] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [problem, setProblem] = useState(null)
|
||||
const [notes, setNotes] = useState({})
|
||||
const [reason, setReason] = useState('')
|
||||
const alive = useRef(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [detail, log] = await Promise.all([
|
||||
api.admin.getEventRun(runId),
|
||||
api.admin.getEventRunLog(runId, 200),
|
||||
])
|
||||
if (!alive.current) return
|
||||
setRun(detail.run)
|
||||
setSteps(detail.steps || [])
|
||||
setCounts(detail.counts || {})
|
||||
setGates(detail.gates || [])
|
||||
setBudget(detail.budget || [])
|
||||
setResources(detail.resources || [])
|
||||
setUnresolved(detail.unresolvedResources || 0)
|
||||
setParticipants(detail.participants || [])
|
||||
setLines(log.log || [])
|
||||
}, [runId])
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load()
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
if (alive.current) setError(err.message)
|
||||
} finally {
|
||||
if (alive.current) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
alive.current = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
// The poll, and its own off switch. A terminal run is not re-read: it cannot
|
||||
// change, and a console left open on last night's completed event should not
|
||||
// be a request every five seconds until the tab is closed.
|
||||
useEffect(() => {
|
||||
if (!run || isTerminalRun(run.status)) return undefined
|
||||
const timer = setInterval(() => {
|
||||
load().catch(() => {})
|
||||
}, POLL_MS)
|
||||
return () => clearInterval(timer)
|
||||
}, [run, load])
|
||||
|
||||
/** Every control goes through here: press, reload, and surface a refusal. */
|
||||
const act = async (fn) => {
|
||||
setBusy(true)
|
||||
setProblem(null)
|
||||
try {
|
||||
await fn()
|
||||
await load()
|
||||
} catch (err) {
|
||||
// A 409 is the ordinary answer to a button pressed against a run that has
|
||||
// moved on since the screen was drawn, so it is shown as a sentence rather
|
||||
// than as an error state — and the reload above has already re-drawn the
|
||||
// controls as they now stand.
|
||||
setProblem(err.body?.errors?.[0] || err.message)
|
||||
await load().catch(() => {})
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !run) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!run) return <ErrorState message="No such run." />
|
||||
|
||||
const controls = runControlsFor(run, gates, steps)
|
||||
const waiting = gates.find((g) => g.phase === run.currentPhase && !g.satisfied)
|
||||
const parked = steps.filter(isParked)
|
||||
const summary = Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(' · ')
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h2 className="sans" style={{ margin: 0, fontSize: '1.05rem' }}>
|
||||
<Link to={`/admin/events/${run.definitionId}`}>{run.definitionTitle}</Link>{' '}
|
||||
<span className="dim" style={{ fontWeight: 400 }}>v{run.version}</span>
|
||||
</h2>
|
||||
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.8rem' }}>
|
||||
Occurrence {when(run.scheduledFor)}
|
||||
{run.scope ? ` · scope ${run.scope}` : ''}
|
||||
{run.rehearsal ? ' · rehearsal' : ''}
|
||||
{run.concurrencyKey ? ` · key ${run.concurrencyKey}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="sans" style={{ fontSize: '1rem', color: STATUS_COLOR[run.status] || undefined }}>
|
||||
{runStatusWord(run.status)}
|
||||
{run.currentPhase && <span className="dim" style={{ fontSize: '0.82rem' }}> · {run.currentPhase}</span>}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||||
{run.health !== 'ok' && <span style={{ color: '#d9c184' }}>{run.health} · </span>}
|
||||
{summary || 'no steps'}
|
||||
{!isTerminalRun(run.status) && <span> · refreshing</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health is not status, which is the whole reason the two are separate
|
||||
columns — but the sentence has to agree with the status it sits beside.
|
||||
A degraded RUNNING run is the interesting case: still going, already in
|
||||
trouble. A degraded PAUSED run is not "still running", and saying so on
|
||||
the one screen an operator opens to find out what stopped it would be
|
||||
the console contradicting itself. Found in the browser walk. */}
|
||||
{run.health === 'degraded' && !isTerminalRun(run.status) && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', color: '#d9c184', marginTop: 10 }}>
|
||||
{run.status === 'paused' ? (
|
||||
<>
|
||||
Something in this run failed, and it is waiting for a person. Resuming carries the phase
|
||||
past the failed step; <em>Retry & resume</em> puts that step back in the queue first.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Something in this run has already had to be retried. It is still running — this is
|
||||
what “degraded” means, and the log below says what happened.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{run.lastError && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', color: '#d98b84', marginTop: 6 }}>{run.lastError}</p>
|
||||
)}
|
||||
|
||||
{problem && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', color: '#d98b84', marginTop: 6 }}>{problem}</p>
|
||||
)}
|
||||
|
||||
{/* ── The run controls ── */}
|
||||
<div className="panel-flat" style={{ padding: '12px 14px', margin: '14px 0', display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 240px' }}>
|
||||
<span className="field-label">Reason (recorded with your name)</span>
|
||||
<input className="input" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="optional" />
|
||||
</label>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.pause}
|
||||
onClick={() => act(() => api.admin.pauseEventRun(run.id, reason))}>
|
||||
Pause
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.resume}
|
||||
onClick={() => act(() => api.admin.resumeEventRun(run.id))}>
|
||||
Resume
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.advance}
|
||||
onClick={() => act(() => api.admin.advanceEventRun(run.id, reason))}>
|
||||
Advance phase
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.cancel}
|
||||
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason))}>
|
||||
Cancel run
|
||||
</button>
|
||||
{/* The separate, admin-only decision (§L). It is a second button rather
|
||||
than a checkbox on the first because the two are not variants of one
|
||||
action: one gives the world back, the other deliberately leaves it
|
||||
changed. A checkbox next to Cancel is a thing an operator unticks by
|
||||
accident at two in the morning. The server refuses this to a
|
||||
moderator, and the refusal arrives as a sentence in `problem`. */}
|
||||
{controls.cancel && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason, false))}>
|
||||
Cancel, leave changes up
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isTerminalRun(run.status) && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem' }}>
|
||||
This run is over ({runStatusWord(run.status)} at {when(run.endedAt)}). Nothing can change it
|
||||
— a run pins the version it started from so that it can still be explained later.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Why this phase has not started (Phase 5) ──
|
||||
Above the step list for the same reason the parked cue is: a phase
|
||||
waiting on a condition is `running` and looks completely healthy, and
|
||||
the one screen an operator opens to find out why nothing is happening
|
||||
must say so before they have to read a log. */}
|
||||
{gates.length > 0 && (
|
||||
<div
|
||||
className="panel-flat"
|
||||
style={{ padding: 14, marginBottom: 14, borderLeft: `3px solid ${waiting ? (waiting.stalled ? '#d98b84' : '#d9c184') : 'var(--rule)'}` }}
|
||||
>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
|
||||
{waiting ? 'Why this phase has not started' : 'Phase advance conditions'}
|
||||
</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 4px', fontSize: '0.8rem' }}>
|
||||
{waiting ? (
|
||||
<>
|
||||
Every step of this phase has finished. It advances when the condition below is met —
|
||||
nothing times out, and <em>Advance phase</em> is how a person overrides it.
|
||||
{waiting.stalled && ' This one has been waiting long enough that the run is marked stalled.'}
|
||||
</>
|
||||
) : (
|
||||
'What each phase of this run waited for, and what released it.'
|
||||
)}
|
||||
</p>
|
||||
{gates.map((gate) => (
|
||||
<GateRow key={gate.phase} gate={gate} current={gate.phase === run.currentPhase} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── What this run is allowed, and what it has spent ──
|
||||
A meter rather than a sentence: a cap is two numbers and a name, and
|
||||
unlike a gate it needs no grammar rendered to be read. It is shown for
|
||||
every run that has a budget at all, finished ones included — "how much
|
||||
did last night's invasion actually spawn" is the same question asked
|
||||
the morning after. */}
|
||||
{budget.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Caps</h3>
|
||||
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<tbody>
|
||||
{budget.map((b) => {
|
||||
const spent = b.cap === null ? 0 : Math.min(b.consumed / b.cap, 1)
|
||||
const full = b.cap !== null && b.consumed >= b.cap
|
||||
return (
|
||||
<tr key={b.dimension}>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{b.dimension}</code>
|
||||
</td>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', color: full ? '#d9c184' : undefined }}>
|
||||
{b.cap === null ? `${b.consumed} spent` : `${b.consumed} of ${b.cap}`}
|
||||
</td>
|
||||
<td style={{ width: '100%', padding: '3px 0' }}>
|
||||
{b.cap === null ? (
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>no cap</span>
|
||||
) : (
|
||||
<span style={{ display: 'block', height: 6, background: 'var(--rule)', borderRadius: 3 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
height: 6,
|
||||
width: `${Math.round(spent * 100)}%`,
|
||||
background: full ? '#d9c184' : '#8fc79a',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{/* Which switch set the number, so an operator can trace a cap
|
||||
back to a thing they can change rather than wondering
|
||||
where 30 came from. */}
|
||||
<td className="dim" style={{ padding: '3px 0 3px 12px', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
|
||||
{b.from || ''}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── What this run changed in the world (Phase 8) ──
|
||||
The WHOLE ledger, reverted rows included: "how much did last night's
|
||||
invasion actually spawn, and did all of it come back" is one question
|
||||
with two halves, and a list of only the failures answers neither.
|
||||
Shown on finished runs for the same reason the caps meter is. */}
|
||||
{(resources.length > 0 || run.cleanupStatus === 'incomplete') && (
|
||||
<div
|
||||
className="panel-flat"
|
||||
style={{
|
||||
padding: 14,
|
||||
marginBottom: 14,
|
||||
borderLeft: `3px solid ${unresolved > 0 ? '#d9c184' : 'var(--rule)'}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
|
||||
What this run changed
|
||||
</h3>
|
||||
{/* The manual retry. Offered only on a terminal run, because a run
|
||||
still in flight has a ledger that is still growing and reverting a
|
||||
resource the next step is about to use would be undoing an event
|
||||
while it is happening. */}
|
||||
{isTerminalRun(run.status) && unresolved > 0 && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.cleanupEventRun(run.id))}>
|
||||
Try cleanup again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
|
||||
{unresolved > 0 ? (
|
||||
<>
|
||||
{unresolved} of these {unresolved === 1 ? 'is' : 'are'} still unresolved. The runner
|
||||
gives them back on its own and stops asking after a few tries;{' '}
|
||||
<em>Try cleanup again</em> clears that count and asks once more.
|
||||
</>
|
||||
) : (
|
||||
'Everything this run created or borrowed has been given back.'
|
||||
)}
|
||||
</p>
|
||||
{resources.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>
|
||||
Nothing named — a step changed the world and its answer never arrived, so core kept the
|
||||
record it wrote beforehand and will ask the module to undo it by key.
|
||||
</p>
|
||||
) : (
|
||||
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<tbody>
|
||||
{resources.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{r.kind}</code>{' '}
|
||||
<code className="dim" style={{ fontSize: '0.78rem' }}>{r.ref}</code>
|
||||
</td>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', color: RESOURCE_COLOR[r.status] }}>
|
||||
{RESOURCE_WORD[r.status] || r.status}
|
||||
</td>
|
||||
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
|
||||
{r.module}
|
||||
{r.leaseUntil ? ` · until ${clock(r.leaseUntil)}` : ''}
|
||||
{r.revertAttempts > 0 ? ` · ${r.revertAttempts} attempt${r.revertAttempts === 1 ? '' : 's'}` : ''}
|
||||
</td>
|
||||
<td className="dim" style={{ width: '100%', padding: '3px 0', fontSize: '0.78rem' }}>
|
||||
{r.lastError || ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Who took part (Phase 10) ──
|
||||
Shown whenever a module has reported anybody, published or not — and the
|
||||
difference between the two is the whole point of the line under the
|
||||
heading. A run whose participants are collected and unranked is a real
|
||||
state, not an error: an author has not placed a `core.results.publish`
|
||||
step, or has not run it yet. Saying "not published yet" is what stops
|
||||
somebody reading this table as the final standings. */}
|
||||
{participants.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
|
||||
Who took part
|
||||
</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
|
||||
{run.resultsPublishedAt ? (
|
||||
<>Results published {clock(run.resultsPublishedAt)}. Ranked best first.</>
|
||||
) : (
|
||||
<>
|
||||
{participants.length} recorded, and the results have not been published — nothing
|
||||
outside this page shows them, and nobody has a rank yet. Publishing is a{' '}
|
||||
<code style={{ fontSize: '0.78rem' }}>core.results.publish</code> step in the event
|
||||
itself.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<tbody>
|
||||
{participants.slice(0, PARTICIPANTS_SHOWN).map((p) => (
|
||||
<tr key={p.memberKey}>
|
||||
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', width: 34, textAlign: 'right' }}>
|
||||
{p.rank ?? ''}
|
||||
</td>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{p.memberKey}</code>
|
||||
</td>
|
||||
{/* A participant with no `userId` is not a defect: it is
|
||||
somebody who turned up without a linked website account,
|
||||
and the module is the only thing that could have known
|
||||
otherwise. Saying so beats a blank cell. */}
|
||||
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
|
||||
{p.userId ? `account ${p.userId}` : 'no linked account'}
|
||||
</td>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>{p.score}</td>
|
||||
<td className="dim" style={{ width: '100%', padding: '3px 0', fontSize: '0.78rem' }}>
|
||||
{clock(p.joinedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{participants.length > PARTICIPANTS_SHOWN && (
|
||||
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.78rem' }}>
|
||||
and {participants.length - PARTICIPANTS_SHOWN} more.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Waiting on a person ── */}
|
||||
{parked.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid #d9c184' }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>Waiting on a person</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
|
||||
Nothing else in this phase runs until each of these is confirmed. There is no timeout —
|
||||
a cue posted on Friday is still waiting on Monday.
|
||||
</p>
|
||||
{parked.map((step) => (
|
||||
<div key={step.id} style={{ marginBottom: 10 }}>
|
||||
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.86rem' }}>
|
||||
{step.params?.instruction || step.actionId}
|
||||
{step.params?.assignee && <span className="dim"> — for {step.params.assignee}</span>}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<label style={{ flex: '1 1 240px' }}>
|
||||
<span className="field-label">What you did (optional)</span>
|
||||
<input className="input" value={notes[step.id] || ''}
|
||||
onChange={(e) => setNotes((n) => ({ ...n, [step.id]: e.target.value }))} />
|
||||
</label>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.confirmEventStep(run.id, step.id, notes[step.id]))}>
|
||||
Confirm — done
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.skipEventStep(run.id, step.id, notes[step.id]))}>
|
||||
Skip it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── The steps ── */}
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '0 0 8px' }}>Steps</h3>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Phase</th>
|
||||
<th className="adm-th">#</th>
|
||||
<th className="adm-th">Action</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th">Attempts</th>
|
||||
<th className="adm-th">Detail</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{steps.map((step) => {
|
||||
const c = stepControlsFor(run, step, steps)
|
||||
return (
|
||||
<tr key={step.id}>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem' }}>
|
||||
{step.phase}
|
||||
{step.phase === run.currentPhase && <span className="dim"> ·now</span>}
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{step.seq + 1}</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{step.actionId}</code>
|
||||
<div className="dim" style={{ fontSize: '0.74rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
|
||||
{JSON.stringify(step.params)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: STEP_COLOR[step.status] || undefined }}>
|
||||
{isParked(step) ? <span style={{ color: '#d9c184' }}>waiting</span> : step.status}
|
||||
</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
|
||||
{step.attempts}
|
||||
{step.dueAt && new Date(step.dueAt) > new Date() && (
|
||||
<div style={{ fontSize: '0.74rem' }}>due {clock(step.dueAt)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.78rem', maxWidth: 280, overflowWrap: 'anywhere' }}>
|
||||
{step.lastError || ''}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
{c.retry && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem', marginLeft: 4 }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.retryEventStep(run.id, step.id))}>
|
||||
Retry & resume
|
||||
</button>
|
||||
)}
|
||||
{c.skip && !isParked(step) && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem', marginLeft: 4 }} disabled={busy}
|
||||
onClick={() => act(() => api.admin.skipEventStep(run.id, step.id, reason))}>
|
||||
Skip
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{steps.length === 0 && (
|
||||
<tr><td className="adm-td dim" colSpan={7}>No steps have been materialised yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
|
||||
Steps run strictly in order within a phase, and the phase ends when every one of them has
|
||||
finished. A failed step is not retried by the runner past its attempt limit — resuming a
|
||||
paused run carries the phase past it, and <em>Retry & resume</em> puts the step the run is
|
||||
stopped at back in the queue.
|
||||
</p>
|
||||
|
||||
{/* ── The log ── */}
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '22px 0 8px' }}>Log</h3>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||
The run’s own diagnostic record, newest first — this is what answers “why didn’t phase 3
|
||||
start?” without reading server logs. Who published or started what is recorded separately, in
|
||||
the activity log.
|
||||
</p>
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<tbody>
|
||||
{lines.map((line) => (
|
||||
<tr key={line.id}>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.76rem', whiteSpace: 'nowrap' }}>{clock(line.at)}</td>
|
||||
<td className="adm-td dim" style={{ fontSize: '0.76rem' }}>{line.phase || ''}</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem' }}>{describeLogLine(line)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{lines.length === 0 && <tr><td className="adm-td dim">Nothing logged yet.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
265
client/src/routes/admin/views/EventsAdmin.jsx
Normal file
265
client/src/routes/admin/views/EventsAdmin.jsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { runStatusWord, isTerminalRun } from '../../../lib/eventAuthoring.js'
|
||||
|
||||
// Admin → Events (EVENTS.md §I, Phase 3).
|
||||
//
|
||||
// Two tables on one screen: the definitions an operator authors, and the runs
|
||||
// those definitions have produced. They are together rather than on two nav rows
|
||||
// because the question this screen exists to answer is one question — "what is
|
||||
// scheduled, and what is happening right now" — and the second half of it is the
|
||||
// one somebody opens at 8pm on a Friday.
|
||||
//
|
||||
// **The waiting badge is the whole reason the run table is here rather than
|
||||
// buried a click away.** A run parked on a GM cue looks perfectly healthy: it is
|
||||
// `running`, nothing has failed, and it will stay that way for ever because it
|
||||
// is waiting for a person who does not know they are being waited for. The count
|
||||
// comes from the run row itself (`waitingSteps`), so a run needs nobody to open
|
||||
// it before it can say so.
|
||||
//
|
||||
// **The calendar is a separate screen, not a third table here.** It answers
|
||||
// "when", this one answers "what" — and Phase 4, which built it, also made a
|
||||
// definition able to carry a recurrence, so the two questions stopped having the
|
||||
// same answer the moment an occurrence could exist before anybody pressed Start.
|
||||
|
||||
const STATE_WORD = { draft: 'Draft', ready: 'Ready', archived: 'Archived' }
|
||||
|
||||
const STATUS_COLOR = {
|
||||
failed: '#d98b84',
|
||||
missed: '#d98b84',
|
||||
paused: '#d9c184',
|
||||
cancelled: 'var(--muted)',
|
||||
running: '#8fc79a',
|
||||
}
|
||||
|
||||
const HEALTH_COLOR = { degraded: '#d9c184', stalled: '#d98b84' }
|
||||
|
||||
const when = (value) => (value ? new Date(value).toLocaleString() : '—')
|
||||
|
||||
export default function EventsAdmin() {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [events, setEvents] = useState([])
|
||||
const [runs, setRuns] = useState([])
|
||||
const [state, setState] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState(null)
|
||||
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const mayAuthor = isAdmin || user?.role === 'editor'
|
||||
|
||||
const load = useCallback(async (nextState) => {
|
||||
const [defs, runList] = await Promise.all([
|
||||
api.admin.listEvents(nextState || undefined),
|
||||
api.admin.listEventRuns({ limit: 50 }),
|
||||
])
|
||||
setEvents(defs.events || [])
|
||||
setRuns(runList.runs || [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load(state)
|
||||
if (alive) setError(null)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load, state])
|
||||
|
||||
// "Start now" is an occurrence whose instant is the present, not a separate
|
||||
// concept — the same route a scheduled occurrence will use in Phase 4. Admin
|
||||
// only, deliberately (§N2): starting commits the deployment to everything the
|
||||
// definition contains, unattended.
|
||||
const startNow = async (event) => {
|
||||
setBusy(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
const result = await api.admin.startEventRun(event.id, {})
|
||||
navigate(`/admin/events/runs/${result.run.id}`)
|
||||
} catch (err) {
|
||||
setNotice(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !events.length && !runs.length) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
const live = runs.filter((r) => !isTerminalRun(r.status))
|
||||
const waiting = live.filter((r) => r.waitingSteps > 0)
|
||||
|
||||
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: 620 }}>
|
||||
Scheduled, bounded, audited changes to the live world. A definition is authored as a draft,
|
||||
published as an immutable version, and every occurrence of it runs against the version it
|
||||
pinned. A definition can repeat — once, weekly, or on the nth weekday of the month, in its
|
||||
own timezone — and the <Link to="/admin/events/calendar">calendar</Link> is where those
|
||||
occurrences are read.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||||
<label>
|
||||
<span className="field-label">Show</span>
|
||||
<select className="select" value={state} onChange={(e) => setState(e.target.value)}>
|
||||
<option value="">All definitions</option>
|
||||
<option value="draft">Drafts</option>
|
||||
<option value="ready">Ready</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</label>
|
||||
<Link className="pill" style={{ fontSize: '0.74rem' }} to="/admin/events/calendar">
|
||||
Calendar
|
||||
</Link>
|
||||
{mayAuthor && (
|
||||
<Link className="pill" style={{ fontSize: '0.74rem' }} to="/admin/events/new">
|
||||
New event
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<p className="sans" style={{ fontSize: '0.84rem', color: '#d98b84', marginTop: 0 }}>{notice}</p>
|
||||
)}
|
||||
|
||||
{waiting.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: '12px 14px', marginBottom: 16, borderLeft: '3px solid #d9c184' }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem' }}>
|
||||
<strong>{waiting.length === 1 ? 'One run is' : `${waiting.length} runs are`} waiting on a
|
||||
person.</strong>{' '}
|
||||
<span className="dim">
|
||||
A cue holds its phase until somebody confirms it was done in-client — nothing else will
|
||||
move it.
|
||||
</span>
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
|
||||
{waiting.map((r) => (
|
||||
<Link key={r.id} className="pill" style={{ fontSize: '0.74rem' }} to={`/admin/events/runs/${r.id}`}>
|
||||
{r.definitionTitle} · {r.waitingSteps} waiting
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '0 0 8px' }}>Definitions</h3>
|
||||
{events.length === 0 ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
{state ? 'Nothing matches that filter.' : 'No events have been authored yet.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Event</th>
|
||||
<th className="adm-th">State</th>
|
||||
<th className="adm-th">Version</th>
|
||||
<th className="adm-th">Series</th>
|
||||
<th className="adm-th">Updated</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td className="adm-td" style={{ fontSize: '0.85rem' }}>
|
||||
<Link to={`/admin/events/${e.id}`}>{e.title}</Link>
|
||||
<div className="dim" style={{ fontSize: '0.76rem' }}>{e.slug}</div>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{STATE_WORD[e.state] || e.state}</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{e.currentVersion ? `v${e.currentVersion}` : <span className="dim">unpublished</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{e.seriesName || <span className="dim">—</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem', whiteSpace: 'nowrap' }}>{when(e.updatedAt)}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
{/* Start is admin only and the button follows the route: an
|
||||
editor sees the definition and cannot commit the
|
||||
deployment to running it. */}
|
||||
{isAdmin && e.state === 'ready' && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
|
||||
disabled={busy} onClick={() => startNow(e)}>
|
||||
Start now
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '22px 0 8px' }}>
|
||||
Recent runs
|
||||
{live.length > 0 && <span className="dim" style={{ fontWeight: 400 }}> · {live.length} in flight</span>}
|
||||
</h3>
|
||||
{runs.length === 0 ? (
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Nothing has run yet.</p>
|
||||
) : (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Occurrence</th>
|
||||
<th className="adm-th">Event</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th">Phase</th>
|
||||
<th className="adm-th">Health</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="adm-td" style={{ fontSize: '0.8rem', whiteSpace: 'nowrap' }}>
|
||||
<Link to={`/admin/events/runs/${r.id}`}>{when(r.scheduledFor)}</Link>
|
||||
{r.rehearsal && <span className="dim" style={{ fontSize: '0.74rem' }}> · rehearsal</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.definitionTitle} <span className="dim">v{r.version}</span>
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: STATUS_COLOR[r.status] || undefined }}>
|
||||
{runStatusWord(r.status)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||||
{r.currentPhase || <span className="dim">—</span>}
|
||||
</td>
|
||||
<td className="adm-td" style={{ fontSize: '0.82rem', color: HEALTH_COLOR[r.health] || undefined }}>
|
||||
{r.health === 'ok' ? <span className="dim">ok</span> : r.health}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right', fontSize: '0.78rem' }}>
|
||||
{r.waitingSteps > 0 && (
|
||||
<span style={{ color: '#d9c184' }}>
|
||||
waiting on {r.waitingSteps === 1 ? 'a person' : `${r.waitingSteps} people`}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
457
client/src/routes/admin/views/EventsCalendar.jsx
Normal file
457
client/src/routes/admin/views/EventsCalendar.jsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { runStatusWord, isProjected } from '../../../lib/eventAuthoring.js'
|
||||
|
||||
// Admin → Events → Calendar (EVENTS.md §I, Phase 4).
|
||||
//
|
||||
// **This screen is the deliverable.** What this feature replaces is a WordPress
|
||||
// calendar plugin with no series field, no recurrence and no results — so a
|
||||
// month grid that knows about arcs, repeats and local time is not decoration
|
||||
// here, it is the point.
|
||||
//
|
||||
// **Two kinds of entry, drawn differently on purpose.** A solid one is a *run*:
|
||||
// a real row with a status, a pinned version and a console, and somebody can
|
||||
// cancel it. A dashed one is a *projection*: arithmetic past the runner's
|
||||
// fourteen-day horizon, with no row behind it, nothing committed and nothing to
|
||||
// open. An operator who treats a forecast as a booking has been misled by the
|
||||
// UI, not by the server, so the difference is drawn rather than merely stated —
|
||||
// and the legend says which is which.
|
||||
//
|
||||
// **The grid's date axis is the READER's zone; each entry's time is the
|
||||
// EVENT's.** §E gives the timezone to the event because every listing this
|
||||
// replaces is written in the shard's local zone, but "what is happening this
|
||||
// month" is a question about the month the person reading is living in. So the
|
||||
// cell an event lands in is the reader's date, and the time beside it always
|
||||
// carries the event's own zone — `20:00 Europe/Berlin` misreads as nothing.
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
|
||||
const STATUS_COLOR = {
|
||||
failed: '#d98b84',
|
||||
missed: '#d98b84',
|
||||
paused: '#d9c184',
|
||||
cancelled: 'var(--muted)',
|
||||
running: '#8fc79a',
|
||||
}
|
||||
|
||||
/** The event's own wall clock, which is the only time worth showing beside it. */
|
||||
function localTime(instant, timezone) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
timeZone: timezone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).format(new Date(instant))
|
||||
} catch {
|
||||
return new Date(instant).toISOString().slice(11, 16)
|
||||
}
|
||||
}
|
||||
|
||||
/** The reader's own date key, which is what places an entry in a cell. */
|
||||
const readerDayKey = (instant) => {
|
||||
const d = new Date(instant)
|
||||
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The six-week grid a month view draws, Monday first.
|
||||
*
|
||||
* Always six weeks rather than however many the month needs: a grid that
|
||||
* changes height as you page through it is a grid whose rows move under the
|
||||
* cursor.
|
||||
*/
|
||||
function monthGrid(year, month) {
|
||||
const first = new Date(year, month, 1)
|
||||
const offset = (first.getDay() + 6) % 7
|
||||
const start = new Date(year, month, 1 - offset)
|
||||
return Array.from({ length: 42 }, (_, i) => new Date(start.getTime() + i * DAY_MS))
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
]
|
||||
|
||||
export default function EventsCalendar() {
|
||||
const { user } = useAuth()
|
||||
const today = useMemo(() => new Date(), [])
|
||||
const [year, setYear] = useState(today.getFullYear())
|
||||
const [month, setMonth] = useState(today.getMonth())
|
||||
const [view, setView] = useState('month')
|
||||
const [seriesId, setSeriesId] = useState('')
|
||||
const [series, setSeries] = useState([])
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [managingSeries, setManagingSeries] = useState(false)
|
||||
|
||||
const mayAuthor = user?.role === 'admin' || user?.role === 'editor'
|
||||
|
||||
// The window is the grid's own span, not the month's: an entry in the leading
|
||||
// or trailing week of the grid belongs to a neighbouring month and still has
|
||||
// to be fetched, or the first row of every month renders empty.
|
||||
const grid = useMemo(() => monthGrid(year, month), [year, month])
|
||||
const window = useMemo(() => {
|
||||
if (view === 'month') {
|
||||
return { from: grid[0], to: new Date(grid[41].getTime() + DAY_MS) }
|
||||
}
|
||||
// The list view answers a different question — "what is coming" — so it runs
|
||||
// forward from now rather than over a calendar month.
|
||||
const from = new Date()
|
||||
return { from, to: new Date(from.getTime() + 60 * DAY_MS) }
|
||||
}, [view, grid])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [calendar, seriesList] = await Promise.all([
|
||||
api.admin.eventCalendar({
|
||||
from: window.from.toISOString(),
|
||||
to: window.to.toISOString(),
|
||||
seriesId: seriesId || undefined,
|
||||
}),
|
||||
api.admin.eventSeries(),
|
||||
])
|
||||
setData(calendar)
|
||||
setSeries(seriesList.series || [])
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [window.from, window.to, seriesId])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const byDay = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const entry of data?.entries || []) {
|
||||
const key = readerDayKey(entry.scheduledFor)
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key).push(entry)
|
||||
}
|
||||
return map
|
||||
}, [data])
|
||||
|
||||
const step = (delta) => {
|
||||
const next = new Date(year, month + delta, 1)
|
||||
setYear(next.getFullYear())
|
||||
setMonth(next.getMonth())
|
||||
}
|
||||
|
||||
if (loading && !data) return <Loading />
|
||||
if (error && !data) return <ErrorState error={error} onRetry={load} />
|
||||
|
||||
const horizon = data?.horizon ? new Date(data.horizon) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{/*
|
||||
The stepper belongs to the MONTH view only. The list answers "what is
|
||||
coming" and runs sixty days forward from now whatever month is
|
||||
selected -- so paging it would be three controls that visibly do
|
||||
nothing, which is the one thing this feature has refused since Phase
|
||||
1. The heading says which question is being asked instead.
|
||||
*/}
|
||||
{view === 'month' && (
|
||||
<>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => step(-1)}>
|
||||
←
|
||||
</button>
|
||||
<strong className="sans" style={{ fontSize: '0.95rem', minWidth: 150, textAlign: 'center' }}>
|
||||
{`${MONTH_NAMES[month]} ${year}`}
|
||||
</strong>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => step(1)}>
|
||||
→
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
|
||||
disabled={year === today.getFullYear() && month === today.getMonth()}
|
||||
onClick={() => { setYear(today.getFullYear()); setMonth(today.getMonth()) }}>
|
||||
Today
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{view === 'list' && (
|
||||
<strong className="sans" style={{ fontSize: '0.95rem' }}>The next 60 days</strong>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', alignItems: 'center' }}>
|
||||
<select className="select" value={seriesId} onChange={(e) => setSeriesId(e.target.value)}
|
||||
style={{ minWidth: 170 }}>
|
||||
<option value="">Every series</option>
|
||||
{series.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
<button type="button" className="pill" aria-pressed={view === 'month'}
|
||||
style={{ fontSize: '0.72rem', opacity: view === 'month' ? 1 : 0.5 }}
|
||||
onClick={() => setView('month')}>
|
||||
Month
|
||||
</button>
|
||||
<button type="button" className="pill" aria-pressed={view === 'list'}
|
||||
style={{ fontSize: '0.72rem', opacity: view === 'list' ? 1 : 0.5 }}
|
||||
onClick={() => setView('list')}>
|
||||
List
|
||||
</button>
|
||||
{mayAuthor && (
|
||||
<button type="button" className="pill" aria-pressed={managingSeries}
|
||||
style={{ fontSize: '0.72rem', opacity: managingSeries ? 1 : 0.6 }}
|
||||
onClick={() => setManagingSeries((v) => !v)}>
|
||||
Series
|
||||
</button>
|
||||
)}
|
||||
<Link to="/admin/events" className="pill" style={{ fontSize: '0.72rem' }}>Events</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The legend is not optional. The whole screen rests on the reader
|
||||
knowing that a dashed entry is not a booking. */}
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||
<span style={{ ...chip, borderStyle: 'solid' }}>Scheduled run</span> is a real occurrence with
|
||||
a console — it can be opened, paused and cancelled.{' '}
|
||||
<span style={{ ...chip, borderStyle: 'dashed', opacity: 0.7 }}>Forecast</span> is what the
|
||||
recurrence works out to beyond the {data?.horizonDays ?? 14}-day horizon: nothing is
|
||||
committed yet and there is nothing to open.
|
||||
{horizon && ` Everything up to ${horizon.toLocaleDateString()} is real.`}
|
||||
</p>
|
||||
|
||||
{managingSeries && <SeriesManager series={series} onChanged={load} />}
|
||||
|
||||
{data?.truncated && (
|
||||
<p className="sans" style={{ fontSize: '0.8rem', color: '#d9c184' }}>
|
||||
This window has more than the calendar will draw. Narrow it by series, or page to a
|
||||
shorter span.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{view === 'month' ? (
|
||||
<div className="panel-flat" style={{ padding: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 4 }}>
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((d) => (
|
||||
<div key={d} className="sans dim" style={{ fontSize: '0.72rem', textAlign: 'center', padding: '2px 0' }}>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
{grid.map((day) => {
|
||||
const entries = byDay.get(readerDayKey(day)) || []
|
||||
const outside = day.getMonth() !== month
|
||||
const isToday = readerDayKey(day) === readerDayKey(today)
|
||||
return (
|
||||
<div key={day.toISOString()}
|
||||
style={{
|
||||
minHeight: 84,
|
||||
padding: 4,
|
||||
borderRadius: 4,
|
||||
border: isToday ? '1px solid var(--accent, #8fc79a)' : '1px solid transparent',
|
||||
background: outside ? 'transparent' : 'rgba(255,255,255,0.03)',
|
||||
opacity: outside ? 0.4 : 1,
|
||||
}}>
|
||||
<div className="sans dim" style={{ fontSize: '0.7rem', marginBottom: 3 }}>
|
||||
{day.getDate()}
|
||||
</div>
|
||||
{entries.map((entry) => <EntryChip key={entryKey(entry)} entry={entry} />)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel-flat" style={{ padding: 4 }}>
|
||||
{(data?.entries || []).length === 0 ? (
|
||||
<p className="sans dim" style={{ padding: 14, margin: 0, fontSize: '0.84rem' }}>
|
||||
Nothing is scheduled in the next sixty days.{' '}
|
||||
{mayAuthor && <Link to="/admin/events/new">Author an event</Link>}
|
||||
</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Event</th>
|
||||
<th>Series</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.entries || []).map((entry) => (
|
||||
<tr key={entryKey(entry)} style={{ opacity: isProjected(entry) ? 0.7 : 1 }}>
|
||||
<td className="sans" style={{ fontSize: '0.82rem', whiteSpace: 'nowrap' }}>
|
||||
{new Date(entry.scheduledFor).toLocaleDateString()}{' '}
|
||||
<span className="dim">
|
||||
{localTime(entry.scheduledFor, entry.timezone)} {entry.timezone}
|
||||
</span>
|
||||
</td>
|
||||
<td className="sans" style={{ fontSize: '0.84rem' }}>
|
||||
{entry.runId ? (
|
||||
<Link to={`/admin/events/runs/${entry.runId}`}>{entry.title}</Link>
|
||||
) : (
|
||||
<Link to={`/admin/events/${entry.definitionId}`}>{entry.title}</Link>
|
||||
)}
|
||||
{entry.adjusted === 'gap' && (
|
||||
<span className="dim" title="Daylight saving skips the time this was authored at, so it moves forward to the next one that exists">
|
||||
{' '}(clocks change)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="sans dim" style={{ fontSize: '0.8rem' }}>{entry.seriesName || '—'}</td>
|
||||
<td className="sans" style={{ fontSize: '0.8rem', color: STATUS_COLOR[entry.status] }}>
|
||||
{isProjected(entry) ? <span className="dim">Forecast</span> : runStatusWord(entry.status)}
|
||||
{entry.waitingSteps > 0 && (
|
||||
<span style={{ color: '#d9c184' }}> · waiting on a person</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// A projection has no run id, so the definition and the instant are its
|
||||
// identity — the same pair the server dedupes projections against.
|
||||
const entryKey = (entry) =>
|
||||
entry.runId ? `run-${entry.runId}` : `proj-${entry.definitionId}-${entry.scheduledFor}`
|
||||
|
||||
const chip = {
|
||||
display: 'inline-block',
|
||||
padding: '0 5px',
|
||||
borderRadius: 3,
|
||||
borderWidth: 1,
|
||||
border: '1px solid var(--muted)',
|
||||
fontSize: '0.72rem',
|
||||
}
|
||||
|
||||
/**
|
||||
* The arcs, managed where they are used.
|
||||
*
|
||||
* A series is a label, not authored content — nothing pins one and no run
|
||||
* references one — so this is a small inline panel rather than a screen of its
|
||||
* own, and it lives on the calendar because the calendar is what makes an arc
|
||||
* visible in the first place. §I: *"Royal Spy Mission → Risky Partner → Message
|
||||
* From the Void" is continuity that exists nowhere in the tooling this replaces.*
|
||||
*
|
||||
* The delete is a real delete, and it says what it will detach before it
|
||||
* happens: `series_id` is ON DELETE SET NULL, so the definitions survive without
|
||||
* an arc and re-attaching one is a dropdown in the editor. Nothing is destroyed,
|
||||
* which is why this is the one delete in this feature that is not an archive.
|
||||
*/
|
||||
function SeriesManager({ series, onChanged }) {
|
||||
const [name, setName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [problem, setProblem] = useState(null)
|
||||
|
||||
const run = async (fn) => {
|
||||
setBusy(true)
|
||||
setProblem(null)
|
||||
try {
|
||||
await fn()
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
setProblem(err?.body?.errors?.join('; ') || err?.message || 'That did not work')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 12 }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>Series</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.78rem' }}>
|
||||
An arc several events form together. The order here is where a series sits among the
|
||||
others; where an event sits <em>within</em> its arc is that event’s own order, in the
|
||||
editor.
|
||||
</p>
|
||||
|
||||
{problem && (
|
||||
<p className="sans" style={{ fontSize: '0.8rem', color: '#d98b84' }}>{problem}</p>
|
||||
)}
|
||||
|
||||
{series.map((s) => (
|
||||
<div key={s.id} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
||||
<input className="input" defaultValue={s.name} disabled={busy} style={{ flex: 1 }}
|
||||
onBlur={(e) => {
|
||||
const next = e.target.value.trim()
|
||||
if (next && next !== s.name) {
|
||||
run(() => api.admin.updateEventSeries(s.id, { name: next, description: s.description, ordering: s.ordering }))
|
||||
}
|
||||
}} />
|
||||
<input className="input" type="number" defaultValue={s.ordering} disabled={busy}
|
||||
style={{ width: 72 }} aria-label={`Order of ${s.name}`}
|
||||
onBlur={(e) => {
|
||||
const next = Number(e.target.value)
|
||||
if (Number.isInteger(next) && next !== s.ordering) {
|
||||
run(() => api.admin.updateEventSeries(s.id, { name: s.name, description: s.description, ordering: next }))
|
||||
}
|
||||
}} />
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem', minWidth: 70 }}>
|
||||
{s.definitionCount} event{s.definitionCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
<button type="button" className="pill" disabled={busy} style={{ fontSize: '0.7rem' }}
|
||||
onClick={() => {
|
||||
// The count is in the question, because the consequence of this
|
||||
// delete is entirely about the rows it does not delete.
|
||||
const ask = s.definitionCount
|
||||
? `Delete "${s.name}"? ${s.definitionCount} event(s) will keep their content and lose this series.`
|
||||
: `Delete "${s.name}"?`
|
||||
// eslint-disable-next-line no-alert
|
||||
if (window.confirm(ask)) run(() => api.admin.deleteEventSeries(s.id))
|
||||
}}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||
<input className="input" value={name} placeholder="New series name" disabled={busy}
|
||||
style={{ flex: 1 }} onChange={(e) => setName(e.target.value)} />
|
||||
<button type="button" className="pill" disabled={busy || !name.trim()} style={{ fontSize: '0.72rem' }}
|
||||
onClick={() => run(async () => {
|
||||
await api.admin.createEventSeries({ name: name.trim() })
|
||||
setName('')
|
||||
})}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EntryChip({ entry }) {
|
||||
const projected = isProjected(entry)
|
||||
const to = entry.runId ? `/admin/events/runs/${entry.runId}` : `/admin/events/${entry.definitionId}`
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="sans"
|
||||
title={`${entry.title} — ${localTime(entry.scheduledFor, entry.timezone)} ${entry.timezone}${projected ? ' (forecast)' : ` — ${runStatusWord(entry.status)}`}`}
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.7rem',
|
||||
padding: '1px 4px',
|
||||
marginBottom: 2,
|
||||
borderRadius: 3,
|
||||
borderLeft: `2px ${projected ? 'dashed' : 'solid'} ${STATUS_COLOR[entry.status] || 'var(--accent, #8fc79a)'}`,
|
||||
background: projected ? 'transparent' : 'rgba(255,255,255,0.05)',
|
||||
opacity: projected ? 0.7 : 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
textDecoration: 'none',
|
||||
}}>
|
||||
<span className="dim">{localTime(entry.scheduledFor, entry.timezone)}</span> {entry.title}
|
||||
{entry.waitingSteps > 0 && <span style={{ color: '#d9c184' }}> ●</span>}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
84
client/src/routes/player/PlayerEvents.jsx
Normal file
84
client/src/routes/player/PlayerEvents.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
// This account's event participation (EVENTS.md §J, Phase 14a).
|
||||
//
|
||||
// **The screen's one real design decision is what an unranked row says.** A run
|
||||
// whose participants were collected but whose results have not been published
|
||||
// has a score and no rank, and that is a real state rather than an error — it is
|
||||
// the same state the admin run console has shown since Phase 10. Rendering "—"
|
||||
// with nothing explaining it would read as a bug; the row says "not published",
|
||||
// which is a fact about the event rather than about the reader.
|
||||
//
|
||||
// The list is keyset-paged on the participation row's own id, not offset-paged:
|
||||
// it gains a row every time the reader attends something.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { eventDateTime } from '../../lib/eventCalendar.js'
|
||||
|
||||
const PAGE = 25
|
||||
|
||||
export default function PlayerEvents() {
|
||||
const [pages, setPages] = useState([])
|
||||
const [more, setMore] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const result = await api.player.eventHistory({ limit: PAGE })
|
||||
setPages([result.entries || []])
|
||||
setMore((result.entries || []).length === PAGE)
|
||||
return result
|
||||
}, [])
|
||||
const { loading, error } = useAsync(load)
|
||||
|
||||
const entries = pages.flat()
|
||||
|
||||
const loadMore = async () => {
|
||||
const last = entries[entries.length - 1]
|
||||
if (!last) return
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const result = await api.player.eventHistory({ limit: PAGE, before: last.id })
|
||||
setPages((p) => [...p, result.entries || []])
|
||||
setMore((result.entries || []).length === PAGE)
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message="Could not load your event history." />
|
||||
if (loading) return <Loading />
|
||||
if (entries.length === 0) {
|
||||
return <EmptyState>You have not taken part in an event yet.</EmptyState>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{entries.map((e) => (
|
||||
<div key={e.id} className="panel" style={{ padding: '16px 20px', display: 'flex', gap: 18, flexWrap: 'wrap' }}>
|
||||
<span style={{ flex: 1, minWidth: 240 }}>
|
||||
<Link to={`/site/events/${e.slug}?run=${e.runId}`} style={{ fontSize: '1.05rem' }}>
|
||||
{e.title}
|
||||
</Link>
|
||||
<div className="dim sans" style={{ fontSize: '0.82rem', marginTop: 4 }}>
|
||||
{eventDateTime(e.scheduledFor, e.timezone)}
|
||||
{e.seriesName && ` · ${e.seriesName}`}
|
||||
</div>
|
||||
</span>
|
||||
<span style={{ textAlign: 'right', minWidth: 140 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)' }}>
|
||||
{e.rank != null ? `Rank ${e.rank}` : <span className="dim">Results not published</span>}
|
||||
</div>
|
||||
<div className="dim sans" style={{ fontSize: '0.82rem' }}>Score {e.score}</div>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{more && (
|
||||
<button className="btn" onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore ? 'Loading…' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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,14 @@ 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.
|
||||
// Participation history (Phase 14a). A calendar rather than a trophy: the row
|
||||
// is every event this account attended, ranked or not, and most of them will
|
||||
// never have a result published against them at all.
|
||||
const IconCalendar = () => <Icon><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M3 10h18M8 3v4M16 3v4" /></Icon>
|
||||
|
||||
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
|
||||
@@ -47,8 +56,10 @@ const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-
|
||||
// UO module registers it again at `/player/uo/characters`, in this position,
|
||||
// with `order: 0`.
|
||||
export const NAV = [
|
||||
{ to: '/account/events', label: 'Events', icon: IconCalendar },
|
||||
{ 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 +70,7 @@ const TITLES = {
|
||||
'/account': 'Account',
|
||||
'/account/appeals': 'Appeals',
|
||||
'/account/notifications': 'Notifications',
|
||||
'/account/notifications/settings': 'Notification settings',
|
||||
}
|
||||
|
||||
function moduleTitle(baseNav, pathname) {
|
||||
@@ -186,9 +198,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>
|
||||
|
||||
190
client/src/routes/public/EventPage.jsx
Normal file
190
client/src/routes/public/EventPage.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
// One event's public page (EVENTS.md § API surface, Phase 14a).
|
||||
//
|
||||
// The storyline, its arc, what is live, what is next, what happened recently,
|
||||
// and a results table once an occurrence has published one.
|
||||
//
|
||||
// **`?run=` is read from the URL and passed straight through**, because that is
|
||||
// what an announcement's link carries. The page lives at the definition's slug —
|
||||
// one stable address, so a link posted in Discord survives a retitle — and the
|
||||
// occurrence has to be in the query string or a mail about last Friday's
|
||||
// invasion would open next Friday's.
|
||||
//
|
||||
// **The error is checked before the form.** Phase 13 found the inverse of this
|
||||
// on the admin editor: `if (loading || !form) return <Loading/>` above the error
|
||||
// branch left a failed load spinning for ever with nothing on screen naming the
|
||||
// problem. Order matters, and the order is error first.
|
||||
|
||||
import { useParams, useSearchParams, Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { eventDateTime, statusWord } from '../../lib/eventCalendar.js'
|
||||
|
||||
export default function EventPage() {
|
||||
const { slug } = useParams()
|
||||
const [params] = useSearchParams()
|
||||
const run = params.get('run')
|
||||
const { loading, error, data } = useAsync(() => api.publicEvent(slug, run), [slug, run])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<ErrorState message="That event could not be found." />
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link to="/site/events">Back to the calendar</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<Loading />
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const event = data.event
|
||||
const headline = event.current || event.next
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<PageHeader
|
||||
eyebrow={event.series ? event.series.name : 'Event'}
|
||||
title={event.title}
|
||||
lead={event.summary || ''}
|
||||
/>
|
||||
|
||||
{event.series && (
|
||||
<p className="sans" style={{ marginTop: -12 }}>
|
||||
<Link to={`/site/events/series/${event.series.slug}`}>Part of {event.series.name}</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The one fact a visitor came for, before the storyline rather than
|
||||
after it: whether it is happening now, and if not, when it next is. */}
|
||||
<div
|
||||
className="panel"
|
||||
style={{
|
||||
padding: '18px 22px',
|
||||
marginBottom: 24,
|
||||
borderColor: event.live ? '#8fc79a' : undefined,
|
||||
}}
|
||||
>
|
||||
{event.live ? (
|
||||
<>
|
||||
<div
|
||||
className="sans"
|
||||
style={{ color: '#8fc79a', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', fontSize: '0.74rem' }}
|
||||
>
|
||||
Happening now
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--head)', fontSize: '1.1rem' }}>
|
||||
{/* The phase LABEL, and only while it is live. The plan behind
|
||||
the event is never published. */}
|
||||
{event.current.phase || 'Under way'}
|
||||
</div>
|
||||
</>
|
||||
) : event.next ? (
|
||||
<>
|
||||
<div className="sans dim" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', fontSize: '0.74rem' }}>
|
||||
Next
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--head)', fontSize: '1.1rem' }}>
|
||||
{eventDateTime(event.next.scheduledFor, event.next.timezone)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="dim">Nothing scheduled at the moment.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{event.body && (
|
||||
<article
|
||||
className="panel"
|
||||
style={{ padding: 28, marginBottom: 24 }}
|
||||
// Sanitized on write, the treatment a wiki page and a forum post get.
|
||||
dangerouslySetInnerHTML={{ __html: event.body }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{event.results && (
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||
Results
|
||||
</h2>
|
||||
<p className="dim sans" style={{ marginTop: -6, fontSize: '0.85rem' }}>
|
||||
{eventDateTime(event.results.scheduledFor, event.timezone)}
|
||||
</p>
|
||||
{event.results.participants.length === 0 ? (
|
||||
<EmptyState>Results were published with nobody recorded.</EmptyState>
|
||||
) : (
|
||||
<table className="table" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 60 }}>#</th>
|
||||
<th>Who</th>
|
||||
<th style={{ width: 120, textAlign: 'right' }}>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{event.results.participants.map((p, i) => (
|
||||
<tr key={`${p.name || 'anon'}-${i}`}>
|
||||
<td>{p.rank ?? '—'}</td>
|
||||
{/* A module supplies a display name in `meta` or it does
|
||||
not; the member key is never published, so there is
|
||||
genuinely nothing else to render. */}
|
||||
<td>{p.name || <span className="dim">Unnamed</span>}</td>
|
||||
<td style={{ textAlign: 'right' }}>{p.score}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Occurrences title="Coming up" list={event.upcoming} slug={event.slug} timezone={event.timezone} />
|
||||
<Occurrences title="Previously" list={event.past} slug={event.slug} timezone={event.timezone} past />
|
||||
|
||||
{!headline && event.past.length === 0 && (
|
||||
<EmptyState>This event has not been scheduled yet.</EmptyState>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function Occurrences({ title, list, slug, timezone, past = false }) {
|
||||
if (!list || list.length === 0) return null
|
||||
return (
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{list.map((o) => (
|
||||
<li key={o.runId} className="panel" style={{ padding: '12px 18px', display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<span style={{ flex: 1, minWidth: 220 }}>{eventDateTime(o.scheduledFor, o.timezone || timezone)}</span>
|
||||
<span className="dim sans" style={{ fontSize: '0.78rem' }}>{statusWord(o.status, o.scheduledFor)}</span>
|
||||
{/* Only a past occurrence gets its own link, and only when it has
|
||||
results: on any other, `?run=` would change nothing a reader
|
||||
could see. */}
|
||||
{past && o.resultsPublishedAt && (
|
||||
<Link className="sans" style={{ fontSize: '0.78rem' }} to={`/site/events/${slug}?run=${o.runId}`}>
|
||||
Results
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
78
client/src/routes/public/EventSeries.jsx
Normal file
78
client/src/routes/public/EventSeries.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
// One arc (EVENTS.md §I, Phase 14a).
|
||||
//
|
||||
// **The arc is the thing the tooling this replaces could not express at all.**
|
||||
// A WordPress calendar plugin has no series field, so "Royal Spy Mission → Risky
|
||||
// Partner → Message From the Void" existed only in a GM's head and in whatever
|
||||
// the forum post said. This page is that continuity, in the order an editor
|
||||
// arranged it — which is why the events are numbered rather than dated: an arc
|
||||
// has an order, and its parts may be months apart or run out of sequence.
|
||||
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
export default function EventSeries() {
|
||||
const { slug } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.publicEventSeries(slug), [slug])
|
||||
|
||||
// Error first, then loading — the order Phase 13 had to fix on the admin
|
||||
// editor, where a failed load sat behind a spinner that never stopped.
|
||||
if (error) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<ErrorState message="That series could not be found." />
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link to="/site/events">Back to the calendar</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<Loading />
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const series = data.series
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<PageHeader eyebrow="Series" title={series.name} lead={series.description || ''} />
|
||||
<ol style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{series.events.map((e, i) => (
|
||||
<li key={e.slug}>
|
||||
<Link to={`/site/events/${e.slug}`} style={{ textDecoration: 'none' }}>
|
||||
<div className="panel" style={{ padding: '18px 22px', display: 'flex', gap: 18 }}>
|
||||
<span
|
||||
className="display"
|
||||
style={{ color: 'var(--accent)', fontSize: '1.4rem', minWidth: 36, textAlign: 'right' }}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>
|
||||
<span className="display" style={{ fontSize: '1.15rem', color: 'var(--head)' }}>
|
||||
{e.title}
|
||||
</span>
|
||||
{e.summary && <p style={{ margin: '6px 0 0', color: 'var(--text)' }}>{e.summary}</p>}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="sans" style={{ marginTop: 24 }}>
|
||||
<Link to="/site/events">Back to the calendar</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
131
client/src/routes/public/Events.jsx
Normal file
131
client/src/routes/public/Events.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
// The public event calendar (EVENTS.md §I, Phase 14a).
|
||||
//
|
||||
// **A list, not a month grid.** The admin calendar draws a grid because an
|
||||
// operator's question is "what does this month look like" — coverage, clashes,
|
||||
// the gap on the third weekend. A visitor's question is "what is on, and when is
|
||||
// the next one", which a chronological list answers in one glance and a grid
|
||||
// answers by making them count squares. Same data, different question.
|
||||
//
|
||||
// **A projection is drawn differently from a run, and the reason is the
|
||||
// operator's reason one tier along.** Past the materialisation horizon there is
|
||||
// no row: nothing is committed to, nothing can be cancelled, and a forecast
|
||||
// rendered identically to a booking would be the page promising something the
|
||||
// server has not. It is dashed and labelled "expected".
|
||||
//
|
||||
// The date heading is the READER's day and the time beside each entry is the
|
||||
// EVENT's own zone. That split is §I's: the shard's evening is what "8pm" means
|
||||
// to everyone reading it, but "this month" is the month the reader is living in.
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { eventTime, readerDayLabel, statusWord } from '../../lib/eventCalendar.js'
|
||||
|
||||
export default function Events() {
|
||||
const { loading, error, data } = useAsync(() => api.publicEvents())
|
||||
const entries = data?.entries || []
|
||||
|
||||
// Grouped by the reader's own day, in order. The server already sorted by
|
||||
// instant, so this preserves that order rather than re-sorting.
|
||||
const days = []
|
||||
for (const entry of entries) {
|
||||
const label = readerDayLabel(entry.scheduledFor)
|
||||
const last = days[days.length - 1]
|
||||
if (last && last.label === label) last.entries.push(entry)
|
||||
else days.push({ label, entries: [entry] })
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-mid page-body">
|
||||
<PageHeader
|
||||
eyebrow="What's on"
|
||||
title="Events"
|
||||
lead="Everything scheduled, live and recently finished. Times are shown in the shard's own timezone."
|
||||
/>
|
||||
<section style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the calendar right now." />}
|
||||
{!loading && !error && entries.length === 0 && (
|
||||
<EmptyState>Nothing on the calendar just yet — check back soon.</EmptyState>
|
||||
)}
|
||||
{days.map((day) => (
|
||||
<div key={day.label}>
|
||||
<h2
|
||||
className="sans"
|
||||
style={{
|
||||
margin: '0 0 12px',
|
||||
fontSize: '0.74rem',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
{day.label}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{day.entries.map((entry) => (
|
||||
<EventRow key={`${entry.slug}-${entry.scheduledFor}-${entry.kind}`} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({ entry }) {
|
||||
const projected = entry.kind === 'projected'
|
||||
const body = (
|
||||
<div
|
||||
className="panel"
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 16,
|
||||
flexWrap: 'wrap',
|
||||
// The whole visual difference between a booking and a forecast, and it
|
||||
// is deliberately not subtle.
|
||||
borderStyle: projected ? 'dashed' : undefined,
|
||||
opacity: projected ? 0.72 : 1,
|
||||
}}
|
||||
>
|
||||
<span className="sans" style={{ fontWeight: 700, color: 'var(--accent)', minWidth: 96 }}>
|
||||
{eventTime(entry.scheduledFor, entry.timezone)}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 200 }}>
|
||||
<span className="display" style={{ fontSize: '1.15rem', color: 'var(--head)' }}>
|
||||
{entry.title}
|
||||
</span>
|
||||
{entry.seriesName && (
|
||||
<span className="dim" style={{ marginLeft: 10, fontSize: '0.9rem' }}>
|
||||
{entry.seriesName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.72rem',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
color: entry.live ? '#8fc79a' : 'var(--muted)',
|
||||
fontWeight: entry.live ? 700 : 400,
|
||||
}}
|
||||
>
|
||||
{projected ? 'Expected' : statusWord(entry.status, entry.scheduledFor)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
// A projection has no page of its own worth linking to any differently — the
|
||||
// event page IS the definition's — so both link to the same place. It is the
|
||||
// OCCURRENCE that does not exist yet, not the event.
|
||||
return <Link to={`/site/events/${entry.slug}`} style={{ textDecoration: 'none' }}>{body}</Link>
|
||||
}
|
||||
@@ -252,3 +252,40 @@ test('a Team slug is URL-encoded on every forum path', async () => {
|
||||
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
|
||||
})
|
||||
|
||||
|
||||
// ── Public events (Phase 14a) ───────────────────────────────────────────
|
||||
//
|
||||
// The one shape worth pinning is `?run=`: it is what an announcement's link
|
||||
// carries, and a client that dropped it would make a mail about last Friday's
|
||||
// occurrence open next Friday's.
|
||||
|
||||
test('the public calendar asks for no window at all by default', async () => {
|
||||
willReply({ body: { entries: [] } })
|
||||
await api.publicEvents()
|
||||
// The server defaults to now through a month out, so the first render need
|
||||
// not compute two ISO instants before it can ask for anything.
|
||||
assert.equal(calls[0].url, '/api/v1/public/events')
|
||||
})
|
||||
|
||||
test('an event page carries the run when one was named, and not when it was not', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.publicEvent('the-yew-invasion')
|
||||
assert.equal(calls[0].url, '/api/v1/public/events/the-yew-invasion')
|
||||
|
||||
willReply({ body: { ok: true } })
|
||||
await api.publicEvent('the-yew-invasion', 3692)
|
||||
assert.equal(calls[1].url, '/api/v1/public/events/the-yew-invasion?run=3692')
|
||||
})
|
||||
|
||||
test('an event slug is URL-encoded on every public path', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.publicEventSeries('a b/c')
|
||||
assert.equal(calls[0].url, '/api/v1/public/events/series/a%20b%2Fc')
|
||||
})
|
||||
|
||||
test('participation history takes a keyset cursor, never an offset', async () => {
|
||||
willReply({ body: { entries: [] } })
|
||||
await api.player.eventHistory({ limit: 25, before: 900 })
|
||||
assert.equal(calls[0].url, '/api/v1/player/events/history?limit=25&before=900')
|
||||
})
|
||||
|
||||
910
client/test/eventAuthoring.test.js
Normal file
910
client/test/eventAuthoring.test.js
Normal file
@@ -0,0 +1,910 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
runControlsFor,
|
||||
stepControlsFor,
|
||||
isParked,
|
||||
lastStartedSeqOf,
|
||||
formFromDefinition,
|
||||
payloadFromForm,
|
||||
parseParams,
|
||||
blankStep,
|
||||
blankPhase,
|
||||
describeLogLine,
|
||||
logKindWord,
|
||||
runStatusWord,
|
||||
describeSchedule,
|
||||
scheduleFormFrom,
|
||||
scheduleFromForm,
|
||||
isProjected,
|
||||
blankAdvance,
|
||||
advanceFormFrom,
|
||||
advancePayload,
|
||||
WEEKDAYS,
|
||||
MONTHLY_NTHS,
|
||||
ADVANCE_KINDS,
|
||||
blankWhere,
|
||||
whereFormFrom,
|
||||
paramsRenderable,
|
||||
paramsMode,
|
||||
paramValue,
|
||||
setParam,
|
||||
datetimeInputValue,
|
||||
priceBodyFrom,
|
||||
worthPricing,
|
||||
PARAM_FORM,
|
||||
PARAM_JSON,
|
||||
} from '../src/lib/eventAuthoring.js'
|
||||
|
||||
// lib/eventAuthoring.js — what the three Events screens say and what they let
|
||||
// staff press (EVENTS.md §I, Phase 3).
|
||||
//
|
||||
// None of this is a boundary: `events/spec.js` decides what may be saved and the
|
||||
// six control statements decide what may happen to a run, each of them a
|
||||
// compare-and-set that re-checks the status this file only predicted.
|
||||
//
|
||||
// **The controls get most of the tests, and the reason is worth stating.** A
|
||||
// button offered that the server refuses is not a wrong write — but it is the
|
||||
// failure an operator meets at 2am, on the screen they opened because something
|
||||
// is already going wrong, about the run they are trying to stop. So the guards
|
||||
// are deliberately written twice and this is where the copy is checked against
|
||||
// the original.
|
||||
|
||||
const run = (over = {}) => ({ id: 1, status: 'running', currentPhase: 'main', ...over })
|
||||
const step = (over = {}) => ({
|
||||
id: 10,
|
||||
phase: 'main',
|
||||
seq: 0,
|
||||
status: 'pending',
|
||||
parked: false,
|
||||
...over,
|
||||
})
|
||||
|
||||
// ── The run controls ───────────────────────────────────────────────────────
|
||||
|
||||
test('pause is offered only for a run in flight', () => {
|
||||
assert.equal(runControlsFor(run({ status: 'running' })).pause, true)
|
||||
assert.equal(runControlsFor(run({ status: 'starting' })).pause, true)
|
||||
// A scheduled occurrence that should not happen is cancelled, not paused:
|
||||
// resuming one after its grace window would produce a `missed` from a button
|
||||
// labelled resume.
|
||||
assert.equal(runControlsFor(run({ status: 'scheduled' })).pause, false)
|
||||
assert.equal(runControlsFor(run({ status: 'paused' })).pause, false)
|
||||
})
|
||||
|
||||
test('cancel is offered right up to the moment a run goes terminal, and never after', () => {
|
||||
for (const status of ['scheduled', 'starting', 'running', 'paused', 'ending']) {
|
||||
assert.equal(runControlsFor(run({ status })).cancel, true, `${status} should be cancellable`)
|
||||
}
|
||||
for (const status of ['completed', 'cancelled', 'failed', 'missed']) {
|
||||
assert.equal(runControlsFor(run({ status })).cancel, false, `${status} should not be`)
|
||||
}
|
||||
})
|
||||
|
||||
test('resume is offered for exactly one status', () => {
|
||||
assert.equal(runControlsFor(run({ status: 'paused' })).resume, true)
|
||||
assert.equal(runControlsFor(run({ status: 'running' })).resume, false)
|
||||
})
|
||||
|
||||
// ── The step controls ──────────────────────────────────────────────────────
|
||||
|
||||
test('a parked step is running with nothing holding it, and only that', () => {
|
||||
assert.equal(isParked(step({ status: 'running', parked: true })), true)
|
||||
assert.equal(isParked(step({ status: 'running', parked: false })), false, 'a live lease is a dispatch')
|
||||
assert.equal(isParked(step({ status: 'pending', parked: true })), false)
|
||||
})
|
||||
|
||||
test('confirm is offered for a parked cue and for nothing else', () => {
|
||||
const r = run()
|
||||
const parked = step({ status: 'running', parked: true })
|
||||
assert.equal(stepControlsFor(r, parked, [parked]).confirm, true)
|
||||
|
||||
const dispatching = step({ status: 'running', parked: false })
|
||||
assert.equal(stepControlsFor(r, dispatching, [dispatching]).confirm, false)
|
||||
|
||||
const pending = step()
|
||||
assert.equal(stepControlsFor(r, pending, [pending]).confirm, false)
|
||||
})
|
||||
|
||||
test('skip is offered for a pending step and a parked cue', () => {
|
||||
const r = run()
|
||||
const pending = step()
|
||||
const parked = step({ id: 11, seq: 1, status: 'running', parked: true })
|
||||
const dispatching = step({ id: 12, seq: 2, status: 'running', parked: false })
|
||||
const failed = step({ id: 13, seq: 3, status: 'failed' })
|
||||
const steps = [pending, parked, dispatching, failed]
|
||||
|
||||
assert.equal(stepControlsFor(r, pending, steps).skip, true)
|
||||
assert.equal(stepControlsFor(r, parked, steps).skip, true)
|
||||
assert.equal(stepControlsFor(r, dispatching, steps).skip, false)
|
||||
// A failed step does not need skipping: the runner already steps over it, so
|
||||
// resuming the run carries the phase past it.
|
||||
assert.equal(stepControlsFor(r, failed, steps).skip, false)
|
||||
})
|
||||
|
||||
test('retry is offered for the failed step a paused run is stopped at', () => {
|
||||
const r = run({ status: 'paused' })
|
||||
const done = step({ id: 1, seq: 0, status: 'done' })
|
||||
const failed = step({ id: 2, seq: 1, status: 'failed' })
|
||||
const pending = step({ id: 3, seq: 2, status: 'pending' })
|
||||
const steps = [done, failed, pending]
|
||||
|
||||
assert.equal(stepControlsFor(r, failed, steps).retry, true)
|
||||
assert.equal(stepControlsFor(r, done, steps).retry, false)
|
||||
assert.equal(stepControlsFor(r, pending, steps).retry, false)
|
||||
})
|
||||
|
||||
test('retry is NOT offered for a failed step the run has moved past', () => {
|
||||
// The case the server guard exists for, and the one this copy of it has to
|
||||
// agree about: a phase that carried on past an `on_failure: skip` failure and
|
||||
// then paused at a later step. Offering retry on the first would re-queue a row
|
||||
// behind the runner's own cursor, where it sits pending for ever.
|
||||
const r = run({ status: 'paused' })
|
||||
const skippedOver = step({ id: 1, seq: 0, status: 'failed' })
|
||||
const carriedOn = step({ id: 2, seq: 1, status: 'done' })
|
||||
const stoppedAt = step({ id: 3, seq: 2, status: 'failed' })
|
||||
const notYet = step({ id: 4, seq: 3, status: 'pending' })
|
||||
const steps = [skippedOver, carriedOn, stoppedAt, notYet]
|
||||
|
||||
assert.equal(stepControlsFor(r, skippedOver, steps).retry, false)
|
||||
assert.equal(stepControlsFor(r, stoppedAt, steps).retry, true)
|
||||
})
|
||||
|
||||
test('retry is not offered while the run is still running, or in a phase it has left', () => {
|
||||
const failed = step({ status: 'failed' })
|
||||
assert.equal(stepControlsFor(run({ status: 'running' }), failed, [failed]).retry, false)
|
||||
|
||||
const old = step({ phase: 'one', status: 'failed' })
|
||||
const r = run({ status: 'paused', currentPhase: 'two' })
|
||||
assert.equal(stepControlsFor(r, old, [old]).retry, false)
|
||||
})
|
||||
|
||||
test('no control is offered on a run that is over', () => {
|
||||
for (const status of ['completed', 'cancelled', 'failed', 'missed']) {
|
||||
const parked = step({ status: 'running', parked: true })
|
||||
assert.deepEqual(stepControlsFor(run({ status }), parked, [parked]), {
|
||||
confirm: false,
|
||||
skip: false,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('lastStartedSeqOf is the furthest step of the phase, and null when none has run', () => {
|
||||
const steps = [
|
||||
step({ id: 1, seq: 0, status: 'failed' }),
|
||||
step({ id: 2, seq: 1, status: 'done' }),
|
||||
step({ id: 3, seq: 2, status: 'pending' }),
|
||||
step({ id: 4, seq: 0, phase: 'other', status: 'done' }),
|
||||
]
|
||||
assert.equal(lastStartedSeqOf(steps, 'main'), 1)
|
||||
assert.equal(lastStartedSeqOf([step({ status: 'pending' })], 'main'), null)
|
||||
assert.equal(lastStartedSeqOf(steps, 'nothing-here'), null)
|
||||
})
|
||||
|
||||
// ── The definition form ────────────────────────────────────────────────────
|
||||
|
||||
const ANNOUNCE = {
|
||||
id: 'core.announce',
|
||||
label: 'Announce',
|
||||
risk: 'notify',
|
||||
params: [
|
||||
{ name: 'leg', type: 'string', required: true, example: 'discord' },
|
||||
{ name: 'title', type: 'string', required: false, example: 'The gates open' },
|
||||
{ name: 'body', type: 'string', required: true, example: 'A caravan was sighted.' },
|
||||
],
|
||||
}
|
||||
|
||||
test('a new step arrives prefilled from the action’s declared examples', () => {
|
||||
const fresh = blankStep(ANNOUNCE)
|
||||
assert.equal(fresh.actionId, 'core.announce')
|
||||
assert.deepEqual(JSON.parse(fresh.paramsText), {
|
||||
leg: 'discord',
|
||||
title: 'The gates open',
|
||||
body: 'A caravan was sighted.',
|
||||
})
|
||||
})
|
||||
|
||||
test('a new phase never collides with an existing key', () => {
|
||||
// Two phases sharing a key would silently collapse at materialisation —
|
||||
// `event_run_steps` is UNIQUE on (run_id, phase, seq) — so half the authored
|
||||
// steps would never exist. The server refuses it; the form must not propose it.
|
||||
const first = blankPhase([])
|
||||
const second = blankPhase([first])
|
||||
const third = blankPhase([first, second])
|
||||
assert.equal(new Set([first.key, second.key, third.key]).size, 3)
|
||||
})
|
||||
|
||||
test('the form round-trips a definition without losing a step', () => {
|
||||
const event = {
|
||||
title: 'Invasion',
|
||||
graceSeconds: 600,
|
||||
timezone: 'Europe/Berlin',
|
||||
concurrencyKey: 'invasion:{region}',
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{
|
||||
key: 'warn',
|
||||
label: 'Warning',
|
||||
steps: [
|
||||
{ actionId: 'core.announce', label: 'Herald', onFailure: 'skip', params: { leg: 'discord', body: 'hi' } },
|
||||
{ actionId: 'core.wait', params: { seconds: 300 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const built = payloadFromForm(formFromDefinition(event))
|
||||
assert.equal(built.ok, true)
|
||||
assert.deepEqual(built.payload.spec.phases, [
|
||||
{
|
||||
key: 'warn',
|
||||
label: 'Warning',
|
||||
steps: [
|
||||
{ actionId: 'core.announce', label: 'Herald', onFailure: 'skip', params: { leg: 'discord', body: 'hi' } },
|
||||
{ actionId: 'core.wait', params: { seconds: 300 } },
|
||||
],
|
||||
},
|
||||
])
|
||||
assert.equal(built.payload.graceSeconds, 600)
|
||||
assert.equal(built.payload.concurrencyKey, 'invasion:{region}')
|
||||
})
|
||||
|
||||
test('`listed` round-trips, and an unlisted event is not quietly re-listed', () => {
|
||||
// The trap this guards is `||` where `??` is meant. A definition an operator
|
||||
// deliberately unlisted sends `listed: false`, and `event?.listed || true`
|
||||
// would put it back on the public calendar on the author's next save — a
|
||||
// surprise event announced by a typo fix.
|
||||
const unlisted = payloadFromForm(
|
||||
formFromDefinition({ title: 'Invasion', listed: false, spec: { schedule: { kind: 'manual' }, phases: [] } }),
|
||||
)
|
||||
assert.equal(unlisted.payload.listed, false)
|
||||
|
||||
const listed = payloadFromForm(
|
||||
formFromDefinition({ title: 'Invasion', listed: true, spec: { schedule: { kind: 'manual' }, phases: [] } }),
|
||||
)
|
||||
assert.equal(listed.payload.listed, true)
|
||||
})
|
||||
|
||||
test('a new definition defaults to listed', () => {
|
||||
// The column's own default, and the ordinary case: unlisting is the
|
||||
// deliberate act, not listing.
|
||||
const fresh = payloadFromForm(formFromDefinition({ spec: { schedule: { kind: 'manual' }, phases: [] } }))
|
||||
assert.equal(fresh.payload.listed, true)
|
||||
})
|
||||
|
||||
test('an unchosen onFailure is omitted rather than invented', () => {
|
||||
// The server defaults it from the action's risk class, which is the whole
|
||||
// reason `risk` is required at registration. A form that posted a value would
|
||||
// silently override that — turning a `change` action's `pause` into a `skip`
|
||||
// and advancing a run over a half-changed world.
|
||||
const form = formFromDefinition({
|
||||
spec: { phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'core.announce', params: {} }] }] },
|
||||
})
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal('onFailure' in built.payload.spec.phases[0].steps[0], false)
|
||||
})
|
||||
|
||||
test('a params box that is not JSON is refused with the step named', () => {
|
||||
const form = formFromDefinition({
|
||||
spec: { phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'core.announce', params: {} }] }] },
|
||||
})
|
||||
form.phases[0].steps[0].paramsText = '{ leg: discord }'
|
||||
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal(built.ok, false)
|
||||
assert.match(built.errors[0], /Phase 1 "Main", step 1/)
|
||||
})
|
||||
|
||||
test('an empty params box is an empty object, not an error', () => {
|
||||
assert.deepEqual(parseParams('').params, {})
|
||||
assert.deepEqual(parseParams(' ').params, {})
|
||||
assert.ok(parseParams('[1,2]').error, 'an array is not a params object')
|
||||
assert.ok(parseParams('"leg"').error)
|
||||
})
|
||||
|
||||
// ── Rendering what happened ────────────────────────────────────────────────
|
||||
|
||||
test('a human transition reads differently from the runner’s own', () => {
|
||||
// Both are `run.status` rows. `detail.control` is the only thing that separates
|
||||
// "the runner paused this because a world write failed" from "somebody pressed
|
||||
// pause", and the console has to tell them apart at a glance.
|
||||
const byRunner = describeLogLine({
|
||||
kind: 'run.status',
|
||||
detail: { from: 'running', to: 'paused', because: 'core.spawn' },
|
||||
})
|
||||
const byPerson = describeLogLine({
|
||||
kind: 'run.status',
|
||||
detail: { from: 'running', to: 'paused', control: 'pause', by: 4, reason: 'shard is lagging' },
|
||||
})
|
||||
|
||||
assert.match(byRunner, /Running → Paused/)
|
||||
assert.match(byRunner, /core\.spawn/)
|
||||
assert.match(byPerson, /pause/)
|
||||
assert.match(byPerson, /by staff/)
|
||||
assert.match(byPerson, /shard is lagging/)
|
||||
})
|
||||
|
||||
test('the log lines a run produces all render as something', () => {
|
||||
const lines = [
|
||||
{ kind: 'run.created', detail: { version: 3, rehearsal: true } },
|
||||
{ kind: 'run.blocked', detail: { heldBy: 9, concurrencyKey: 'invasion:Yew' } },
|
||||
{ kind: 'run.health', detail: { to: 'degraded', because: 'core.announce' } },
|
||||
{ kind: 'phase.entered', phase: 'warn', detail: { steps: 2 } },
|
||||
{ kind: 'phase.completed', phase: 'warn', detail: {} },
|
||||
{ kind: 'step.parked', detail: { action: 'core.cue' } },
|
||||
{ kind: 'step.retry', detail: { action: 'core.announce', attempt: 1, of: 3, error: 'timeout' } },
|
||||
{ kind: 'step.status', detail: { action: 'core.wait', to: 'done' } },
|
||||
{ kind: 'note', detail: {} },
|
||||
]
|
||||
for (const line of lines) {
|
||||
const text = describeLogLine(line)
|
||||
assert.equal(typeof text, 'string')
|
||||
assert.ok(text.length > 0, `${line.kind} rendered as nothing`)
|
||||
assert.ok(!text.includes('undefined'), `${line.kind} rendered an undefined: ${text}`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── The one log line core did not compose (Phase 15) ──────────────────────
|
||||
|
||||
test('a module detail line renders the module keys, not the kind id', () => {
|
||||
// The failure this guards is subtle and total: `step.detail` falling to the
|
||||
// default renders the literal string "step.detail", which is the reporting
|
||||
// channel existing and showing nothing — exactly what it was built to fix.
|
||||
const text = describeLogLine({
|
||||
kind: 'step.detail',
|
||||
detail: { action: 'uo.item.grant', granted: 8, missed: 4, why: ['bank full', 'offline'] },
|
||||
})
|
||||
|
||||
assert.ok(!text.includes('step.detail'), `the kind id leaked into the sentence: ${text}`)
|
||||
assert.match(text, /uo\.item\.grant/)
|
||||
assert.match(text, /granted: 8/)
|
||||
assert.match(text, /missed: 4/)
|
||||
assert.match(text, /bank full/)
|
||||
})
|
||||
|
||||
test('a module detail is rendered generically, whatever a module puts in it', () => {
|
||||
// Core does not interpret these keys and neither does the renderer — a switch
|
||||
// here would be the browser learning one module vocabulary, which is the thing
|
||||
// the module system exists to prevent. So an unfamiliar shape still reads.
|
||||
const text = describeLogLine({
|
||||
kind: 'step.detail',
|
||||
detail: { action: 'rust.wipe.announce', servers: { eu: 3, us: 1 }, dryRun: false, at: null },
|
||||
})
|
||||
assert.ok(!text.includes('undefined'), text)
|
||||
assert.ok(!text.includes('[object Object]'), `a nested object rendered as a brace: ${text}`)
|
||||
assert.match(text, /eu 3/)
|
||||
assert.match(text, /dryRun: false/, 'false is a value, not an absence')
|
||||
})
|
||||
|
||||
test('a long module detail stays one line', () => {
|
||||
const many = Array.from({ length: 40 }, (_, i) => `player-${i}`)
|
||||
const text = describeLogLine({
|
||||
kind: 'step.detail',
|
||||
detail: { action: 'uo.item.grant', missed: many, note: 'x'.repeat(500) },
|
||||
})
|
||||
assert.match(text, /and 35 more/)
|
||||
assert.ok(text.length < 300, `one row should not wrap eight times: ${text.length} chars`)
|
||||
})
|
||||
|
||||
test('a module detail with nothing in it still reads as a sentence', () => {
|
||||
const text = describeLogLine({ kind: 'step.detail', detail: { action: 'uo.world.save' } })
|
||||
assert.ok(text.length > 0)
|
||||
assert.ok(!text.includes('undefined'), text)
|
||||
})
|
||||
|
||||
test('every run status has a word, and an unknown one falls through rather than blanking', () => {
|
||||
for (const s of ['scheduled', 'starting', 'running', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
|
||||
assert.ok(runStatusWord(s).length > 0)
|
||||
}
|
||||
assert.equal(runStatusWord('something-new'), 'something-new')
|
||||
})
|
||||
|
||||
|
||||
// ── The schedule form (Phase 4) ─────────────────────────────────────
|
||||
//
|
||||
// The form is the whole argument against cron: a closed set of four shapes has a
|
||||
// dropdown, and a dropdown can be proofread. What is checked here is that the
|
||||
// round trip through the form does not quietly change what the author wrote —
|
||||
// the server would refuse a malformed schedule, but it cannot refuse a
|
||||
// well-formed one that says something the author did not mean.
|
||||
|
||||
test('a schedule survives the round trip through the form unchanged', () => {
|
||||
for (const schedule of [
|
||||
{ kind: 'manual' },
|
||||
{ kind: 'once', at: '2026-10-31T20:00' },
|
||||
{ kind: 'weekly', days: ['monday', 'friday'], time: '20:00' },
|
||||
{ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
|
||||
]) {
|
||||
const form = scheduleFormFrom(schedule)
|
||||
assert.deepEqual(scheduleFromForm(form), schedule, JSON.stringify(schedule))
|
||||
}
|
||||
})
|
||||
|
||||
test('switching kind keeps the other shapes fields, and sends only the chosen one', () => {
|
||||
// An author who clicks Weekly, then Monthly, then back must not find the days
|
||||
// they picked gone — but the request body must still be a single clean shape,
|
||||
// not a union of everything they touched.
|
||||
const form = { ...scheduleFormFrom({ kind: 'weekly', days: ['friday'], time: '20:00' }), scheduleKind: 'monthly' }
|
||||
const sent = scheduleFromForm(form)
|
||||
assert.deepEqual(Object.keys(sent).sort(), ['kind', 'nth', 'time', 'weekday'])
|
||||
assert.equal(form.scheduleDays.includes('friday'), true)
|
||||
})
|
||||
|
||||
test('formFromDefinition carries the whole schedule, not only its kind', () => {
|
||||
const form = formFromDefinition({
|
||||
title: 'Fishing contest',
|
||||
timezone: 'Europe/Berlin',
|
||||
spec: {
|
||||
schedule: { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [] }],
|
||||
},
|
||||
})
|
||||
assert.equal(form.scheduleKind, 'monthly')
|
||||
assert.equal(form.scheduleNth, '-1')
|
||||
assert.equal(form.scheduleWeekday, 'friday')
|
||||
assert.equal(form.scheduleTime, '19:30')
|
||||
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal(built.ok, true)
|
||||
assert.deepEqual(built.payload.spec.schedule, {
|
||||
kind: 'monthly',
|
||||
nth: -1,
|
||||
weekday: 'friday',
|
||||
time: '19:30',
|
||||
})
|
||||
})
|
||||
|
||||
test('a definition with no schedule at all reads as manual rather than as broken', () => {
|
||||
const form = formFromDefinition({ title: 'x', spec: { phases: [] } })
|
||||
assert.equal(form.scheduleKind, 'manual')
|
||||
assert.deepEqual(scheduleFromForm(form), { kind: 'manual' })
|
||||
})
|
||||
|
||||
test('every schedule describes as a sentence, and a half-built one says what is missing', () => {
|
||||
assert.match(describeSchedule({ kind: 'manual' }), /by hand/)
|
||||
assert.equal(
|
||||
describeSchedule({ kind: 'weekly', days: ['friday', 'saturday'], time: '20:00' }, 'Europe/Berlin'),
|
||||
'Every Friday and Saturday at 20:00 (Europe/Berlin)',
|
||||
)
|
||||
assert.equal(
|
||||
describeSchedule({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, 'Asia/Kolkata'),
|
||||
'The last Friday of every month at 19:30 (Asia/Kolkata)',
|
||||
)
|
||||
// Half-built is the state the preview spends most of its life in — an author
|
||||
// is typing. It must prompt, never render "undefined".
|
||||
for (const partial of [
|
||||
{ kind: 'weekly', days: [], time: '20:00' },
|
||||
{ kind: 'weekly', days: ['friday'], time: '' },
|
||||
{ kind: 'monthly', nth: 1, weekday: '', time: '19:00' },
|
||||
{ kind: 'once', at: '' },
|
||||
]) {
|
||||
const text = describeSchedule(partial, 'UTC')
|
||||
assert.ok(text.length > 0)
|
||||
assert.ok(!text.includes('undefined'), `${JSON.stringify(partial)} rendered: ${text}`)
|
||||
assert.match(text, /choose|no date/i)
|
||||
}
|
||||
})
|
||||
|
||||
test('the weekday and nth vocabularies match the server', () => {
|
||||
// Verbatim `events/recurrence.js`. A client list that drifted would offer a
|
||||
// value the server refuses, which is exactly the class of failure this file
|
||||
// exists to catch.
|
||||
assert.deepEqual(WEEKDAYS, [
|
||||
'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday',
|
||||
])
|
||||
assert.deepEqual(MONTHLY_NTHS.map((n) => n.value), [1, 2, 3, 4, -1])
|
||||
})
|
||||
|
||||
test('a projection is told apart from a run, because only one of them can be acted on', () => {
|
||||
assert.equal(isProjected({ kind: 'projected', runId: null }), true)
|
||||
assert.equal(isProjected({ kind: 'run', runId: 12 }), false)
|
||||
assert.equal(isProjected(null), false)
|
||||
})
|
||||
|
||||
|
||||
// -- The advance gate (Phase 5) ---------------------------------------------
|
||||
//
|
||||
// What this screen must get right is what it OFFERS. `advance` is the one
|
||||
// control in this feature whose whole point is that it overrides the engine, so
|
||||
// a button offered in a state the server refuses would be the "control that
|
||||
// answers 409 and does nothing" this feature has refused twice.
|
||||
|
||||
test('advance is offered only when the phase is waiting on its gate', () => {
|
||||
const gate = (over = {}) => [{ phase: 'boss', satisfied: false, ...over }]
|
||||
const done = [{ phase: 'boss', status: 'done' }]
|
||||
|
||||
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), done).advance, true)
|
||||
|
||||
// A phase with an open step is held by the STEP, and skip is its control.
|
||||
assert.equal(
|
||||
runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), [...done, { phase: 'boss', status: 'pending' }]).advance,
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), [{ phase: 'boss', status: 'running' }]).advance,
|
||||
false,
|
||||
)
|
||||
|
||||
// A phase with no gate advances on its steps and always has.
|
||||
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, [], done).advance, false)
|
||||
// A gate already satisfied is not waiting.
|
||||
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, gate({ satisfied: true }), done).advance, false)
|
||||
// And a run that is not running is waiting on nothing.
|
||||
for (const status of ['scheduled', 'starting', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
|
||||
assert.equal(runControlsFor({ status, currentPhase: 'boss' }, gate(), done).advance, false, status)
|
||||
}
|
||||
})
|
||||
|
||||
test('runControlsFor still answers with no gates or steps at all', () => {
|
||||
// The three Phase 3 controls were called with one argument for two phases, and
|
||||
// the calendar still calls it that way.
|
||||
const controls = runControlsFor({ status: 'running', currentPhase: 'boss' })
|
||||
assert.equal(controls.pause, true)
|
||||
assert.equal(controls.advance, false)
|
||||
})
|
||||
|
||||
test('a gate round-trips through the form without losing the other shape', () => {
|
||||
assert.deepEqual(advanceFormFrom(null), blankAdvance())
|
||||
assert.equal(advanceFormFrom({ after: '2h' }).kind, 'after')
|
||||
assert.equal(advanceFormFrom({ after: '2h' }).after, '2h')
|
||||
|
||||
const on = advanceFormFrom({ on: 'uo.champ.boss_up', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 3 })
|
||||
assert.equal(on.kind, 'on')
|
||||
assert.equal(on.count, 3)
|
||||
assert.deepEqual(JSON.parse(on.whereText), { variable: 'region', cmp: 'eq', value: 'Yew' })
|
||||
|
||||
// The dropdown's three options, and the empty one is what nearly every phase
|
||||
// is — so it is first and it is not called "none".
|
||||
assert.equal(ADVANCE_KINDS[0].value, '')
|
||||
})
|
||||
|
||||
test('advancePayload sends one shape, built from the builder\u2019s rows', () => {
|
||||
const errors = []
|
||||
assert.equal(advancePayload({ kind: '' }, 'Phase 1', errors), null, 'no gate sends no key at all')
|
||||
assert.deepEqual(advancePayload({ kind: 'after', after: '30m' }, 'Phase 1', errors), { after: '30m' })
|
||||
assert.deepEqual(
|
||||
advancePayload({ kind: 'on', on: 'uo.champ.boss_up', count: '2', ...blankWhere() }, 'Phase 1', errors),
|
||||
{ on: 'uo.champ.boss_up', count: 2 },
|
||||
'an empty predicate is omitted, not sent as an empty object',
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
|
||||
// Whether the predicate is VALID is still the server's answer, named variable
|
||||
// and all \u2014 the builder only offers what the trigger declares, and a variable
|
||||
// that has gone away comes back named from the save.
|
||||
assert.deepEqual(
|
||||
advancePayload(
|
||||
{
|
||||
kind: 'on',
|
||||
on: 'x',
|
||||
count: 1,
|
||||
...blankWhere(),
|
||||
whereRows: [{ variable: 'nope', cmp: 'eq', value: '1' }],
|
||||
},
|
||||
'Phase 1',
|
||||
errors,
|
||||
[{ name: 'nope', type: 'int' }],
|
||||
),
|
||||
{ on: 'x', count: 1, where: { variable: 'nope', cmp: 'eq', value: 1 } },
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
test('the builder coerces each literal to the type the trigger declared', () => {
|
||||
// The trap this closes: every value in an HTML input is a string, and
|
||||
// `{ cmp: 'gt', value: "5" }` against an int variable is refused by
|
||||
// engagement/conditions.js. Without this the author reads an error about JSON
|
||||
// rather than about what they typed.
|
||||
const built = advancePayload(
|
||||
{
|
||||
kind: 'on',
|
||||
on: 'x',
|
||||
count: 1,
|
||||
...blankWhere(),
|
||||
whereOp: 'or',
|
||||
whereRows: [
|
||||
{ variable: 'tier', cmp: 'gte', value: '3' },
|
||||
{ variable: 'region', cmp: 'in', value: 'Yew, Britain' },
|
||||
],
|
||||
},
|
||||
'Phase 1',
|
||||
[],
|
||||
[{ name: 'tier', type: 'int' }, { name: 'region', type: 'string' }],
|
||||
)
|
||||
assert.deepEqual(built.where, {
|
||||
op: 'or',
|
||||
nodes: [
|
||||
{ variable: 'tier', cmp: 'gte', value: 3 },
|
||||
{ variable: 'region', cmp: 'in', value: ['Yew', 'Britain'] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('a predicate the builder cannot render is posted back unchanged, not flattened', () => {
|
||||
// `A and (B or C)` is not `A and B and C` \u2014 they fire on different events \u2014
|
||||
// and an author would have no way to know the save had done it. The condition
|
||||
// builder's own rule, and this is the same function.
|
||||
const nested = {
|
||||
op: 'and',
|
||||
nodes: [
|
||||
{ variable: 'region', cmp: 'eq', value: 'Yew' },
|
||||
{ op: 'or', nodes: [{ variable: 'tier', cmp: 'eq', value: 1 }, { variable: 'tier', cmp: 'eq', value: 2 }] },
|
||||
],
|
||||
}
|
||||
const form = whereFormFrom(nested)
|
||||
assert.equal(form.whereEditable, false)
|
||||
assert.deepEqual(form.whereRows, [])
|
||||
|
||||
const errors = []
|
||||
const built = advancePayload({ kind: 'on', on: 'x', count: 1, ...form }, 'Phase 1', errors)
|
||||
assert.deepEqual(built.where, nested, 'the tree survives a screen that cannot draw it')
|
||||
assert.equal(errors.length, 0)
|
||||
|
||||
// And the text is still the thing that can fail to parse, which is the only
|
||||
// reason this path keeps an error channel at all.
|
||||
advancePayload(
|
||||
{ kind: 'on', on: 'x', count: 1, whereEditable: false, whereText: '{ not json' },
|
||||
'Phase 2 "Boss"',
|
||||
errors,
|
||||
)
|
||||
assert.equal(errors.length, 1)
|
||||
assert.match(errors[0], /Phase 2 "Boss", advance condition:/)
|
||||
})
|
||||
|
||||
test('a phase with no gate sends no `advance` key', () => {
|
||||
const form = formFromDefinition({
|
||||
title: 'x',
|
||||
spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] },
|
||||
})
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal(built.ok, true)
|
||||
assert.equal('advance' in built.payload.spec.phases[0], false)
|
||||
})
|
||||
|
||||
test('an authored gate survives the round trip through the form', () => {
|
||||
const form = formFromDefinition({
|
||||
title: 'x',
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{ key: 'boss', label: 'Boss', steps: [], advance: { on: 'uo.champ.boss_up', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 2 } },
|
||||
{ key: 'loot', label: 'Loot', steps: [], advance: { after: '10m' } },
|
||||
],
|
||||
},
|
||||
})
|
||||
const built = payloadFromForm(form)
|
||||
assert.equal(built.ok, true)
|
||||
assert.deepEqual(built.payload.spec.phases[0].advance, {
|
||||
on: 'uo.champ.boss_up',
|
||||
where: { variable: 'region', cmp: 'eq', value: 'Yew' },
|
||||
count: 2,
|
||||
})
|
||||
assert.deepEqual(built.payload.spec.phases[1].advance, { after: '10m' })
|
||||
})
|
||||
|
||||
test('the log renders Phase 5\'s three kinds, including the near miss', () => {
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'phase.gate', phase: 'boss', detail: { kind: 'on', trigger: 'uo.champ.boss_up', needed: 2, where: 'region is "Yew"' } }),
|
||||
/boss advances on 2 × uo\.champ\.boss_up where region is "Yew"/,
|
||||
)
|
||||
assert.match(describeLogLine({ kind: 'phase.gate', phase: 'loot', detail: { kind: 'after', after: '10m' } }), /loot advances 10m after it started/)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'condition.evaluated', detail: { trigger: 'uo.champ.boss_up', matched: false, seen: 0, needed: 2 } }),
|
||||
/did not count — 0 of 2/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'condition.evaluated', detail: { trigger: 'uo.champ.boss_up', matched: true, seen: 2, needed: 2, satisfied: true } }),
|
||||
/counted — 2 of 2, condition met/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'phase.advanced', phase: 'boss', detail: { because: 'forced', waitedSeconds: 4080, reason: 'never spawned' } }),
|
||||
/boss advanced by hand after 4080s: never spawned/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'phase.advanced', phase: 'loot', detail: { because: 'elapsed', waitedSeconds: 600 } }),
|
||||
/loot advanced on its deadline after 600s/,
|
||||
)
|
||||
})
|
||||
|
||||
test("the log renders Phase 6's three kinds, and a refusal does not read as a failure", () => {
|
||||
// The distinction the whole kind exists for. An operator scanning a stopped run
|
||||
// has to be able to see that nothing is broken — the deployment simply does not
|
||||
// permit what the author asked for — and the answer differs by cause: a switch
|
||||
// for "not enabled", a number for "over the cap".
|
||||
assert.match(
|
||||
describeLogLine({
|
||||
kind: 'step.refused',
|
||||
detail: { action: 'uo.creature.spawn', error: 'asks for 12 of "uo.creatures"; 28 of 30 is already spent this run' },
|
||||
}),
|
||||
/uo\.creature\.spawn refused: asks for 12 of "uo\.creatures"; 28 of 30 is already spent this run/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({
|
||||
kind: 'step.refused',
|
||||
detail: { action: 'uo.creature.spawn', error: '"Spawn creatures" is not enabled on this deployment' },
|
||||
}),
|
||||
/refused: "Spawn creatures" is not enabled/,
|
||||
)
|
||||
assert.equal(logKindWord('step.refused'), 'Refused')
|
||||
|
||||
// The caps a run was seeded with, and which switch set each — so a number on
|
||||
// the meter can be traced back to something an operator can change.
|
||||
assert.match(
|
||||
describeLogLine({
|
||||
kind: 'run.budget',
|
||||
detail: { dimensions: [{ dimension: 'uo.creatures', cap: 30, from: 'uo.creature.spawn' }] },
|
||||
}),
|
||||
/uo\.creatures capped at 30 \(uo\.creature\.spawn\)/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'run.budget', detail: { dimensions: [{ dimension: 'uo.gate.minutes', cap: null, from: null }] } }),
|
||||
/uo\.gate\.minutes capped at nothing/,
|
||||
)
|
||||
// A run with no capped dimension at all still gets a sentence rather than an
|
||||
// empty line, because an empty log entry reads as a bug.
|
||||
assert.match(describeLogLine({ kind: 'run.budget', detail: { dimensions: [] } }), /no caps apply to this run/)
|
||||
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'version.verified', detail: { versionId: 4, version: 2, by: 1 } }),
|
||||
/Version 2 passed its dry run — scheduled occurrences may start/,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
// ── Step params as a form (Phase 13) ──────────────────────────────
|
||||
//
|
||||
// The form is not a boundary either — `events/spec.js` still decides what may be
|
||||
// saved. What is tested here is the thing that would be wrong SILENTLY: a form
|
||||
// that drops a param it cannot draw, or writes a value the author never typed.
|
||||
|
||||
const spawn = {
|
||||
id: 'test.spawn',
|
||||
label: 'Spawn',
|
||||
params: [
|
||||
{ name: 'creature', type: 'string', required: true, example: 'orc', source: 'test.creatures' },
|
||||
{ name: 'count', type: 'int', required: true, example: 8 },
|
||||
{ name: 'tame', type: 'boolean', required: false, example: false },
|
||||
{ name: 'at', type: 'datetime', required: false, example: '2026-09-07T20:00:00.000Z' },
|
||||
],
|
||||
}
|
||||
|
||||
const stepWith = (params, over = {}) => ({
|
||||
actionId: 'test.spawn',
|
||||
paramsText: JSON.stringify(params, null, 2),
|
||||
...over,
|
||||
})
|
||||
|
||||
test('a step whose params the form can hold opens as a form', () => {
|
||||
const mode = paramsMode(stepWith({ creature: 'orc', count: 8 }), spawn)
|
||||
assert.deepEqual(mode, { mode: PARAM_FORM, forced: false, reason: null })
|
||||
})
|
||||
|
||||
test('an author who chose JSON stays in JSON', () => {
|
||||
const mode = paramsMode(stepWith({ creature: 'orc' }, { paramsMode: PARAM_JSON }), spawn)
|
||||
assert.equal(mode.mode, PARAM_JSON)
|
||||
assert.equal(mode.forced, false, 'their choice, so no reason is shown')
|
||||
})
|
||||
|
||||
test('a param the action does not declare FORCES the JSON box and says which', () => {
|
||||
// The form would render four fields and post four values, having deleted
|
||||
// `radius` — a save that looks clean and means something else. The save path
|
||||
// refuses it by name, which is what the author needs to see.
|
||||
const mode = paramsMode(stepWith({ creature: 'orc', count: 8, radius: 12 }), spawn)
|
||||
assert.equal(mode.mode, PARAM_JSON)
|
||||
assert.equal(mode.forced, true)
|
||||
assert.match(mode.reason, /carries "radius", which test\.spawn does not declare/)
|
||||
})
|
||||
|
||||
test('a value no single control can hold forces the JSON box', () => {
|
||||
assert.match(paramsMode(stepWith({ creature: ['orc', 'troll'] }), spawn).reason, /holds a list/)
|
||||
assert.match(paramsMode(stepWith({ creature: { id: 'orc' } }), spawn).reason, /holds a structure/)
|
||||
})
|
||||
|
||||
test('a dormant step is edited as JSON, because there is no declaration to draw', () => {
|
||||
const mode = paramsMode(stepWith({ creature: 'orc' }), undefined)
|
||||
assert.equal(mode.mode, PARAM_JSON)
|
||||
assert.equal(mode.forced, true)
|
||||
assert.match(mode.reason, /not installed/)
|
||||
})
|
||||
|
||||
test('a params box that is not JSON opens as JSON with the parse error', () => {
|
||||
const mode = paramsMode({ actionId: 'test.spawn', paramsText: '{ not json' }, spawn)
|
||||
assert.equal(mode.mode, PARAM_JSON)
|
||||
assert.equal(mode.forced, true)
|
||||
assert.match(mode.reason, /not valid JSON/)
|
||||
})
|
||||
|
||||
test('paramsRenderable accepts a step with nothing in it', () => {
|
||||
// A brand-new step with an optional-only action, and the empty case a form
|
||||
// needs to survive before anybody has typed.
|
||||
assert.deepEqual(paramsRenderable(spawn, {}), { ok: true })
|
||||
})
|
||||
|
||||
test('setParam writes the type the param declared, not the string the input held', () => {
|
||||
const step = stepWith({ creature: 'orc', count: 8 })
|
||||
assert.deepEqual(JSON.parse(setParam(step, 'count', '12', 'int')), { creature: 'orc', count: 12 })
|
||||
assert.deepEqual(JSON.parse(setParam(step, 'tame', 'true', 'boolean')), {
|
||||
creature: 'orc',
|
||||
count: 8,
|
||||
tame: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('a half-typed number is kept as typed rather than turned into NaN', () => {
|
||||
// `coerceLiteral`'s rule, and the reason it is borrowed rather than rewritten:
|
||||
// turning `-` into NaN while somebody types would either post a value they
|
||||
// never wrote or make a negative impossible to enter. The server's type check
|
||||
// then names the param.
|
||||
const step = stepWith({ count: 8 })
|
||||
assert.deepEqual(JSON.parse(setParam(step, 'count', '-', 'int')), { count: '-' })
|
||||
})
|
||||
|
||||
test('clearing a field REMOVES the key rather than posting an empty string', () => {
|
||||
// `checkParams` treats undefined, null and '' alike — absent — so a required
|
||||
// param left blank comes back as "is required", which is the error the author
|
||||
// needs, instead of a type complaint about "".
|
||||
const step = stepWith({ creature: 'orc', count: 8 })
|
||||
assert.deepEqual(JSON.parse(setParam(step, 'creature', '', 'string')), { count: 8 })
|
||||
})
|
||||
|
||||
test('setParam leaves an unparseable box alone rather than overwriting it', () => {
|
||||
// The only way to reach this is a race between the mode switch and a
|
||||
// keystroke; silently replacing the text with `{ "count": 1 }` would destroy
|
||||
// whatever the author was midway through writing.
|
||||
const step = { actionId: 'test.spawn', paramsText: '{ not json' }
|
||||
assert.equal(setParam(step, 'count', '1', 'int'), '{ not json')
|
||||
})
|
||||
|
||||
test('paramValue reads one param, and answers nothing for a box that does not parse', () => {
|
||||
assert.equal(paramValue(stepWith({ count: 8 }), 'count'), 8)
|
||||
assert.equal(paramValue(stepWith({ count: 8 }), 'creature'), undefined)
|
||||
assert.equal(paramValue({ paramsText: '{ not json' }, 'count'), undefined)
|
||||
})
|
||||
|
||||
test('a datetime is sliced to what the input wants, and anything else is empty', () => {
|
||||
assert.equal(datetimeInputValue('2026-09-07T20:00:00.000Z'), '2026-09-07T20:00')
|
||||
assert.equal(datetimeInputValue(undefined), '')
|
||||
assert.equal(datetimeInputValue(12), '')
|
||||
})
|
||||
|
||||
// ── The meter's request (Phase 13) ────────────────────────────
|
||||
|
||||
test('the price body carries the plan and nothing else', () => {
|
||||
const form = formFromDefinition({
|
||||
title: 'Invasion',
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{ key: 'warn', label: 'Warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
|
||||
{ key: 'assault', label: 'Assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
assert.deepEqual(priceBodyFrom(form), {
|
||||
phases: [
|
||||
{ key: 'warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
|
||||
{ key: 'assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('a step whose params do not parse is priced with none rather than dropped', () => {
|
||||
// Dropping it would move every step after it up an ordinal, so the meter's
|
||||
// "phase 2 step 3" would name a different step from the one on the screen.
|
||||
const form = {
|
||||
phases: [{ key: 'p', steps: [{ actionId: 'test.spawn', paramsText: '{ not json' }] }] ,
|
||||
}
|
||||
assert.deepEqual(priceBodyFrom(form).phases[0].steps, [{ actionId: 'test.spawn', params: {} }])
|
||||
})
|
||||
|
||||
test('an empty plan is not worth pricing', () => {
|
||||
// Otherwise the meter asks the server what nothing costs on every keystroke of
|
||||
// the title field.
|
||||
assert.equal(worthPricing({ phases: [] }), false)
|
||||
assert.equal(worthPricing({ phases: [{ steps: [] }] }), false)
|
||||
assert.equal(worthPricing({ phases: [{ steps: [{ actionId: '' }] }] }), false)
|
||||
assert.equal(worthPricing({ phases: [{ steps: [{ actionId: 'test.spawn' }] }] }), true)
|
||||
})
|
||||
89
client/test/eventCalendar.test.js
Normal file
89
client/test/eventCalendar.test.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// The public event screens' time rendering (EVENTS_PLAN.md Phase 14a).
|
||||
//
|
||||
// One property matters here and it is EVENTS.md §I's: **the time beside an
|
||||
// entry is the EVENT's zone, the day it is filed under is the READER's.** A
|
||||
// helper that quietly rendered both in the reader's zone would pass any test
|
||||
// that only ever looked at one of them, and would put an American shard's 8pm
|
||||
// event at "02:00" for a player in Berlin — true, useless, and looking like the
|
||||
// shard's own announcement was wrong.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { eventTime, eventDateTime, readerDayLabel, statusWord } from '../src/lib/eventCalendar.js'
|
||||
|
||||
// 2026-09-12T00:00Z is 2026-09-11 20:00 in New York — deliberately an instant
|
||||
// whose DATE differs between the two zones, which is what makes the split
|
||||
// observable at all.
|
||||
const INSTANT = '2026-09-12T00:00:00.000Z'
|
||||
|
||||
test('the time is rendered in the EVENT’s zone, not the reader’s', () => {
|
||||
assert.equal(eventTime(INSTANT, 'America/New_York'), '20:00 New York')
|
||||
assert.equal(eventTime(INSTANT, 'UTC'), '00:00 UTC')
|
||||
assert.equal(eventTime(INSTANT, 'Europe/Berlin'), '02:00 Berlin')
|
||||
})
|
||||
|
||||
test('the zone is named in a form a reader recognises', () => {
|
||||
// `America/New_York` is a database identifier, not something to show a player.
|
||||
assert.match(eventTime(INSTANT, 'America/Los_Angeles'), /Los Angeles$/)
|
||||
})
|
||||
|
||||
test('an unknown zone falls back to UTC rather than throwing', () => {
|
||||
// `Intl` rejects an unknown identifier, and an event whose timezone column
|
||||
// holds a typo must still render.
|
||||
assert.equal(eventTime(INSTANT, 'Not/AZone'), '00:00 UTC')
|
||||
assert.equal(eventDateTime(INSTANT, 'Not/AZone'), '2026-09-12 00:00 UTC')
|
||||
})
|
||||
|
||||
test('a bad instant renders as nothing rather than as "Invalid Date"', () => {
|
||||
assert.equal(readerDayLabel('not a date'), '')
|
||||
assert.equal(eventDateTime('not a date', 'UTC'), '')
|
||||
})
|
||||
|
||||
test('the day label is the reader’s own day, whatever the event’s zone', () => {
|
||||
// Two entries at the same instant in different event zones are filed under one
|
||||
// heading, which is what makes a chronological list group correctly.
|
||||
assert.equal(readerDayLabel(INSTANT), readerDayLabel(INSTANT))
|
||||
const label = readerDayLabel(INSTANT)
|
||||
assert.ok(label.length > 0)
|
||||
// The instant's UTC date is the 12th and New York's is the 11th; the label
|
||||
// must not carry a zone at all, because it is neither of theirs.
|
||||
assert.equal(/UTC|New York/.test(label), false)
|
||||
})
|
||||
|
||||
test('eventDateTime carries the day and the zone together', () => {
|
||||
const text = eventDateTime(INSTANT, 'America/New_York')
|
||||
assert.match(text, /New York$/)
|
||||
assert.match(text, /20:00/)
|
||||
})
|
||||
|
||||
// ── The status word ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Found by the browser walk: the calendar was saying "DID NOT HAPPEN" about a
|
||||
// run four days out that an operator had cancelled. The server publishes
|
||||
// `failed` and `missed` as `cancelled` too — to a visitor the three are one
|
||||
// event — but they do not share one English sentence, so the tense follows the
|
||||
// clock rather than the status.
|
||||
|
||||
const NOW = Date.parse('2026-09-08T12:00:00Z')
|
||||
|
||||
test('a cancelled occurrence in the future reads "Cancelled"', () => {
|
||||
assert.equal(statusWord('cancelled', '2026-09-12T18:00:00Z', NOW), 'Cancelled')
|
||||
})
|
||||
|
||||
test('a cancelled occurrence in the past reads "Did not happen"', () => {
|
||||
// Which is also the honest word for the failed and missed runs folded into
|
||||
// `cancelled` on the way out.
|
||||
assert.equal(statusWord('cancelled', '2026-09-04T18:00:00Z', NOW), 'Did not happen')
|
||||
})
|
||||
|
||||
test('the other three words do not depend on the clock at all', () => {
|
||||
for (const at of ['2026-09-04T18:00:00Z', '2026-09-12T18:00:00Z']) {
|
||||
assert.equal(statusWord('live', at, NOW), 'Happening now')
|
||||
assert.equal(statusWord('scheduled', at, NOW), 'Scheduled')
|
||||
assert.equal(statusWord('completed', at, NOW), 'Finished')
|
||||
}
|
||||
})
|
||||
|
||||
test('an unreadable instant falls to the past-tense word rather than throwing', () => {
|
||||
assert.equal(statusWord('cancelled', 'not a date', NOW), 'Did not happen')
|
||||
})
|
||||
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')
|
||||
}
|
||||
})
|
||||
@@ -1774,9 +1774,18 @@ CREATE TABLE IF NOT EXISTS engagement_cooldowns (
|
||||
-- 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),
|
||||
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.
|
||||
@@ -1785,6 +1794,29 @@ CREATE TABLE IF NOT EXISTS engagement_cooldowns (
|
||||
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 (
|
||||
@@ -1818,7 +1850,10 @@ CREATE TABLE IF NOT EXISTS engagement_outbox (
|
||||
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)
|
||||
INDEX idx_engo_cancel (rule_id, user_id, subject_key, status),
|
||||
-- Phase 14. The sweep is `status IN (terminal) AND created_at < ?`, and
|
||||
-- `idx_engo_due` cannot serve it: its second column is `due_at`.
|
||||
INDEX idx_engo_sweep (status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- G15: the per-message record. Today "did user X get the mail?" is unanswerable.
|
||||
@@ -1845,7 +1880,11 @@ CREATE TABLE IF NOT EXISTS engagement_sends (
|
||||
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)
|
||||
INDEX idx_engs_rule_window (rule_id, created_at),
|
||||
-- Phase 14's retention sweep deletes by age alone, so it needs `created_at`
|
||||
-- LEADING. Every index above has it in second position, which serves a
|
||||
-- per-rule or per-user window and is useless to a whole-table horizon.
|
||||
INDEX idx_engs_sweep (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- §4.4. The mail (and, from Phase 7, in-app) bodies an operator can edit, stored
|
||||
@@ -1891,3 +1930,769 @@ CREATE TABLE IF NOT EXISTS engagement_templates (
|
||||
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;
|
||||
|
||||
-- Phase 14 (retention). The two sweep indexes, for deployments whose tables
|
||||
-- predate them. `IF NOT EXISTS` on an index is MariaDB-only and already used
|
||||
-- above (`idx_wiki_search`), so this needs no INFORMATION_SCHEMA guard like the
|
||||
-- cooldown primary-key change did -- that one needed one because MariaDB has no
|
||||
-- conditional form of a PRIMARY KEY change, not because indexes lack one.
|
||||
ALTER TABLE engagement_outbox ADD INDEX IF NOT EXISTS idx_engo_sweep (status, created_at);
|
||||
ALTER TABLE engagement_sends ADD INDEX IF NOT EXISTS idx_engs_sweep (created_at);
|
||||
|
||||
-- §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;
|
||||
|
||||
-- ── The Event System (EVENTS.md §D — Phase 1) ──────────────────────────────
|
||||
|
||||
-- Six of the eleven core tables land here: the ones that do not depend on the
|
||||
-- module contract. The rest arrive with the phases that give them a writer
|
||||
-- rather than as empty tables nothing reads -- `event_run_phase_gates` in P5,
|
||||
-- `event_action_settings` and `event_run_budget` in P6, `event_run_resources` in
|
||||
-- P8 (below) and `event_run_participants` in P10.
|
||||
--
|
||||
-- Core tables, so no module prefix, and no game vocabulary anywhere below: an
|
||||
-- action id, a scope, a resource kind and a budget dimension are all opaque
|
||||
-- strings core stores and never interprets (§C).
|
||||
|
||||
-- The arc. Definitions optionally belong to one, and the series is what carries
|
||||
-- continuity across them — "Royal Spy Mission -> Risky Partner -> Message From
|
||||
-- the Void" is a thing the tooling this replaces cannot express at all.
|
||||
--
|
||||
-- The table lands in Phase 1 because `event_definitions.series_id` points at it;
|
||||
-- the routes that create and order one are Phase 4's, where the calendar makes
|
||||
-- an arc visible.
|
||||
CREATE TABLE IF NOT EXISTS event_series (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
slug VARCHAR(160) NOT NULL,
|
||||
description TEXT NULL,
|
||||
-- Where this series sits among the others on the calendar. Not a position
|
||||
-- WITHIN the series: a definition's place in its arc is `event_definitions`'
|
||||
-- own `series_order` below, because that is the column an editor drags.
|
||||
ordering INT NOT NULL DEFAULT 0,
|
||||
created_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_evser_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uq_evser_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The thing that is listed, searched, scheduled and audited.
|
||||
--
|
||||
-- **Three states, not five** (§E). There is no `submitted` and no `approved`: an
|
||||
-- admin publishes their own work, so there is nobody to submit it to, and a
|
||||
-- review state nobody uses is a state every query has to remember anyway.
|
||||
--
|
||||
-- `owner_module` is NULLable and is the module that SHIPPED this definition as
|
||||
-- content, not the module whose actions its steps call — a definition may call
|
||||
-- three modules' verbs and belong to none of them. NULL means an operator
|
||||
-- authored it here, which is the ordinary case.
|
||||
--
|
||||
-- `concurrency_key` is stored as the TEMPLATE, not as the rendered value
|
||||
-- (`invasion:{region}`), because it is rendered from a run's own params at
|
||||
-- materialisation (§E). A flat definition-id key would wrongly stop one
|
||||
-- definition running in two regions at once.
|
||||
--
|
||||
-- `current_version_id` carries NO foreign key, deliberately, and it is the one
|
||||
-- column in this group without one: `event_versions.definition_id` already
|
||||
-- points back here, and a second FK in the other direction makes the pair a
|
||||
-- chicken and an egg on insert.
|
||||
CREATE TABLE IF NOT EXISTS event_definitions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
slug VARCHAR(200) NOT NULL,
|
||||
summary VARCHAR(500) NULL,
|
||||
-- The storyline. Sanitized HTML, the same treatment a wiki page gets.
|
||||
body MEDIUMTEXT NULL,
|
||||
image_url VARCHAR(500) NULL,
|
||||
owner_module VARCHAR(64) NULL,
|
||||
state ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft',
|
||||
current_version_id INT NULL,
|
||||
-- The WORKING COPY of the spec - phases and their steps - as the author last
|
||||
-- saved it. §D's column list does not name it because §D describes what a
|
||||
-- PUBLISHED event is made of, and a version row is where a spec ends up. But
|
||||
-- "editing a draft is free; no version exists yet" (EVENTS.md "Versioning")
|
||||
-- has to mean the draft lives somewhere, and it cannot be an `event_versions`
|
||||
-- row: that table is immutable and a run pins one, so a mutable unpublished
|
||||
-- row in it would be the exact thing versioning exists to prevent. Publishing
|
||||
-- copies this column into a version and leaves it here as the next draft.
|
||||
spec JSON NOT NULL,
|
||||
series_id INT NULL,
|
||||
series_order INT NOT NULL DEFAULT 0,
|
||||
concurrency_key VARCHAR(190) NULL,
|
||||
-- The grace window (§E). A schedule that passed this many seconds ago while the
|
||||
-- process was down is `missed`, never a late silent start.
|
||||
grace_seconds INT NOT NULL DEFAULT 900,
|
||||
-- IANA, and it belongs to the EVENT rather than to the viewer: every listing
|
||||
-- this replaces is written in the shard's local zone, and a recurrence computed
|
||||
-- in UTC puts a Friday-8pm event at 7pm for half the year.
|
||||
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
|
||||
created_by INT 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_evdef_series FOREIGN KEY (series_id) REFERENCES event_series(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_evdef_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_evdef_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uq_evdef_slug (slug),
|
||||
-- The admin list's default ordering, and the public calendar's filter.
|
||||
INDEX idx_evdef_state (state, updated_at),
|
||||
INDEX idx_evdef_series (series_id, series_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- An immutable snapshot of a whole definition spec: phases, steps, schedule,
|
||||
-- conditions, announcements. A run pins one, and that pin is the entire reason
|
||||
-- this table exists — it is what makes a run reproducible, and an audit
|
||||
-- answerable, after the definition has been edited underneath it.
|
||||
--
|
||||
-- Nothing updates a row here. Editing a `ready` definition creates the NEXT
|
||||
-- version on publish; a live run keeps the version it pinned and is unaffected
|
||||
-- (EVENTS.md "Versioning, and editing a live event").
|
||||
CREATE TABLE IF NOT EXISTS event_versions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
definition_id INT NOT NULL,
|
||||
version INT NOT NULL,
|
||||
spec JSON NOT NULL,
|
||||
published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
published_by INT NULL,
|
||||
CONSTRAINT fk_evver_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evver_user FOREIGN KEY (published_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- Two publishes racing for version 4 is one 1062, not two rows called 4.
|
||||
UNIQUE KEY uq_evver_def_version (definition_id, version)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- One occurrence of one definition, in one scope.
|
||||
--
|
||||
-- The UNIQUE key below — not the claim — is what makes "one run per occurrence
|
||||
-- per scope" true (§E). The claim decides WHO advances an occurrence; this index
|
||||
-- is what stops two of them existing. `scope` is inside the key so a worldwide
|
||||
-- event fans out to many servers without colliding with itself, and it is
|
||||
-- module-opaque: core stores the string and never parses it.
|
||||
--
|
||||
-- `scheduled_for` is UTC. The definition's IANA zone is what the occurrence was
|
||||
-- COMPUTED in (Phase 4); what is stored is the instant.
|
||||
--
|
||||
-- `health` is a separate column from `status` because a run can be genuinely
|
||||
-- running and degraded at once — announcements landing, world writes parked —
|
||||
-- and one column cannot say both. `cleanup_status` is separate for the mirror
|
||||
-- reason: a run reaches `completed` with `cleanup_status = 'incomplete'` rather
|
||||
-- than being held open, and stays on the admin screen until a human resolves it.
|
||||
--
|
||||
-- `version_id`'s foreign key has no ON DELETE clause, so it RESTRICTs: a run
|
||||
-- whose pinned spec had been deleted could not be explained afterwards, which is
|
||||
-- the one thing this table is for.
|
||||
CREATE TABLE IF NOT EXISTS event_runs (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
definition_id INT NOT NULL,
|
||||
version_id INT NOT NULL,
|
||||
-- Module-opaque, and '' rather than NULL for the single-scope case: it is part
|
||||
-- of a UNIQUE key, and multiple NULLs do not collide in MariaDB, so a NULL
|
||||
-- scope would silently permit two runs of one occurrence.
|
||||
scope VARCHAR(190) NOT NULL DEFAULT '',
|
||||
status ENUM('scheduled','starting','running','paused','ending',
|
||||
'completed','cancelled','failed','missed')
|
||||
NOT NULL DEFAULT 'scheduled',
|
||||
health ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok',
|
||||
cleanup_status ENUM('not_required','pending','complete','incomplete')
|
||||
NOT NULL DEFAULT 'not_required',
|
||||
current_phase VARCHAR(64) NULL,
|
||||
scheduled_for DATETIME NOT NULL,
|
||||
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
|
||||
concurrency_key VARCHAR(190) NULL, -- rendered from this run's params
|
||||
params JSON NULL,
|
||||
-- A rehearsal dispatches for real but is excluded from the public calendar and
|
||||
-- from participation history. Declared here in Phase 1 so the column exists
|
||||
-- before anything can create a run without it.
|
||||
rehearsal TINYINT(1) NOT NULL DEFAULT 0,
|
||||
started_at DATETIME NULL,
|
||||
ended_at DATETIME NULL,
|
||||
claimed_by VARCHAR(64) NULL,
|
||||
claim_expires_at DATETIME NULL,
|
||||
started_by INT NULL,
|
||||
last_error VARCHAR(500) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evrun_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evrun_version FOREIGN KEY (version_id) REFERENCES event_versions(id),
|
||||
CONSTRAINT fk_evrun_user FOREIGN KEY (started_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uq_evrun_occurrence (definition_id, scope, scheduled_for),
|
||||
-- The runner's materialise/advance scan: due runs by status.
|
||||
INDEX idx_evrun_due (status, scheduled_for),
|
||||
-- The admin run list, newest first, and the per-definition history.
|
||||
INDEX idx_evrun_def (definition_id, scheduled_for),
|
||||
-- Phase 2's overlap check. NULL keys are skipped by the index, which is right:
|
||||
-- a definition with no key never contends.
|
||||
INDEX idx_evrun_concurrency (concurrency_key, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The work queue: one action invocation, claimed with the outbox's
|
||||
-- compare-and-set. This is the shape `engagement_outbox` already proved, and
|
||||
-- retries, timeouts, duplicate execution and resumption are all properties of
|
||||
-- this row rather than of a scheduler's memory.
|
||||
--
|
||||
-- `idempotency_key` is minted ONCE at materialisation and does not vary by
|
||||
-- attempt (§E) — a retry re-sends the same key so the game side can recognise
|
||||
-- the repeat. It is generated by core rather than by the module because core is
|
||||
-- what guarantees its stability.
|
||||
--
|
||||
-- `action_version` records what the step was AUTHORED against. A module that
|
||||
-- bumps its action makes the step render a warning in the editor rather than
|
||||
-- dispatch a mistyped parameter.
|
||||
--
|
||||
-- `refused` is in the status set and is deliberately not `failed`: a cap breach
|
||||
-- means nothing is wrong with the system, and an author asked for more than this
|
||||
-- deployment allows.
|
||||
CREATE TABLE IF NOT EXISTS event_run_steps (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
phase VARCHAR(64) NOT NULL,
|
||||
seq INT NOT NULL,
|
||||
action_id VARCHAR(96) NOT NULL,
|
||||
params JSON NULL,
|
||||
action_version INT NOT NULL DEFAULT 1,
|
||||
status ENUM('pending','running','done','failed','skipped','refused','cancelled')
|
||||
NOT NULL DEFAULT 'pending',
|
||||
due_at DATETIME NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
-- Defaulted from the action's risk class at materialisation (§L): retry->skip
|
||||
-- for notify, retry->pause for change, retry->abort_run for irreversible.
|
||||
on_failure VARCHAR(32) NOT NULL DEFAULT 'skip',
|
||||
idempotency_key CHAR(40) NOT NULL,
|
||||
claimed_by VARCHAR(64) NULL,
|
||||
claim_expires_at DATETIME NULL,
|
||||
last_error VARCHAR(500) NULL,
|
||||
started_at DATETIME NULL,
|
||||
finished_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evstep_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
-- The drain scan, exactly as written: due, pending, oldest first.
|
||||
INDEX idx_evstep_due (status, due_at),
|
||||
-- The run console: every step of one run in authored order.
|
||||
INDEX idx_evstep_run (run_id, phase, seq),
|
||||
-- Materialisation is INSERT IGNORE against this, so a tick that overruns into
|
||||
-- the next one cannot double-materialise a phase.
|
||||
UNIQUE KEY uq_evstep_slot (run_id, phase, seq)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- "Why didn't phase 3 start?" must be a query.
|
||||
--
|
||||
-- `activity_log.detail` is TEXT and unqueryable, which is the whole reason this
|
||||
-- table exists rather than the audit log being reused: an operator diagnosing a
|
||||
-- stalled phase needs to filter by kind and read structured detail, and an
|
||||
-- administrative audit of WHO published WHAT is a different question with a
|
||||
-- different retention. Both are written — the audit to `activity_log`, the
|
||||
-- diagnosis here.
|
||||
--
|
||||
-- `kind` is a closed set enforced in `eventRunLog.db.js` rather than an ENUM,
|
||||
-- because the set grows with almost every later phase and an ENUM change is a
|
||||
-- table alter this project has no migration system for.
|
||||
--
|
||||
-- The log is high-cardinality and grows per event, so it needs a retention sweep
|
||||
-- from the start — `engagementRetentionPrune` is the pattern, and the rule that
|
||||
-- work learned is that only TERMINAL rows are eligible. The sweep itself lands
|
||||
-- with the runner in Phase 2; the index it needs is here from the beginning.
|
||||
CREATE TABLE IF NOT EXISTS event_run_log (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
step_id BIGINT NULL,
|
||||
kind VARCHAR(48) NOT NULL,
|
||||
phase VARCHAR(64) NULL,
|
||||
detail JSON NULL,
|
||||
at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evlog_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evlog_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL,
|
||||
-- The run console reads this whole index and nothing else.
|
||||
INDEX idx_evlog_run (run_id, at),
|
||||
-- What the Phase 2 retention sweep queries. Without it the sweep is a table
|
||||
-- scan of every line this deployment has ever logged.
|
||||
INDEX idx_evlog_at (at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- What a phase is waiting for, and how far it has got (§E, Phase 5).
|
||||
--
|
||||
-- A phase used to advance on one fact — every step terminal — and that fact
|
||||
-- lives in `event_run_steps`. An advance CONDITION is a second fact, and it is
|
||||
-- not derivable from any row that already exists: `{ on: 'uo.champ.boss_up',
|
||||
-- count: 3 }` is a tally of things that happened between one tick and the next,
|
||||
-- and the runner is not running when they happen. This table is where a firing
|
||||
-- is counted at the moment it fires.
|
||||
--
|
||||
-- **One row per (run, phase), created at phase entry by INSERT IGNORE**, the
|
||||
-- same idempotence `materialisePhase` has and for the same reason: a process
|
||||
-- that died between entering a phase and writing this must not open a second
|
||||
-- gate on the next tick.
|
||||
--
|
||||
-- **The tally is incremented by one statement with the threshold in it**, never
|
||||
-- read-then-written — the argument `event_run_budget`'s conditional increment
|
||||
-- makes, one phase early. Two emits arriving together each add one, and exactly
|
||||
-- one of them crosses `needed`.
|
||||
--
|
||||
-- `last_event` holds ONLY the variables the condition names, not the payload.
|
||||
-- It exists to answer "what did the last one look like, and why did it not
|
||||
-- count", and a copy of a whole game event's data is a second copy of exactly
|
||||
-- the content `engagement_sends` is careful not to keep.
|
||||
--
|
||||
-- `satisfied_by` is a VARCHAR rather than an ENUM for `event_run_log.kind`'s
|
||||
-- reason: the set can grow (an authored timeout was considered and declined for
|
||||
-- Phase 5) and this project has no migration system for a column alter. `kind`
|
||||
-- IS an ENUM, because §E closes it at two shapes.
|
||||
CREATE TABLE IF NOT EXISTS event_run_phase_gates (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
phase VARCHAR(64) NOT NULL,
|
||||
kind ENUM('after','on') NOT NULL,
|
||||
-- kind='after': normalised to seconds at save, so the runner never parses a
|
||||
-- duration string. `due_at` is entered_at + this, computed once at entry.
|
||||
after_seconds INT NULL,
|
||||
-- kind='on': the trigger being waited on and the predicate over its declared
|
||||
-- variables. `conditions` is NULL for "any firing of this trigger".
|
||||
trigger_id VARCHAR(96) NULL,
|
||||
conditions JSON NULL,
|
||||
needed INT NOT NULL DEFAULT 1,
|
||||
tally INT NOT NULL DEFAULT 0,
|
||||
entered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
due_at DATETIME NULL,
|
||||
last_event JSON NULL,
|
||||
last_event_at DATETIME NULL,
|
||||
satisfied_at DATETIME NULL,
|
||||
satisfied_by VARCHAR(16) NULL, -- 'condition' | 'elapsed' | 'forced'
|
||||
forced_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_evgate_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evgate_user FOREIGN KEY (forced_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- Entry is INSERT IGNORE against this.
|
||||
UNIQUE KEY uq_evgate_phase (run_id, phase),
|
||||
-- The emit path's only query: every open gate waiting on this trigger. It runs
|
||||
-- on every game event of every trigger anything waits on, so it is the one
|
||||
-- index in this feature that is on a hot path rather than an admin screen.
|
||||
INDEX idx_evgate_open (trigger_id, satisfied_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Enablement, caps and the verify gate (EVENTS.md §D/§K — Phase 6) ───────
|
||||
|
||||
-- The deployment's switchboard, and the whole of the permission model beyond the
|
||||
-- role (§K layer 2).
|
||||
--
|
||||
-- **Not a grant table.** Nobody is named in it, because the role check already
|
||||
-- answered who; this table answers *what this deployment permits at all*, and
|
||||
-- how much of it per run. That is the distinction §K draws between a capability
|
||||
-- and permission to invoke it: a module declaring `uo.creature.spawn` is code the
|
||||
-- operator installed, not a permission they granted.
|
||||
--
|
||||
-- **A missing row is not "disabled" — it is "the default for its risk class".**
|
||||
-- Rows are written when an admin changes something, never seeded at boot, for a
|
||||
-- reason that is structural rather than tidy: the registry is assembled in
|
||||
-- `registerCore()` and by module `register()`, both of which run under
|
||||
-- `routeManifest.js` and `swagger.js` against a DEAD POOL (MODULE_API.md §2.2).
|
||||
-- A boot-time seed from the registry would be exactly the database write those
|
||||
-- two forbid. Reading a default from the risk class costs one branch and means a
|
||||
-- deployment that never opens this screen behaves correctly.
|
||||
--
|
||||
-- The row survives its action: uninstalling a module leaves the settings behind,
|
||||
-- so re-installing it restores the caps the operator chose rather than silently
|
||||
-- resetting them. The switchboard only lists what is registered *now*, so a
|
||||
-- stranded row is invisible until its action comes back.
|
||||
--
|
||||
-- `action_id` is the primary key rather than an id column: there is exactly one
|
||||
-- row per action and every read is by that id.
|
||||
CREATE TABLE IF NOT EXISTS event_action_settings (
|
||||
action_id VARCHAR(96) NOT NULL PRIMARY KEY,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
-- `{dimension: perRunCap}`. A dimension absent from this object is uncapped by
|
||||
-- this action; an empty object is an action that declares no cost at all.
|
||||
caps JSON NULL,
|
||||
updated_by INT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evset_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- What one run has spent, and the most it may.
|
||||
--
|
||||
-- **The cap is COPIED here at run start, not read live.** A run is already
|
||||
-- reproducible in every other respect — it pins a version, and the version is
|
||||
-- immutable — and a cap read live would be the one input to a run's behaviour
|
||||
-- that an admin could change underneath it at three in the morning. Copying also
|
||||
-- makes the console's meter answer the right question afterwards: "what was this
|
||||
-- run allowed", not "what is allowed now".
|
||||
--
|
||||
-- **One row per dimension, so the cap is the tightest of the actions the run's
|
||||
-- version names** (org lead, 2026-09-03). Two actions that both spend
|
||||
-- `uo.creatures` share this row, which is what makes a dimension a bound on the
|
||||
-- run's total effect rather than a per-verb allowance. `effective_from` records
|
||||
-- which action's cap won, so the console can say so.
|
||||
--
|
||||
-- `consumed + ? <= cap` in the WHERE is the whole concurrency story (§E): two
|
||||
-- steps drawing on one dimension in the same tick cannot both see 28/30 and both
|
||||
-- spend, and no transaction is needed to say so. Same shape as the outbox claim
|
||||
-- and the gate's conditional increment, and the same reason.
|
||||
CREATE TABLE IF NOT EXISTS event_run_budget (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
dimension VARCHAR(96) NOT NULL,
|
||||
consumed INT NOT NULL DEFAULT 0,
|
||||
-- **NULL is uncapped, and it is a row rather than an absent one.** A dimension
|
||||
-- every action naming it left uncapped still accumulates here, so the console's
|
||||
-- meter can say "14 spawned, no cap" -- and so that a MISSING row keeps its one
|
||||
-- unambiguous meaning: a step spending a dimension its own run's version never
|
||||
-- priced, which `spend()` refuses.
|
||||
cap INT NULL,
|
||||
-- The action whose cap was the minimum. Documentary: it is what lets the run
|
||||
-- console say "30, from uo.creature.spawn" rather than showing a number the
|
||||
-- operator cannot trace back to a switch they set.
|
||||
effective_from VARCHAR(96) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evbud_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
-- Seeding is INSERT IGNORE against this, so a tick that overruns into the next
|
||||
-- one cannot double-seed a run's budget.
|
||||
UNIQUE KEY uq_evbud_dim (run_id, dimension)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The cleanup ledger: everything one run created or leased, and what became of
|
||||
-- it (EVENTS.md §D, §L "The ledger's two rules"; Phase 8).
|
||||
--
|
||||
-- **It holds both kinds of thing an event owns.** An OBJECT it created is
|
||||
-- `kind: 'creature'` with `ref` a serial, reverted by its own action's
|
||||
-- `revert()`. A VALUE it leased is `kind: 'override'` with `ref` the lease id and
|
||||
-- `payload` carrying the baseline and what was applied, restored by the lease's
|
||||
-- own `restore()`. One table, because cleanup asks both the same question --
|
||||
-- what is still out there, and did putting it back work.
|
||||
--
|
||||
-- **Rule 1: a resource is recorded BEFORE it is confirmed.** A spawn's serial
|
||||
-- does not exist until the module answers, so what is written before the dispatch
|
||||
-- is a PLACEHOLDER keyed by the step's idempotency key (`kind` = the reserved
|
||||
-- '@step', `ref` = that key). On the answer the reported resources are inserted
|
||||
-- `confirmed` and the placeholder is resolved. If the acknowledgement is lost the
|
||||
-- placeholder survives, and cleanup calls `revert()` with the idempotency key and
|
||||
-- no resources -- which is why §F's `revert({ runId, resources, idempotencyKey })`
|
||||
-- takes the key at all. Recording afterwards instead would make every object
|
||||
-- whose ack was lost invisible to cleanup for ever.
|
||||
--
|
||||
-- **Rule 2: revert is idempotent, and its failure is loud and sticky.** A row
|
||||
-- that never reverts stays visible -- the run reaches `completed` with
|
||||
-- `cleanup_status = 'incomplete'` rather than being held `running`, because a
|
||||
-- tidy `completed` over a shard full of orphaned monsters is the failure that
|
||||
-- would end this feature's credibility on its first bad night.
|
||||
--
|
||||
-- **The unique key is what stops two events leasing one target**, and it must
|
||||
-- hold among LIVE rows only: last week's finished event must not keep this
|
||||
-- week's from leasing the same rate. MariaDB has no partial index, so the
|
||||
-- encoding is a STORED generated column that is NULL once the row is no longer
|
||||
-- ours -- and multiple NULLs do not collide in a unique index. It is derived from
|
||||
-- `status` ALONE and the opaque columns stay in the KEY, which is the shape
|
||||
-- TEAMS.md §2.5 had to be corrected into: MariaDB refuses ON DELETE SET NULL on a
|
||||
-- foreign key whose column is a base column of a stored generated column
|
||||
-- (error 1901), so `step_id` must not appear in the expression.
|
||||
--
|
||||
-- **The key is held by the three statuses that mean "core still believes this is
|
||||
-- ours"** -- `pending`, `confirmed`, `reverting` -- and released by the three that
|
||||
-- mean it is not. §D says "among non-reverted rows", which was written before the
|
||||
-- six statuses had their meanings; taken literally it makes `drifted` and
|
||||
-- `orphaned` hold a target for ever, so one bad night would disable a lease
|
||||
-- permanently with no control able to clear it. `drifted` means somebody else has
|
||||
-- hold of the value and this run has deliberately let go of it; `orphaned` means
|
||||
-- it vanished. Neither is a claim on the target, and both stay LOUD by another
|
||||
-- mechanism -- `cleanup_status = 'incomplete'` and a row on the run console --
|
||||
-- which is what §L's rule 2 actually asks for. Amended 2026-09-03.
|
||||
CREATE TABLE IF NOT EXISTS event_run_resources (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
-- Which step made it. It is how cleanup finds the ACTION to call `revert()` on:
|
||||
-- the row records the module and the opaque names, and the step records the
|
||||
-- verb. SET NULL rather than CASCADE, for `engagement_sends`' reason -- a record
|
||||
-- of what was changed in the world must outlive the row that scheduled it.
|
||||
step_id BIGINT NULL,
|
||||
-- The registering module, copied at record time rather than derived from the
|
||||
-- action id, so an uninstalled module still names itself on the console.
|
||||
owner_module VARCHAR(64) NOT NULL,
|
||||
-- Both module-opaque, stored verbatim, never interpreted -- `ctx.teams.activity.push`'s
|
||||
-- treatment. '@step' is the one reserved `kind` and core owns it.
|
||||
kind VARCHAR(64) NOT NULL,
|
||||
ref VARCHAR(190) NOT NULL,
|
||||
payload JSON NULL,
|
||||
-- A lease's deadline, and NULL for an owned object. It goes DOWN THE WIRE as
|
||||
-- well: the game side restores baseline when it passes, without being asked
|
||||
-- again, which is the fail-safe that makes an unattended world change
|
||||
-- defensible. This column is core's copy of that promise, for the console and
|
||||
-- for the boot-time check.
|
||||
lease_until DATETIME NULL,
|
||||
status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')
|
||||
NOT NULL DEFAULT 'pending',
|
||||
-- Bounded like a step's `attempts`, and for the same reason: a revert that can
|
||||
-- never succeed must become visible rather than cycling for ever. Engagement
|
||||
-- Phase 14's rule -- only a terminal row is ever retention-eligible -- is what
|
||||
-- makes an unbounded counter a row nothing can ever sweep.
|
||||
revert_attempts INT NOT NULL DEFAULT 0,
|
||||
last_error VARCHAR(500) NULL,
|
||||
-- Optional, and module-opaque like the rest: who received it, for a granted
|
||||
-- reward that results should be able to name. `event_run_participants` joins on
|
||||
-- the same key in Phase 10.
|
||||
member_key VARCHAR(190) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
-- 1 while core still believes this resource is this run's, NULL once it is not.
|
||||
-- See the unique key below; derived from `status` alone, deliberately.
|
||||
live_marker TINYINT AS (IF(status IN ('pending','confirmed','reverting'), 1, NULL)) STORED,
|
||||
CONSTRAINT fk_evres_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evres_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL,
|
||||
-- "Two events cannot hold a lease on one target", among non-reverted rows.
|
||||
UNIQUE KEY uq_evres_target (owner_module, kind, ref, live_marker),
|
||||
-- The run console, and the cleanup sweep's read: one run's ledger in order.
|
||||
INDEX idx_evres_run (run_id, status),
|
||||
-- The cleanup leg's scan across runs, and the boot-time lease self-check.
|
||||
INDEX idx_evres_live (status, lease_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- §K's last bound: "a scheduled definition that has never been verified is the
|
||||
-- case worth refusing to start". A version is immutable, so a dry run that passed
|
||||
-- against it stays true — which is what makes the pass a property of the VERSION
|
||||
-- rather than of the definition, and what makes recording it two columns rather
|
||||
-- than a table.
|
||||
--
|
||||
-- Enforced for SCHEDULED starts only (org lead, 2026-09-03): a human pressing
|
||||
-- start is watching, and that human is the review the gate exists to require.
|
||||
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL;
|
||||
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL;
|
||||
|
||||
-- Whether this definition appears on the PUBLIC calendar (Phase 14a).
|
||||
--
|
||||
-- **Not a second answer to the question `state` answers**, which is the trap the
|
||||
-- `findSchedulable` comment in eventDefinitions.db.js warns about: `state` says
|
||||
-- whether an event is SCHEDULABLE, and this says whether it is ANNOUNCED. The
|
||||
-- two came apart the moment there was a public surface at all, because
|
||||
-- publishing is what makes a definition runnable -- so without this column a
|
||||
-- surprise invasion would have to be advertised a fortnight in advance in order
|
||||
-- to be allowed to happen.
|
||||
--
|
||||
-- Default 1, so every definition that exists keeps the behaviour it had while
|
||||
-- the only reader was staff, and unlisting is the deliberate act.
|
||||
--
|
||||
-- It hides the definition, its runs and its projections from the public
|
||||
-- surfaces and from a participant's own history. It hides nothing from staff:
|
||||
-- the admin calendar is the operational view, and an event nobody outside can
|
||||
-- see is still an event the team is running.
|
||||
ALTER TABLE event_definitions ADD COLUMN IF NOT EXISTS listed TINYINT(1) NOT NULL DEFAULT 1;
|
||||
|
||||
-- ── Integrations: participants, results and the run's announcements
|
||||
-- (EVENTS.md §D/§J — Phase 10) ─────────────────────────────────────────────
|
||||
|
||||
-- Who took part, and how well. The eleventh and last of §D's core tables.
|
||||
--
|
||||
-- **Core writes this table and never sources it.** A `member_key` is
|
||||
-- module-opaque, exactly like a resource's `ref`: core cannot map "Darrow of
|
||||
-- Britain" onto a user row and must not try, because the mapping is one game's
|
||||
-- (`shard_links`, for module-uo) and would be compiled into core the moment it
|
||||
-- guessed. A module that knows both halves supplies both — `memberKey` always,
|
||||
-- `userId` when its own link table has one — and core stores what it is told.
|
||||
--
|
||||
-- `SET NULL` rather than `CASCADE`, matching `engagement_sends`: a record of what
|
||||
-- happened at an event has to survive the deletion of an account that attended
|
||||
-- it, or the results of last year's invasion silently rewrite themselves.
|
||||
--
|
||||
-- **`rank` is NULL until results are published** and is computed then, by
|
||||
-- `core.results.publish`, over `score DESC`. It is a stored column rather than a
|
||||
-- window function in the read because a published result is a fact about a
|
||||
-- moment: a participant added afterwards (a late correction, a module's second
|
||||
-- collect step) must not silently renumber a table people have already read.
|
||||
CREATE TABLE IF NOT EXISTS event_run_participants (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
-- Module-opaque and NOT NULL: it is the identity the module knows, and the
|
||||
-- half of the unique key that makes a repeated collect idempotent. A run whose
|
||||
-- module cannot name its participants has no rows here at all.
|
||||
member_key VARCHAR(190) NOT NULL,
|
||||
user_id INT NULL,
|
||||
-- Signed, because a game may score downward as readily as upward, and DECIMAL
|
||||
-- rather than a float so two equal scores compare equal and a rank is stable.
|
||||
score DECIMAL(18,4) NOT NULL DEFAULT 0,
|
||||
rank_at INT NULL,
|
||||
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
-- Module-opaque. Whatever the module wants results to be able to display
|
||||
-- beside a name -- a class, a city, a kill count -- with no core vocabulary in
|
||||
-- it and nothing core ever reads.
|
||||
meta JSON NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evpart_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evpart_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- One row per participant per run. What makes a module reporting the same set
|
||||
-- twice -- a retried collect step, a second attempt after a timeout -- an
|
||||
-- upsert rather than a duplicated leaderboard.
|
||||
UNIQUE KEY uq_evpart_member (run_id, member_key),
|
||||
-- The results table: one run, best first.
|
||||
INDEX idx_evpart_score (run_id, score),
|
||||
-- Profile history (`GET /player/events/history`, Phase 14), newest first.
|
||||
INDEX idx_evpart_user (user_id, joined_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- When the results table was published, and by which run of the publish action.
|
||||
--
|
||||
-- A stamp rather than a status: a run either has published results or it has
|
||||
-- not, and the two questions a surface asks -- "may I show this table" and "when
|
||||
-- was it settled" -- are the same column. `core.results.publish` is idempotent
|
||||
-- against it (a re-run re-ranks and re-stamps), which is what makes it safe as an
|
||||
-- ordinary retried step.
|
||||
ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS results_published_at DATETIME NULL;
|
||||
|
||||
-- The run an announce job belongs to, when it belongs to one.
|
||||
--
|
||||
-- **Nullable, and every existing row keeps NULL**: the news pipeline's jobs are
|
||||
-- not an event's, and nothing about how they are enqueued, retried or rolled up
|
||||
-- changes. What this buys is that `core.announce.post` may enqueue a SECOND job
|
||||
-- for a post that has already been announced -- the common case, since the post
|
||||
-- an event announces is very often the news post that announced it -- without
|
||||
-- either colliding with the first or overwriting `posts.announce_job_id`, which
|
||||
-- is the back-pointer the post admin panel's retry button reads.
|
||||
--
|
||||
-- **No foreign key, exactly like `posts.announce_job_id` beside it.** An announce
|
||||
-- job that went out is a delivery record and must outlive whatever asked for it,
|
||||
-- and `ADD CONSTRAINT ... FOREIGN KEY` has no `IF NOT EXISTS` in MariaDB -- so a
|
||||
-- constraint here would be the one statement in this file that cannot replay.
|
||||
-- The column is read only to answer "which run announced this", and a run id
|
||||
-- that no longer resolves answers that honestly.
|
||||
ALTER TABLE announce_jobs ADD COLUMN IF NOT EXISTS run_id BIGINT NULL;
|
||||
ALTER TABLE announce_jobs ADD INDEX IF NOT EXISTS idx_announce_run (run_id);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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')
|
||||
@@ -81,6 +82,13 @@ async function seedDefaults() {
|
||||
// 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')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,483 @@
|
||||
{
|
||||
"_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.7.0",
|
||||
"moduleApiVersion": "1.10.0",
|
||||
"triggers": [
|
||||
{
|
||||
"id": "event.phase.changed",
|
||||
"owner": "core",
|
||||
"label": "Event — a new phase",
|
||||
"description": "An event that is under way has moved on to its next stage.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "phase",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "assault",
|
||||
"description": "The phase key just entered, as authored in the spec."
|
||||
},
|
||||
{
|
||||
"name": "phaseLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The assault",
|
||||
"description": "The phase label, when the spec gave it one. Falls back to the key."
|
||||
},
|
||||
{
|
||||
"name": "phaseIndex",
|
||||
"type": "int",
|
||||
"required": true,
|
||||
"example": 2,
|
||||
"description": "Which phase this is, counting from 1."
|
||||
},
|
||||
{
|
||||
"name": "phaseCount",
|
||||
"type": "int",
|
||||
"required": true,
|
||||
"example": 4,
|
||||
"description": "How many phases the pinned version has in total."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.cancelled",
|
||||
"owner": "core",
|
||||
"label": "Event — cancelled",
|
||||
"description": "A scheduled event was cancelled by a member of staff.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "reason",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The shard is down for an emergency patch.",
|
||||
"description": "What the staff member gave as the reason, when they gave one."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.completed",
|
||||
"owner": "core",
|
||||
"label": "Event — finished",
|
||||
"description": "An event has finished.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "participantCount",
|
||||
"type": "int",
|
||||
"required": true,
|
||||
"example": 47,
|
||||
"description": "How many participants the run recorded. Zero when nothing collected any."
|
||||
},
|
||||
{
|
||||
"name": "durationMinutes",
|
||||
"type": "int",
|
||||
"required": true,
|
||||
"example": 95,
|
||||
"description": "How long the run took, start to end, in whole minutes."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.ending",
|
||||
"owner": "core",
|
||||
"label": "Event — winding down",
|
||||
"description": "An event is drawing to a close.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.failed",
|
||||
"owner": "core",
|
||||
"label": "Event — run failed",
|
||||
"description": "An event stopped before it finished.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "admin",
|
||||
"ceiling": "admin",
|
||||
"version": 1,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "phase",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "assault",
|
||||
"description": "The phase it failed in, when it had entered one."
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "sidecar responded 503",
|
||||
"description": "The run’s last error, verbatim from the run row."
|
||||
},
|
||||
{
|
||||
"name": "runUrl",
|
||||
"type": "url",
|
||||
"required": true,
|
||||
"example": "/admin/events/runs/3692",
|
||||
"description": "Site-relative path to the run console."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.scheduled",
|
||||
"owner": "core",
|
||||
"label": "Event — scheduled",
|
||||
"description": "A new event has been added to the calendar.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "startsAt",
|
||||
"type": "datetime",
|
||||
"required": true,
|
||||
"example": "2026-09-12T20:00:00.000Z",
|
||||
"description": "When the occurrence is due to start, UTC."
|
||||
},
|
||||
{
|
||||
"name": "startsAtLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Saturday 12 September at 8:00 pm (America/New_York)",
|
||||
"description": "The start time written out in the shard-local zone, for a mail to read."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.run.started",
|
||||
"owner": "core",
|
||||
"label": "Event — starting now",
|
||||
"description": "A scheduled event has begun.",
|
||||
"kind": "event",
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "3692",
|
||||
"description": "The run this is about. Also the cooldown subject."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Orcish warbands are massing north of Yew.",
|
||||
"description": "The event’s one-line summary, when it has one."
|
||||
},
|
||||
{
|
||||
"name": "seriesName",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "The Yew Campaign",
|
||||
"description": "The arc this event belongs to, when it belongs to one."
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "America/New_York",
|
||||
"description": "The zone the run was computed in — what a time in the body should be read as."
|
||||
},
|
||||
{
|
||||
"name": "startsAt",
|
||||
"type": "datetime",
|
||||
"required": true,
|
||||
"example": "2026-09-12T20:00:00.000Z",
|
||||
"description": "When it actually started, UTC."
|
||||
},
|
||||
{
|
||||
"name": "startsAtLabel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"example": "Saturday 12 September at 8:00 pm (America/New_York)",
|
||||
"description": "The start time written out in the shard-local zone, for a mail to read."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "news.post",
|
||||
"owner": "core",
|
||||
@@ -38,8 +514,8 @@
|
||||
"name": "postUrl",
|
||||
"type": "url",
|
||||
"required": true,
|
||||
"example": "/news/five-on-friday-yew-invasion",
|
||||
"description": "Site-relative path to the post."
|
||||
"example": "/site/news",
|
||||
"description": "Site-relative path to the post. The news list today — the site has no per-post route."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -194,6 +194,24 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/retention",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/retention",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/rules",
|
||||
@@ -293,6 +311,42 @@
|
||||
"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": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/suppressions/by-hash/:hash",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates",
|
||||
@@ -365,6 +419,276 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/events/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/:id",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/:id",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/publish",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/runs",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/verify",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/:id/versions",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/actions",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/actions",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/calendar",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/catalog",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/catalog/options/:sourceId",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/price",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs/:runId",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/advance",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/cancel",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/cleanup",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs/:runId/log",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/pause",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/resume",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/confirm",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/retry",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/skip",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/series",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/series",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/events/series/:seriesId",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/series/:seriesId",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/invites",
|
||||
@@ -1614,6 +1938,28 @@
|
||||
"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",
|
||||
@@ -1634,6 +1980,15 @@
|
||||
"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",
|
||||
@@ -1683,6 +2038,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",
|
||||
@@ -1903,6 +2267,15 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/events/history",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams",
|
||||
@@ -2068,6 +2441,42 @@
|
||||
"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/events",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events/:slug",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events/series/:slug",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/modules",
|
||||
|
||||
@@ -85,6 +85,14 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/channels"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/retention"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/engagement/retention"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/rules"
|
||||
@@ -129,6 +137,22 @@
|
||||
"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": "DELETE",
|
||||
"path": "/api/v1/admin/engagement/suppressions/by-hash/:hash"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/templates"
|
||||
@@ -161,6 +185,126 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/engagement/triggers"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/events/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/:id"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/publish"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/runs"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/:id/verify"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/:id/versions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/actions"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/actions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/calendar"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/catalog"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/catalog/options/:sourceId"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/price"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs/:runId"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/advance"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/cancel"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/cleanup"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/runs/:runId/log"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/pause"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/resume"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/confirm"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/retry"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/skip"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/events/series"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/events/series"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/events/series/:seriesId"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/events/series/:seriesId"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/invites"
|
||||
@@ -649,6 +793,14 @@
|
||||
"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"
|
||||
@@ -657,6 +809,10 @@
|
||||
"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"
|
||||
@@ -677,6 +833,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"
|
||||
@@ -773,6 +933,10 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/appeals/eligible"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/events/history"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams"
|
||||
@@ -837,6 +1001,26 @@
|
||||
"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/events"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/events/series/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/modules"
|
||||
|
||||
@@ -108,7 +108,21 @@ function main() {
|
||||
return
|
||||
}
|
||||
|
||||
const current = fs.existsSync(MANIFEST_PATH) ? fs.readFileSync(MANIFEST_PATH, 'utf8') : ''
|
||||
// **Line endings are normalised before the comparison**, exactly as
|
||||
// `routeManifest.js` does one file along, and for a reason that is not
|
||||
// cosmetic: this repo is developed on Windows under `core.autocrlf=true`, so
|
||||
// git checks a committed LF blob out as CRLF and a byte comparison then calls
|
||||
// an unchanged manifest stale. That failure is worse than useless — it fires on
|
||||
// every Windows checkout, says "a trigger declaration changed", and is fixed by
|
||||
// regenerating a file whose CONTENT was already correct, which teaches a
|
||||
// developer to ignore the one check that exists to be believed.
|
||||
//
|
||||
// What is being asserted is that the committed manifest describes the same
|
||||
// declarations, and a line ending is not a declaration. Policing the encoding
|
||||
// is `.gitattributes`' job, not this check's.
|
||||
const current = fs.existsSync(MANIFEST_PATH)
|
||||
? fs.readFileSync(MANIFEST_PATH, 'utf8').replace(/\r\n/g, '\n')
|
||||
: ''
|
||||
if (current === next) {
|
||||
process.stdout.write('engagement-triggers.json is current\n')
|
||||
return
|
||||
|
||||
755
server/src/config/coreEventActions.js
Normal file
755
server/src/config/coreEventActions.js
Normal file
@@ -0,0 +1,755 @@
|
||||
// ── Core's own event actions ───────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §F, and Phase 1 of EVENTS_PLAN.md. The twin of config/coreTriggers.js
|
||||
// and registered through the same staging area a module will use in Phase 7 —
|
||||
// which is the entire reason these three exist this early. A registry whose first
|
||||
// real registrant is a module is a registry that has already drifted, and §F's
|
||||
// claim that core is "an event engine that can announce, wait, cue a human and
|
||||
// publish results" with NO module installed is only true if core declares the
|
||||
// verbs that do it.
|
||||
//
|
||||
// **Three actions, and between them they cover the three things an event can do
|
||||
// that name no game noun at all**: tell people something, let time pass, and ask
|
||||
// a human to go and do something. A deployment with no game module installed has
|
||||
// a working event system made of exactly these.
|
||||
//
|
||||
// **Phase 8 added a fourth, and it is the odd one out on purpose.** `core.lease`
|
||||
// names no game noun either — it borrows a value some module declared — but
|
||||
// unlike the other three it genuinely changes the world, so it is `risk: 'change'`
|
||||
// and therefore default-off, admin-only and cap-checked like any module verb.
|
||||
// It is CORE's rather than each module's because §F puts the duration bound and
|
||||
// the two-events-one-target conflict check on core's side of the seam: a lease
|
||||
// verb per module would be that bound re-implemented once per module, advisory
|
||||
// everywhere, and wrong in the first one that forgot it.
|
||||
//
|
||||
// **Phase 10 added the last two, and they are the integrations** (EVENTS.md
|
||||
// §J). `core.announce.post` sends an ARTICLE rather than a line — it links a
|
||||
// post an editor already wrote and queues it through `announce_jobs`, so the
|
||||
// town crier and Discord arrive as already-registered legs with their retry and
|
||||
// their classification rather than as a second delivery pipeline. And
|
||||
// `core.results.publish` is what makes §F's "publish results" literal: it ranks
|
||||
// the run's participants and stamps the table published. Both name a game noun
|
||||
// nowhere, which is why they are core's; six actions is now the whole of what an
|
||||
// event can do on a deployment with no game module installed at all.
|
||||
//
|
||||
// **Phase 2 gave all three real bodies**, and between them they exercise every
|
||||
// shape §F's envelope can take: `core.announce` does work and finishes,
|
||||
// `core.wait` finishes while deferring what follows it, and `core.cue` succeeds
|
||||
// without finishing at all. The runner learns nothing about any of them by id —
|
||||
// each says what it needs in the envelope, through the same two members Phase 7
|
||||
// hands to a module.
|
||||
//
|
||||
// **This file must not touch the database.** It is required from `registerCore()`,
|
||||
// which runs under `routeManifest.js` and `swagger.js` against a dead pool
|
||||
// (MODULE_API.md §2.2). Nothing below runs at require time; the announce leg is
|
||||
// looked up inside `perform()`, per call, which is also what makes a leg
|
||||
// registered by a module that booted later reachable at all. `core.lease` is the
|
||||
// one action here that reaches a table, and it requires the model INSIDE
|
||||
// `perform()` for the same reason — a top-level require would make this file
|
||||
// build a pool during route-manifest generation.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
|
||||
// `event_run_resources.ref` is VARCHAR(190). A targeted lease composes its ref
|
||||
// from the lease id and the target, so this is the one place a caller can push a
|
||||
// ref past the column — and the ledger's own rule applies: refuse, never
|
||||
// truncate, because a truncated ref is a restore pointed at another object.
|
||||
const MAX_LEASE_REF = 190
|
||||
|
||||
|
||||
/**
|
||||
* Turn the `value` param's text into whatever the named lease says it holds.
|
||||
*
|
||||
* The range check is here too, and it is REQUIRED on the numeric types for the
|
||||
* reason §F gives: unlike a cap, a bad lease value is in force the moment it is
|
||||
* applied, so "0.5 to 5" is not advice.
|
||||
*/
|
||||
function coerceLeaseValue(lease, raw) {
|
||||
const text = String(raw === undefined || raw === null ? '' : raw).trim()
|
||||
if (lease.type === 'string') {
|
||||
// A string lease with a declared value set is bounded here, at authoring
|
||||
// time, exactly as a numeric one is by its range (Phase 12b). Without it the
|
||||
// only check on the value is the game side's, and that refusal arrives
|
||||
// unattended, mid-run, from a step nobody is watching.
|
||||
if (lease.values && !lease.values.includes(text)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${lease.label} accepts ${lease.values.join(', ')}, and "${raw}" is none of them`,
|
||||
}
|
||||
}
|
||||
return { ok: true, value: text }
|
||||
}
|
||||
if (lease.type === 'bool') {
|
||||
if (['true', '1', 'yes', 'on'].includes(text.toLowerCase())) return { ok: true, value: true }
|
||||
if (['false', '0', 'no', 'off'].includes(text.toLowerCase())) return { ok: true, value: false }
|
||||
return { ok: false, error: `"${raw}" is not a yes or no value for ${lease.label}` }
|
||||
}
|
||||
const n = Number(text)
|
||||
if (text === '' || !Number.isFinite(n)) {
|
||||
return { ok: false, error: `"${raw}" is not a number, and ${lease.label} holds one` }
|
||||
}
|
||||
if (lease.type === 'int' && !Number.isInteger(n)) {
|
||||
return { ok: false, error: `${lease.label} holds a whole number, and "${raw}" is not one` }
|
||||
}
|
||||
if (n < lease.min || n > lease.max) {
|
||||
return { ok: false, error: `${lease.label} accepts ${lease.min} to ${lease.max}, and "${raw}" is outside that` }
|
||||
}
|
||||
return { ok: true, value: n }
|
||||
}
|
||||
|
||||
const ACTIONS = [
|
||||
{
|
||||
id: 'core.announce',
|
||||
label: 'Announce',
|
||||
description:
|
||||
'Publish a line of text to an announce leg — Discord, the in-game town crier, or any leg a module has registered.',
|
||||
|
||||
// Nothing in the world changes and nothing is created: a message goes out.
|
||||
// That is what makes the default `on_failure` for this step `retry -> skip`
|
||||
// (§L) rather than `pause`, and it is the honest class even though the
|
||||
// message itself cannot be unsent.
|
||||
risk: 'notify',
|
||||
// A sent announcement is gone. `none` rather than `ledger` is not an
|
||||
// omission — there is no undo to write, and declaring `ledger` would put a
|
||||
// row in the cleanup ledger that teardown could never resolve.
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
params: [
|
||||
{
|
||||
// A leg id, checked against the announce-leg registry at dispatch rather
|
||||
// than here: legs are registered by modules, and this file is evaluated
|
||||
// before any module has registered anything.
|
||||
//
|
||||
// **`source` is what moves that check earlier** (Phase 7). The dispatch
|
||||
// check stays — a module can boot between authoring and the run — but
|
||||
// until now a typo here was caught mid-run and nowhere else, which is the
|
||||
// defect Phase 6's walk hit: an announce leg "site" no module registers,
|
||||
// found by a dry run rather than by the form that accepted it.
|
||||
name: 'leg',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'discord',
|
||||
source: 'core.options.legs',
|
||||
description: 'The announce leg to publish on. Registered legs only.',
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: 'The gates of Britain open at dusk',
|
||||
description: 'Optional heading, for legs that render one.',
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'A caravan has been sighted on the road east of Cove.',
|
||||
description: 'The announcement itself. Plain text.',
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Publish through the announce leg the step names.
|
||||
*
|
||||
* **The legs are reused rather than reimplemented** (§J, "reuse the legs"):
|
||||
* `discord` is core's and `towncrier` is module-uo's, both already registered,
|
||||
* both already carrying a `classify()` that knows what their transport's
|
||||
* failures mean. An event announcement that went out by some other path would
|
||||
* be a second delivery mechanism with its own bugs.
|
||||
*
|
||||
* A leg's `dispatch()` takes a POST — that is the shape the news path gave it
|
||||
* — so an event announcement is presented as one. `excerpt` is the body
|
||||
* because it is the field every leg renders as prose, and `image_url` is null
|
||||
* because an event announcement has no article behind it to illustrate.
|
||||
* Widening the leg contract to carry a second payload shape is a
|
||||
* MODULE_API change, and Phase 7 is where those are made.
|
||||
*
|
||||
* The leg id is checked HERE rather than at authoring time, and that is not
|
||||
* laxness: legs are registered by modules, and a spec is validated in a
|
||||
* process that may have booted before the module that owns the leg.
|
||||
*/
|
||||
async perform({ params, verify }) {
|
||||
const registered = registries.announceLeg(params.leg)
|
||||
if (!registered) {
|
||||
// Terminal, not transient. A leg nobody registers will not appear
|
||||
// between two attempts sixty seconds apart, and the honest cause — a
|
||||
// module removed, or a typo the authoring form could not catch — is a
|
||||
// thing a human fixes.
|
||||
return { ok: false, retry: false, error: `no module registers the announce leg "${params.leg}"` }
|
||||
}
|
||||
// A dry run reports what it WOULD do and sends nothing (§I). Answering
|
||||
// before the dispatch rather than inside the leg is what keeps that true
|
||||
// for legs written by people who never read this file.
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const result = await registered.dispatch({
|
||||
title: params.title || null,
|
||||
excerpt: params.body,
|
||||
image_url: null,
|
||||
})
|
||||
// The leg's own classification, not a second opinion. `retry` vs
|
||||
// `terminal` for a Discord webhook is a judgement `discordAnnounce.classify`
|
||||
// already makes, and making it twice is how the two drift.
|
||||
const { outcome, error } = registered.classify(result)
|
||||
if (outcome === 'done') return { ok: true }
|
||||
return { ok: false, retry: outcome === 'retry', error: error || `announce leg "${params.leg}" refused` }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.wait',
|
||||
label: 'Wait',
|
||||
description: 'Let a fixed amount of time pass before the next step of this phase runs.',
|
||||
|
||||
// `inspect` rather than `notify`: nothing is sent and nobody is told. It is
|
||||
// the weakest class the closed set has for an action that is not a broadcast.
|
||||
risk: 'inspect',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
params: [
|
||||
{
|
||||
name: 'seconds',
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 300,
|
||||
description: 'How long to wait. The runner sets the next step due_at from this.',
|
||||
},
|
||||
],
|
||||
|
||||
// A wait is a genuine no-op at dispatch, and it stayed one: the delay is the
|
||||
// NEXT step's `due_at`, which the runner owns, not something this function
|
||||
// sleeps through. A `perform` that slept would hold a step's claim for the
|
||||
// duration and turn a five-minute pause into a five-minute lease — and the
|
||||
// reclaim would then re-dispatch it, so a long enough wait would never end.
|
||||
//
|
||||
// `holdFor` is an ordinary envelope member (org lead, 2026-09-02), which is
|
||||
// why the runner can honour this without knowing what `core.wait` is.
|
||||
async perform({ params, verify }) {
|
||||
if (verify) return { ok: true }
|
||||
return { ok: true, holdFor: params.seconds }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.cue',
|
||||
label: 'Cue a human',
|
||||
description:
|
||||
'Post an instruction for staff and wait for someone to confirm it was done before the run advances.',
|
||||
|
||||
// The action itself only posts an instruction. Whatever the human then does
|
||||
// is outside this system entirely, which is precisely why the cue exists:
|
||||
// it is how an event uses a capability no module has automated.
|
||||
risk: 'notify',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
params: [
|
||||
{
|
||||
name: 'instruction',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'Open the north gate and read the herald script in Britain bank.',
|
||||
description: 'What the staff member is being asked to do.',
|
||||
},
|
||||
{
|
||||
name: 'assignee',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: 'Event Team',
|
||||
description: 'Who the cue is addressed to. A label, not an account.',
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Post the instruction and PARK. The step does not complete here.
|
||||
*
|
||||
* `await: 'human'` is the envelope member that says so (org lead,
|
||||
* 2026-09-02), and the runner's answer to it is to leave the step `running`
|
||||
* with a NULL lease — genuinely in flight, nothing holding it, so the stale
|
||||
* reclaim passes it by and a cue posted on Friday is still waiting on Monday.
|
||||
* The step ends when someone presses confirm, which is Phase 3's control.
|
||||
*
|
||||
* **Nothing is delivered from here in Phase 2, and that is visible rather
|
||||
* than pretended.** The instruction is carried by the step's own params and
|
||||
* shown on the run console; routing it to Discord or to a staff inbox is
|
||||
* Phase 10's integration work, through the engagement triggers that own every
|
||||
* other notification on this platform. An action that grew its own delivery
|
||||
* path would be the second one.
|
||||
*/
|
||||
async perform({ verify }) {
|
||||
if (verify) return { ok: true }
|
||||
return { ok: true, await: 'human' }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.lease',
|
||||
label: 'Borrow a value',
|
||||
description:
|
||||
'Hold a module-declared value at a new setting for a bounded time, and put the old one back at teardown.',
|
||||
|
||||
// The world changes and it changes back, so `change` rather than
|
||||
// `irreversible` — and `change`'s default `on_failure` is `pause`, which is
|
||||
// the right stop for a run that failed halfway through altering the world.
|
||||
risk: 'change',
|
||||
// The one action core ships in this class. `override` is what tells the
|
||||
// cleanup sweep to restore through the LEASE registry rather than through an
|
||||
// action's `revert()`, which is why this action needs no `revert()` of its own
|
||||
// and why the registry refuses one on it.
|
||||
reversible: 'override',
|
||||
version: 1,
|
||||
|
||||
params: [
|
||||
{
|
||||
name: 'lease',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'uo.rate.skillgain',
|
||||
source: 'core.options.leases',
|
||||
description: 'Which declared value to borrow.',
|
||||
},
|
||||
{
|
||||
// **A string, and the coercion is here rather than in the type system.**
|
||||
// A param declares ONE type; a lease declares its own, and they are four
|
||||
// different ones. Typing this `float` would make a boolean lease
|
||||
// unauthorable and a string lease nonsense, so the field takes text and
|
||||
// this action turns it into whatever the named lease said it holds — the
|
||||
// one place that knows both halves.
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: '3.0',
|
||||
description: 'What to hold it at, in whatever type the lease declares.',
|
||||
},
|
||||
{
|
||||
// **Optional here, required by the LEASE** (Phase 12b), and the two are
|
||||
// not the same statement. A param's `required` is a property of the
|
||||
// action, and this action serves both a config key (which has no target)
|
||||
// and an object property (which cannot be named without one) — so the
|
||||
// field is declared optional and `perform` refuses a targeted lease with
|
||||
// nothing in it, in the lease's own words.
|
||||
//
|
||||
// It carries no `source` for the same reason: the values behind it are
|
||||
// the chosen LEASE's, and a param declares one source for all time. The
|
||||
// lease's own `target.source` is what the authoring form reads once the
|
||||
// author has picked a lease, which is the only moment the right list is
|
||||
// knowable.
|
||||
name: 'target',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: '003f11b8-9bfa-4587-991e-ca263004efe6',
|
||||
description: 'Which one, for a value that exists on many things. Leave empty otherwise.',
|
||||
},
|
||||
{
|
||||
name: 'minutes',
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 120,
|
||||
description: 'How long to hold it. Core refuses more than the lease allows.',
|
||||
},
|
||||
],
|
||||
|
||||
// What a lease costs is the LEASE's business to bound, not a budget's:
|
||||
// `maxDurationMs` and the numeric range are declared beside the callables and
|
||||
// enforced below. A cap dimension here would be core inventing an accounting
|
||||
// unit for something a module already bounds — and `registerEventBudgets`
|
||||
// refuses a dimension nobody declared, which is exactly the rule that would
|
||||
// then bite core's own action.
|
||||
|
||||
/**
|
||||
* Read the baseline, reserve the target, apply the value.
|
||||
*
|
||||
* **This is rule 1 in its strongest form.** Unlike a spawn, a lease's target
|
||||
* is knowable before the dispatch — it is the lease id the step names — so
|
||||
* the ledger row is written with its real `kind` and `ref` BEFORE anything
|
||||
* touches the world, and the two-events-one-target refusal comes from the
|
||||
* unique index at that moment rather than from a check that read and then
|
||||
* wrote. A second run asking for a lease another run holds comes back
|
||||
* `refused`, in the same words a cap breach uses and for the same reason:
|
||||
* nothing is broken, the deployment already has that value spoken for.
|
||||
*
|
||||
* The order is read then reserve then apply, and a failure at each stage
|
||||
* undoes the one before it: a reservation whose `apply` refuses is released
|
||||
* here rather than left for the sweep, because there is nothing out there to
|
||||
* give back and a shard that is merely down must not lock a lease out for the
|
||||
* length of a retry cycle.
|
||||
*/
|
||||
async perform({ runId, stepId, params, verify }) {
|
||||
// eslint-disable-next-line global-require
|
||||
const resourcesDb = require('../model/events/eventRunResources.db')
|
||||
const lease = registries.eventLease(params.lease)
|
||||
if (!lease) {
|
||||
return { ok: false, retry: false, error: `no module registers the lease "${params.lease}"` }
|
||||
}
|
||||
|
||||
const coerced = coerceLeaseValue(lease, params.value)
|
||||
if (!coerced.ok) return { ok: false, retry: false, error: coerced.error }
|
||||
|
||||
// **The target is checked before anything else about the world is read**
|
||||
// (Phase 12b), because both of its failures are authoring mistakes rather
|
||||
// than outages: a targeted lease with no target names nothing, and a target
|
||||
// on a lease that has none is an author who has confused two fields. Both
|
||||
// are `retry: false` — the second attempt has the same params.
|
||||
const targetRaw = params.target === undefined || params.target === null ? '' : String(params.target).trim()
|
||||
if (lease.target && !targetRaw) {
|
||||
return { ok: false, retry: false, error: `${lease.label} needs a ${lease.target.label.toLowerCase()}` }
|
||||
}
|
||||
if (!lease.target && targetRaw) {
|
||||
return { ok: false, retry: false, error: `${lease.label} is a single value and takes no target` }
|
||||
}
|
||||
const target = lease.target ? targetRaw : null
|
||||
const ref = registries.leaseRef(lease.id, target)
|
||||
// Refused rather than truncated, on the ledger's own rule for a resource
|
||||
// ref: a truncated ref is a restore pointed at the wrong object.
|
||||
if (ref.length > MAX_LEASE_REF) {
|
||||
return { ok: false, retry: false, error: `that target is too long to record (${ref.length} of ${MAX_LEASE_REF})` }
|
||||
}
|
||||
|
||||
const minutes = Number(params.minutes)
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) {
|
||||
return { ok: false, retry: false, error: `"${params.minutes}" is not a number of minutes` }
|
||||
}
|
||||
const ms = Math.round(minutes * 60_000)
|
||||
if (ms > lease.maxDurationMs) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${lease.label} may be held for at most ${Math.floor(lease.maxDurationMs / 60_000)} minutes, not ${minutes}`,
|
||||
}
|
||||
}
|
||||
|
||||
// **The dry run stops here, and it has still checked everything worth
|
||||
// checking**: the lease exists, the value is in range and the duration is
|
||||
// allowed. What it deliberately does not do is reserve the target — a
|
||||
// verify that took a lease would be a dry run that changed something, and
|
||||
// it would then refuse the real run that followed it.
|
||||
//
|
||||
// It also does not check that the TARGET exists, and that is the same
|
||||
// rule rather than an exception: asking the game side whether a spawner is
|
||||
// there is a live read the shard may be down for, and a dry run that fails
|
||||
// because a shard is restarting would make `verified_at` a property of the
|
||||
// moment rather than of the version (§K).
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const baseline = await lease.read({ target })
|
||||
if (!baseline || baseline.ok !== true) {
|
||||
return {
|
||||
ok: false,
|
||||
error: baseline && baseline.error
|
||||
? String(baseline.error)
|
||||
: `could not read the current value of ${lease.label}`,
|
||||
}
|
||||
}
|
||||
|
||||
const until = new Date(Date.now() + ms)
|
||||
const reserved = await resourcesDb.reserve({
|
||||
runId,
|
||||
stepId,
|
||||
owner: lease.owner || 'core',
|
||||
kind: 'override',
|
||||
// **The ref carries the target, and that is what makes the unique index
|
||||
// right rather than merely present.** Reserved under the lease id alone,
|
||||
// an event turning up one spawner would lock every other run out of every
|
||||
// other spawner — a conflict check that refuses correct work is as wrong
|
||||
// as one that permits a collision, and on a shard with 6,707 spawners it
|
||||
// is the failure an operator would actually meet.
|
||||
ref,
|
||||
payload: {
|
||||
target: ref,
|
||||
leaseTarget: target,
|
||||
baseline: baseline.value,
|
||||
applied: coerced.value,
|
||||
until: until.toISOString(),
|
||||
},
|
||||
leaseUntil: until,
|
||||
})
|
||||
if (!reserved.ok) {
|
||||
const heldBy = reserved.holder ? ` (run ${reserved.holder.run_id})` : ''
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${lease.label} is already leased by another run${heldBy}`,
|
||||
}
|
||||
}
|
||||
|
||||
// **`until` goes down the wire** (§F). The module passes it to its sidecar
|
||||
// and the game side restores baseline when it passes, without being asked
|
||||
// again — the fail-safe that makes an unattended, scheduled world change
|
||||
// defensible, because the worst case is a world back at baseline early
|
||||
// rather than one stuck changed indefinitely.
|
||||
let applied
|
||||
try {
|
||||
applied = await lease.apply(coerced.value, until, { target })
|
||||
} catch (err) {
|
||||
applied = { ok: false, error: err.message }
|
||||
}
|
||||
if (!applied || applied.ok !== true) {
|
||||
await resourcesDb.markReverted(reserved.id)
|
||||
return { ok: false, error: applied && applied.error ? String(applied.error) : `${lease.label} refused the new value` }
|
||||
}
|
||||
|
||||
await resourcesDb.confirm(reserved.id)
|
||||
// **The run now owes the world something, and something has to say so.**
|
||||
// The generic path marks a run dirty when it records a module's reported
|
||||
// resources; this action reserves its own row and never goes through it, so
|
||||
// a run whose only resource was a lease would have kept `cleanup_status =
|
||||
// 'not_required'` and never been swept. Found by the live walk, and the
|
||||
// cleanup leg's own scan was widened to make the class impossible rather
|
||||
// than only this instance.
|
||||
// eslint-disable-next-line global-require
|
||||
await require('../events/ledger').markRunDirty(runId)
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
/**
|
||||
* Which of this run's leases the game side still has a record of (Phase 11b).
|
||||
*
|
||||
* **A lease row had no reconcile path at all until this existed**, and nothing
|
||||
* failed to say so. `cleanup.js` resolves a resource to the action of the step
|
||||
* that made it, and for a lease that action is `core.lease` — a CORE action, on
|
||||
* a path a module cannot register anything on. So every `override` row came
|
||||
* back `unanswered` for the life of the run, and a lease the shard had quietly
|
||||
* dropped (a restart reverts every config lease, by design) stayed in the
|
||||
* ledger as live until teardown went looking for a baseline nobody was holding.
|
||||
*
|
||||
* The question asked is deliberately NOT "is the value still what we applied".
|
||||
* That is drift, and drift is teardown's verdict to deliver through `restore`
|
||||
* so the row lands as `drifted` with the current value beside it. A reconcile
|
||||
* that inferred absence from a changed value would orphan the row first and
|
||||
* throw that away — the operator would be told the lease vanished rather than
|
||||
* that somebody moved it.
|
||||
*
|
||||
* A lease with no `inForce()` is reported in force, which is core's posture
|
||||
* everywhere else in this file: "I could not ask" must never be recorded as
|
||||
* "it is gone".
|
||||
*/
|
||||
async reconcile({ resources }) {
|
||||
const inForce = []
|
||||
|
||||
for (const row of resources || []) {
|
||||
if (row.kind !== 'override') continue
|
||||
|
||||
// Resolved through the ref parser rather than by a bare map lookup: a
|
||||
// targeted row's ref is `<id>#<target>` and `eventLease` would miss it,
|
||||
// which would silently report every property lease still in force.
|
||||
const found = registries.eventLeaseForRef(row.ref)
|
||||
const lease = found && found.lease
|
||||
|
||||
if (!lease || typeof lease.inForce !== 'function') {
|
||||
inForce.push(row.ref)
|
||||
continue
|
||||
}
|
||||
|
||||
let answer
|
||||
try {
|
||||
answer = await lease.inForce({ ref: row.ref, target: found.target, payload: row.payload || null })
|
||||
} catch (err) {
|
||||
answer = null
|
||||
}
|
||||
|
||||
// Only an explicit `held: false` takes a row out. A module that threw, timed
|
||||
// out, or answered something unrecognisable has not said the lease is gone.
|
||||
if (answer && answer.ok === true && answer.held === false) continue
|
||||
|
||||
inForce.push(row.ref)
|
||||
}
|
||||
|
||||
return { ok: true, inForce }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.announce.post',
|
||||
label: 'Announce a post',
|
||||
description:
|
||||
'Send an existing news post out on every registered announce leg — Discord, the in-game town crier — as this run\'s announcement.',
|
||||
|
||||
// Nothing in the world changes and nothing is created; a message goes out.
|
||||
// Same class as `core.announce` and for the same reason.
|
||||
risk: 'notify',
|
||||
// The job is queued, the legs deliver, and none of it can be unsent. A
|
||||
// `ledger` here would put a row in the cleanup ledger that teardown could
|
||||
// never resolve.
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
// **`core.announce` sends a line; this sends an ARTICLE**, and that is the
|
||||
// whole difference between them (EVENTS.md §J, "News"). Events does not
|
||||
// write posts — `ctx.posts` is read-only to modules and the CMS is core's —
|
||||
// so an event that wants prose, an image and a permanent page links a post
|
||||
// an editor already wrote. What this action adds over `core.announce` is
|
||||
// therefore not a second transport but a second SHAPE: every leg's
|
||||
// `dispatch()` takes a post, and this is the one that hands it a real one.
|
||||
params: [
|
||||
{
|
||||
name: 'postId',
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 412,
|
||||
source: 'core.options.posts',
|
||||
description: 'The published post to announce. Any category.',
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Queue the post on every registered leg, as this run's announcement.
|
||||
*
|
||||
* **The refusals are all `retry: false`**, and each is a thing a human has to
|
||||
* fix: a post id that names nothing, or a draft. Neither will have changed
|
||||
* sixty seconds later, and retrying would spend two more attempts before
|
||||
* saying the same thing.
|
||||
*
|
||||
* **What it does NOT wait for is delivery.** `enqueueForRun` writes the job
|
||||
* and the legs and returns; `announceWorker` drains them on its own tick with
|
||||
* its own backoff. So this step is `done` when the announcement is queued,
|
||||
* not when Discord has it — which is honest, because a leg that fails after
|
||||
* six attempts over two hours is not something a step could usefully have
|
||||
* stayed open for, and the post admin panel is where that failure is already
|
||||
* surfaced.
|
||||
*/
|
||||
async perform({ runId, params, verify }) {
|
||||
/* eslint-disable global-require */
|
||||
const posts = require('../model/posts/posts.model')
|
||||
const announceJobs = require('../model/announceJobs/announceJobs.model')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const postId = Number(params.postId)
|
||||
if (!Number.isInteger(postId) || postId < 1) {
|
||||
return { ok: false, retry: false, error: `"${params.postId}" is not a post id` }
|
||||
}
|
||||
|
||||
const post = await posts.getById(postId)
|
||||
if (!post) return { ok: false, retry: false, error: `no post with id ${postId}` }
|
||||
if (!post.published) {
|
||||
// A draft has no public page for a town-crier line to point at, and
|
||||
// announcing one would publish its title to a shard before an editor
|
||||
// meant to. Refused rather than published on the author's behalf:
|
||||
// publishing is the CMS's decision and this action is not it.
|
||||
return { ok: false, retry: false, error: `"${post.title}" is not published` }
|
||||
}
|
||||
|
||||
// The dry run has now checked everything worth checking — the post exists
|
||||
// and is published — and queues nothing. Checked BEFORE the legs are read,
|
||||
// because a deployment with no leg registered is a real state and a verify
|
||||
// that reported it as a failure would refuse a plan that is fine.
|
||||
if (verify) return { ok: true }
|
||||
|
||||
await announceJobs.enqueueForRun(postId, runId)
|
||||
return { ok: true }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.results.publish',
|
||||
label: 'Publish the results',
|
||||
description:
|
||||
'Rank this run\'s participants by score and publish the results table.',
|
||||
|
||||
// Nothing in the game world changes and nobody is messaged: a table core
|
||||
// already holds becomes readable. `inspect` is the weakest class the closed
|
||||
// set has and it is the honest one — which also means this action is
|
||||
// default-ON like `core.wait`, and an author can place it without an admin
|
||||
// first visiting the switchboard.
|
||||
risk: 'inspect',
|
||||
// **`none`, and it is worth saying why a publication is not reversible.**
|
||||
// Nothing is created that core would have to come back for; un-publishing is
|
||||
// an admin decision about a table, not a teardown obligation, and a `ledger`
|
||||
// row here would make every completed event carry an outstanding resource
|
||||
// for ever.
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
// No params. What is published is this run's participants, which is the only
|
||||
// set there is — a param naming which run would be a way to publish someone
|
||||
// else's results from inside your own event.
|
||||
params: [],
|
||||
|
||||
/**
|
||||
* Rank, stamp, and say how many.
|
||||
*
|
||||
* **Idempotent by construction**, which is what makes it safe as an ordinary
|
||||
* retried step: ranking is a total order over `(score, joined_at, id)`, so
|
||||
* running it twice over an unchanged table writes the same numbers, and the
|
||||
* stamp simply moves. A late participant added by a second collect step and
|
||||
* a re-publish afterwards renumbers deliberately — that is the operator
|
||||
* asking for exactly that.
|
||||
*
|
||||
* **A run with no participants publishes an empty table rather than
|
||||
* failing.** "Nobody was recorded" is a true and renderable result, and it is
|
||||
* the state of every run until a module can source attendance at all (Phase
|
||||
* 12). Failing here would make an event whose module reports nothing look
|
||||
* broken on the console for a reason that has nothing to do with the event.
|
||||
*/
|
||||
async perform({ runId, verify }) {
|
||||
/* eslint-disable global-require */
|
||||
const participantsDb = require('../model/events/eventRunParticipants.db')
|
||||
const runsDb = require('../model/events/eventRuns.db')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
await participantsDb.rankRun(runId)
|
||||
await runsDb.markResultsPublished(runId)
|
||||
return { ok: true }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// ── Core's own param option sources (§F, Phase 7) ──────────────────
|
||||
//
|
||||
// One, and it is core's half of the seam it hands a module on the same boot: a
|
||||
// param's `source` names a registered option source, core asks it for values, and
|
||||
// the authoring form renders a dropdown instead of a text box.
|
||||
//
|
||||
// **The legs are already a registry with labels in it**, so this costs nothing
|
||||
// new — which is what makes it the right first exercise. `resolve()` is called
|
||||
// per request rather than read once, for the same reason `core.announce` looks a
|
||||
// leg up inside `perform()`: a leg registered by a module that booted after this
|
||||
// file was evaluated must still appear, and a module uninstalled since must stop
|
||||
// appearing.
|
||||
const OPTION_SOURCES = [
|
||||
{
|
||||
id: 'core.options.legs',
|
||||
label: 'Announce legs',
|
||||
description: 'Every delivery leg registered on this deployment right now.',
|
||||
async resolve() {
|
||||
return registries.announceLegs().map((l) => ({ value: l.leg, label: l.label || l.leg }))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'core.options.leases',
|
||||
label: 'Borrowable values',
|
||||
description: 'Every value a module has declared this deployment may lease.',
|
||||
async resolve() {
|
||||
return registries
|
||||
.allEventLeases()
|
||||
.map((l) => ({ value: l.id, label: l.label, group: l.id.split('.')[0] }))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'core.options.posts',
|
||||
label: 'Published posts',
|
||||
description: 'Every published post an event may announce, newest first.',
|
||||
/**
|
||||
* **The one option source in core that reaches a table**, and the reason it
|
||||
* is allowed to is the rule §F draws about WHEN: a source resolves on its own
|
||||
* request (`GET /admin/events/catalog/options/:sourceId`), which is a live
|
||||
* request on a booted server, not at `register()` time under a dead pool.
|
||||
*
|
||||
* Grouped by category so the dropdown separates news from the newsletter
|
||||
* rather than presenting one long list in which the two are indistinguishable
|
||||
* — a `group` is what the form renders as an optgroup, and it costs a column
|
||||
* that is already selected.
|
||||
*/
|
||||
async resolve() {
|
||||
// eslint-disable-next-line global-require
|
||||
const postsDb = require('../model/posts/posts.db')
|
||||
const rows = await postsDb.listPublishedForOptions(200)
|
||||
return rows.map((p) => ({ value: p.id, label: p.title, group: p.category }))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { ACTIONS, OPTION_SOURCES }
|
||||
@@ -75,6 +75,38 @@ const STREAMS = [
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
|
||||
// ── The event system (EVENTS.md §J — Phase 10) ──────────────────────────
|
||||
//
|
||||
// **One of the seven `event.` triggers is also a stream, and that is a
|
||||
// decision rather than an oversight** (org lead, 2026-09-04). A stream is a
|
||||
// PUSH toggle: `notificationChannelPrefs.catalog` offers the push channel only
|
||||
// for ids registered here, `publishToUsers` joins `notification_subscriptions`,
|
||||
// and that table is only ever written for a channel a user could switch on. So
|
||||
// a trigger that is not also a stream can be mailed and put in the inbox, and
|
||||
// its push is dead — a tickle published to nobody, which the send log
|
||||
// nonetheless records as sent. Found on the live rig; the seeded rule named
|
||||
// `push` before this line existed.
|
||||
//
|
||||
// **`run.started` alone, because push is the channel that says "now".** It is
|
||||
// the one lifecycle moment worth waking a phone for — ENGAGEMENT.md §8.5's
|
||||
// *"come back for X"* — and the other six are things a player reads when they
|
||||
// next look. Six more toggles would put a wall of switches on the preferences
|
||||
// screen for one feature, and `event.phase.changed` is the one most likely to
|
||||
// buzz a phone four times in an evening.
|
||||
//
|
||||
// Same id as the trigger, which is §7.2's one namespace and the same-owner
|
||||
// upgrade `news.post` already is: one id, one owner, two facets.
|
||||
{
|
||||
id: 'event.run.started',
|
||||
label: 'Events — starting now',
|
||||
description: 'A scheduled event is beginning.',
|
||||
// Not owner-keyed: this is a public event happening in public, not a fact
|
||||
// about one account's own property. Same as `news.post`.
|
||||
personal: false,
|
||||
// A player with no linked game account can still want to know an event is on.
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { STREAMS }
|
||||
|
||||
@@ -29,6 +29,29 @@
|
||||
// test-send without a live game event, which is the reason template systems go
|
||||
// untested.
|
||||
|
||||
/**
|
||||
* The three facts `events/announce.js` puts on EVERY `event.*` payload, declared
|
||||
* once because they are spread into all seven.
|
||||
*
|
||||
* `baseFor()` has always computed them and nothing declared them, so
|
||||
* `engagementEmit.validatePayload` dropped all three before a template could see
|
||||
* one — they were absent from the variable list an author picks from, and every
|
||||
* single event emit logged `emit carried undeclared variables`. Found by the
|
||||
* Phase 16 acceptance walk, in the DEBUG line it had been writing all along.
|
||||
*
|
||||
* All three are optional, and each for its own reason rather than by default: an
|
||||
* event need not carry a summary, most events belong to no series, and a run
|
||||
* whose definition has been deleted resolves no zone.
|
||||
*/
|
||||
const EVENT_AMBIENT = [
|
||||
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
|
||||
description: 'The event’s one-line summary, when it has one.' },
|
||||
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
|
||||
description: 'The arc this event belongs to, when it belongs to one.' },
|
||||
{ name: 'timezone', type: 'string', required: false, example: 'America/New_York',
|
||||
description: 'The zone the run was computed in — what a time in the body should be read as.' },
|
||||
]
|
||||
|
||||
const TRIGGERS = [
|
||||
{
|
||||
id: 'news.post',
|
||||
@@ -49,8 +72,15 @@ const TRIGGERS = [
|
||||
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: '/news/five-on-friday-yew-invasion',
|
||||
description: 'Site-relative path to the post.' },
|
||||
// **`/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.' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -142,6 +172,269 @@ const TRIGGERS = [
|
||||
description: 'Site-relative path to the announcement.' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── The event system (EVENTS.md §J — Phase 10) ──────────────────────────
|
||||
//
|
||||
// **Seven triggers, one per moment a run passes through that somebody outside
|
||||
// the run console might want to hear about — and Events owns none of the
|
||||
// delivery.** A run emits; an operator's rule decides who is told, on what,
|
||||
// and how often. That is the whole of §J's "clean fit" row, and it is why
|
||||
// there is no announcement machinery anywhere in `utils/eventRunner.js`
|
||||
// beyond a call to `emit`.
|
||||
//
|
||||
// **Six are ceilinged `authenticated` and one at `admin`** (§J, and the org
|
||||
// lead 2026-09-04). `run.failed` is an operational fact — a step ran out of
|
||||
// attempts, the world may be half-changed — and a rule that mailed it to
|
||||
// every subscriber would publish the deployment's incidents. The other six
|
||||
// describe a public event happening in public, so they sit exactly where
|
||||
// `news.post` sits: ceiling `authenticated`, default audience `subscribers`,
|
||||
// which is "people who asked to be told" rather than the whole user table.
|
||||
//
|
||||
// **Every `description` here is read by two audiences**, and the second one is
|
||||
// easy to forget: the rule editor's catalog, and — through
|
||||
// `projection.project`'s `intro` fallback — every recipient of an unauthored
|
||||
// render through `notify.event` or `inapp.event`. So each is prose a player
|
||||
// can read rather than a note to the operator. The live rig caught the
|
||||
// original `run.failed` line, which ended "Staff-facing." and put those words
|
||||
// in an administrator's own inbox item. Who a trigger is for is said by its
|
||||
// CEILING, which is the only place that can enforce it anyway.
|
||||
//
|
||||
// **`subjectKey: 'runId'` on every one of them**, and it is the one place
|
||||
// these differ from `news.post`. A cooldown keyed on the user would make
|
||||
// `phase.changed` mean "at most one phase of at most one event an hour",
|
||||
// silently swallowing the second wave of an invasion because the first wave's
|
||||
// mail went out forty minutes ago. Keyed on the run it means "at most one
|
||||
// line an hour ABOUT THIS RUN", which is the useful sentence — and across
|
||||
// runs of the same definition the ids differ, so a weekly event is not
|
||||
// throttled by last week's.
|
||||
//
|
||||
// **All six public ones now declare `eventUrl`, and Phase 14a is what made
|
||||
// that legal.** Until it there was no public event page at all — `App.jsx`
|
||||
// mounted nothing under `/site/events` — and `news.post` had already paid for
|
||||
// that mistake once: its `postUrl` example named `/news/<slug>`, a path that
|
||||
// did not exist, so the template editor previewed a link that was dead in
|
||||
// every mail it sent. The variable arrived with the page it points at, which
|
||||
// is what makes this a version bump (1 -> 2) rather than a correction.
|
||||
//
|
||||
// **It carries `?run=`, and the query string is the whole reason it is a run
|
||||
// url and not an event url.** The page lives at the DEFINITION's slug, so a
|
||||
// weekly event has one stable address — but every one of these triggers is
|
||||
// about one OCCURRENCE, and a mail about last Friday's invasion whose link
|
||||
// opened next Friday's would answer a different question from the one the
|
||||
// reader clicked. `run.failed` keeps its own `runUrl` into the admin console
|
||||
// and gains nothing here: an admin reading about broken machinery wants the
|
||||
// console, not the storyline.
|
||||
{
|
||||
id: 'event.run.scheduled',
|
||||
label: 'Event — scheduled',
|
||||
description: 'A new event has been added to the calendar.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
|
||||
description: 'When the occurrence is due to start, UTC.' },
|
||||
// **A presentational fragment, and §4.6.1 convention 1 is what sanctions
|
||||
// one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
|
||||
// string — correct as data and unreadable in a mail, and a template has no
|
||||
// logic with which to format it. So the formatting happens at the emitter,
|
||||
// in the shard-local zone, and arrives as a variable whose `example` shows
|
||||
// exactly what it produces. Same trade `forWhom` makes in the auth bodies.
|
||||
{ name: 'startsAtLabel', type: 'string', required: false,
|
||||
example: 'Saturday 12 September at 8:00 pm (America/New_York)',
|
||||
description: 'The start time written out in the shard-local zone, for a mail to read.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.started',
|
||||
label: 'Event — starting now',
|
||||
description: 'A scheduled event has begun.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
|
||||
description: 'When it actually started, UTC.' },
|
||||
// **A presentational fragment, and §4.6.1 convention 1 is what sanctions
|
||||
// one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
|
||||
// string — correct as data and unreadable in a mail, and a template has no
|
||||
// logic with which to format it. So the formatting happens at the emitter,
|
||||
// in the shard-local zone, and arrives as a variable whose `example` shows
|
||||
// exactly what it produces. Same trade `forWhom` makes in the auth bodies.
|
||||
{ name: 'startsAtLabel', type: 'string', required: false,
|
||||
example: 'Saturday 12 September at 8:00 pm (America/New_York)',
|
||||
description: 'The start time written out in the shard-local zone, for a mail to read.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.phase.changed',
|
||||
label: 'Event — a new phase',
|
||||
description: 'An event that is under way has moved on to its next stage.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
{ name: 'phase', type: 'string', required: true, example: 'assault',
|
||||
description: 'The phase key just entered, as authored in the spec.' },
|
||||
{ name: 'phaseLabel', type: 'string', required: false, example: 'The assault',
|
||||
description: 'The phase label, when the spec gave it one. Falls back to the key.' },
|
||||
{ name: 'phaseIndex', type: 'int', required: true, example: 2,
|
||||
description: 'Which phase this is, counting from 1.' },
|
||||
{ name: 'phaseCount', type: 'int', required: true, example: 4,
|
||||
description: 'How many phases the pinned version has in total.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.ending',
|
||||
label: 'Event — winding down',
|
||||
description: 'An event is drawing to a close.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.completed',
|
||||
label: 'Event — finished',
|
||||
description: 'An event has finished.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
// Counted from `event_run_participants` at emit. Zero on a run whose
|
||||
// module reported nobody, which is every run until a module collects —
|
||||
// a template that says "47 took part" needs a number that is never
|
||||
// missing, and "0" is the honest one.
|
||||
{ name: 'participantCount', type: 'int', required: true, example: 47,
|
||||
description: 'How many participants the run recorded. Zero when nothing collected any.' },
|
||||
{ name: 'durationMinutes', type: 'int', required: true, example: 95,
|
||||
description: 'How long the run took, start to end, in whole minutes.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.cancelled',
|
||||
label: 'Event — cancelled',
|
||||
description: 'A scheduled event was cancelled by a member of staff.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
// **The operator's reason, and not the run's `last_error`.** `cancel`
|
||||
// takes a `{ reason }` a human typed for other humans; a diagnostic
|
||||
// string is for the run console and would read as gibberish in a mail.
|
||||
{ name: 'reason', type: 'string', required: false, example: 'The shard is down for an emergency patch.',
|
||||
description: 'What the staff member gave as the reason, when they gave one.' },
|
||||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||||
// base, and an absolute one baked in here would be wrong on every
|
||||
// deployment but the first.
|
||||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||||
description: 'The public page for this occurrence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.failed',
|
||||
label: 'Event — run failed',
|
||||
description: 'An event stopped before it finished.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
// **`admin`, and both halves of that.** The ceiling is the security
|
||||
// boundary (§J, G24): no rule may ever widen this past admins, because a
|
||||
// failure names the deployment's own broken machinery. The default audience
|
||||
// matches, so a rule created from this trigger starts where it must end.
|
||||
audience: 'admin',
|
||||
ceiling: 'admin',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
...EVENT_AMBIENT,
|
||||
{ name: 'phase', type: 'string', required: false, example: 'assault',
|
||||
description: 'The phase it failed in, when it had entered one.' },
|
||||
{ name: 'error', type: 'string', required: false, example: 'sidecar responded 503',
|
||||
description: 'The run’s last error, verbatim from the run row.' },
|
||||
// The admin console, not the public page — and this trigger gains no
|
||||
// `eventUrl` at all. An admin reading that the machinery broke wants the
|
||||
// steps and the errors, not the storyline. See the note above the six.
|
||||
{ name: 'runUrl', type: 'url', required: true, example: '/admin/events/runs/3692',
|
||||
description: 'Site-relative path to the run console.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { TRIGGERS }
|
||||
|
||||
@@ -11,6 +11,25 @@
|
||||
//
|
||||
// `api` is the coarse contract version (bumped only on a breaking re-shape, which
|
||||
// would be a v2 mount); `server` is the informational package version.
|
||||
//
|
||||
// ── `capabilities` (Phase 14a) ──
|
||||
//
|
||||
// Opaque strings naming what CORE serves beyond the surface every backend has —
|
||||
// the same idea as a module's `capabilities` on GET /public/modules, and
|
||||
// deliberately the same word, so a client feature-detects one way rather than
|
||||
// two. They are a different LIST because core is not a module: publishing core
|
||||
// as a pseudo-module would leave a client unable to tell "this backend has
|
||||
// events" from "a module called core happens to be installed", which is exactly
|
||||
// the distinction the loader exists to make.
|
||||
//
|
||||
// The value is in what is ABSENT. A backend released before Events answers this
|
||||
// object with no `capabilities` key at all, so a client can tell an older site
|
||||
// from one that simply has nothing on its calendar — which it could not do by
|
||||
// probing /public/events, where "not built" and "temporarily down" look alike.
|
||||
//
|
||||
// Static, because these are compiled-in features rather than installed ones:
|
||||
// a core that has these routes always has them. An unknown string is to be
|
||||
// treated as absent, exactly as MODULE_API.md §2.1 says of a module's.
|
||||
|
||||
const pkg = require('../../package.json')
|
||||
|
||||
@@ -18,4 +37,6 @@ module.exports = {
|
||||
service: 'runic-gateway', // stable backend identifier for first-run detection
|
||||
api: 'v1', // API contract version (matches the /api/v1 mount)
|
||||
server: pkg.version || '0.0.0', // server package version (informational)
|
||||
// What core serves beyond the baseline. See the note above.
|
||||
capabilities: ['events'],
|
||||
}
|
||||
|
||||
@@ -52,7 +52,15 @@ registerEmailBlock({
|
||||
// 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 ''
|
||||
const href = ctx.safeHref(props.url)
|
||||
// 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 (
|
||||
@@ -78,8 +86,12 @@ registerEmailBlock({
|
||||
)
|
||||
},
|
||||
toText(props, ctx) {
|
||||
const url = ctx.t(props.url).trim()
|
||||
if (url === '') return '' // see toHtml: no url, no block, in either part
|
||||
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
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ── 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`,
|
||||
// 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.
|
||||
@@ -16,11 +16,11 @@
|
||||
// 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` as a PLAIN audience resolves to nobody. It is the ceiling for
|
||||
// "a module-declared list", and without a segment there is no list - core
|
||||
// 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
|
||||
// saved that way is inert and visible as such, rather than quietly falling
|
||||
// back to something wider.
|
||||
// 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')
|
||||
@@ -88,9 +88,14 @@ async function resolveForRule(rule, event) {
|
||||
}
|
||||
}
|
||||
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.STAFF_CEILING_ROLES),
|
||||
ceiling: 'staff',
|
||||
userIds: await recipients.staff(ceilings.ROLE_CEILINGS[rule.audience].roles),
|
||||
ceiling: rule.audience,
|
||||
dormant: false,
|
||||
reason: null,
|
||||
}
|
||||
@@ -104,13 +109,36 @@ async function resolveForRule(rule, event) {
|
||||
case 'authenticated':
|
||||
case 'everyone':
|
||||
return { userIds: await recipients.active(), ceiling: rule.audience, dormant: false, reason: null }
|
||||
case 'members':
|
||||
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',
|
||||
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.
|
||||
@@ -128,11 +156,27 @@ async function resolveForRule(rule, event) {
|
||||
* 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.
|
||||
*
|
||||
* **`emitted` is the second thing this gate now weighs** (Phase 10). A firing may
|
||||
* carry a ceiling of its own — a rehearsal's `staff` (EVENTS.md §I) — and the
|
||||
* effective bound is the MEET of the two, so a firing can only ever narrow what
|
||||
* the declaration allows. Two incomparable ceilings meet to null and the gate
|
||||
* refuses: `owner` and `staff` have no common descendant, and picking one would
|
||||
* be the guess §5.1a rule 3 exists to refuse. That is also why an unknown value
|
||||
* cannot get here — `emit` validates it against the same lattice — but the null
|
||||
* is handled anyway, because this is the boundary and a boundary that trusts its
|
||||
* caller is not one.
|
||||
*
|
||||
* @param {string} triggerId
|
||||
* @param {string} ceiling the audience the rule resolved to
|
||||
* @param {string|null} [emitted] a narrowing ceiling this firing carries
|
||||
*/
|
||||
function permitted(triggerId, ceiling) {
|
||||
function permitted(triggerId, ceiling, emitted = null) {
|
||||
const declaration = registries.eventTrigger(triggerId)
|
||||
if (!declaration) return false
|
||||
return ceilings.permits(declaration.ceiling, ceiling)
|
||||
const bound = emitted ? ceilings.meet(declaration.ceiling, emitted) : declaration.ceiling
|
||||
if (!bound) return false
|
||||
return ceilings.permits(bound, 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,
|
||||
}
|
||||
@@ -44,6 +44,33 @@ const isMode = (value) => MODES.includes(value)
|
||||
* @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')
|
||||
@@ -67,6 +94,16 @@ function registerDeliveryChannel(def) {
|
||||
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,
|
||||
@@ -75,12 +112,24 @@ function registerDeliveryChannel(def) {
|
||||
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. */
|
||||
const all = () => [...channels.values()].map((c) => ({ ...c }))
|
||||
/**
|
||||
* 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()]
|
||||
@@ -108,6 +157,25 @@ const modesFor = (id) => {
|
||||
/** 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()
|
||||
@@ -124,5 +192,6 @@ module.exports = {
|
||||
defaultMode,
|
||||
modesFor,
|
||||
acceptsMode,
|
||||
eligibleFor,
|
||||
_reset,
|
||||
}
|
||||
|
||||
@@ -248,4 +248,18 @@ const vocabulary = () =>
|
||||
/** 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 }
|
||||
// `checkLiteral` is exported for the event system's step-param validator
|
||||
// (EVENTS.md §F, Phase 1), which checks an authored param value against an
|
||||
// action's declared param type — the same six types over the same coercion. A
|
||||
// second copy of this switch would be a second answer to "is this a datetime",
|
||||
// and the two would drift on the first zone-suffixed string somebody typed.
|
||||
module.exports = {
|
||||
validate,
|
||||
validateFor,
|
||||
evaluate,
|
||||
vocabulary,
|
||||
checkLiteral,
|
||||
OPERATORS,
|
||||
MAX_LIST,
|
||||
MAX_DEPTH,
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
// 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 = [
|
||||
{
|
||||
@@ -43,6 +46,10 @@ const CHANNELS = [
|
||||
// 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',
|
||||
@@ -53,19 +60,41 @@ const CHANNELS = [
|
||||
// `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-IN like the other two, and for a reason particular to this channel: the
|
||||
// inbox does not exist until Phase 7. A default of 'instant' would mean every
|
||||
// user is opted into a surface that has no rows and no screen, and the first
|
||||
// thing Phase 7 shipped would be a backlog. Whether the inbox is opt-out once
|
||||
// it is real is a Phase 7 decision with a live surface to look at.
|
||||
defaultMode: 'off',
|
||||
// **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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
319
server/src/engagement/coreRules.js
Normal file
319
server/src/engagement/coreRules.js
Normal file
@@ -0,0 +1,319 @@
|
||||
// ── The seven 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.
|
||||
//
|
||||
// **EVENTS.md Phase 10 added two more, and a third key**, for the event
|
||||
// lifecycle: `event.run.started` and `event.run.failed`. Same argument, third
|
||||
// application — a deployment that has already stamped the news key must still
|
||||
// see these.
|
||||
|
||||
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'
|
||||
|
||||
// EVENTS.md Phase 10's, third group, third key.
|
||||
const EVENT_SEEDED_KEY = 'engagement_event_rules_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,
|
||||
},
|
||||
]
|
||||
|
||||
// Phase 10's two, in their own list under their own one-shot key — the rule
|
||||
// Phase 11 established, applied for the second time. Appending to `NEWS_RULES`
|
||||
// would seed these on fresh installs only and on exactly the upgrades that want
|
||||
// them, never.
|
||||
//
|
||||
// **Two rules for seven triggers, and that is the whole decision** (org lead,
|
||||
// 2026-09-04). Every one of the seven is declared, so an operator can write a
|
||||
// rule against any of them from the rules screen; what is SEEDED is the pair
|
||||
// somebody would otherwise have to build from scratch on the first day — the
|
||||
// player-facing "it is starting" and the staff-facing "it broke". Seeding all
|
||||
// seven would grow Admin → Engagement → Rules by seven disabled rows nobody
|
||||
// asked for, and `event.phase.changed` is the one most likely to be switched on
|
||||
// by accident and then mail a player four times in one evening.
|
||||
const EVENT_RULES = [
|
||||
{
|
||||
trigger_id: 'event.run.started',
|
||||
name: 'Events — starting now',
|
||||
// `subscribers`, the trigger's own default: people who opted into this id on
|
||||
// at least one channel. Not `authenticated`, even though the ceiling permits
|
||||
// it — an event is worth telling people who asked to be told about events,
|
||||
// and mailing the whole user table every Saturday night is how a feature
|
||||
// earns a spam complaint. An operator who wants the whole site can widen it;
|
||||
// the ceiling is what stops them widening it past that.
|
||||
audience: 'subscribers',
|
||||
// All three, like the news rule and for the same reason: push is the channel
|
||||
// that gets somebody to log in *now*, which is the entire point of a
|
||||
// "come back for this" notice (ENGAGEMENT.md §8.5), and the in-app inbox is
|
||||
// the surface a content-free tickle deep-links into.
|
||||
channels: ['email', 'inapp', 'push'],
|
||||
// The one bespoke body this phase seeds; see `templateSeeds.js` for why it
|
||||
// is one and not seven. `inapp.event` is the in-app renderer's generic, and
|
||||
// push carries no content by construction and needs no template.
|
||||
template_keys: { email: 'notify.event-started', inapp: 'inapp.event', digest: 'notify.digest' },
|
||||
// An hour, per user PER RUN — `event.run.started` declares `subjectKey:
|
||||
// 'runId'`, so the cooldown subject is the run and not the recipient. It is
|
||||
// near-redundant on a trigger that fires once per run, which is the point:
|
||||
// it costs nothing and it is the guard if a run is ever restarted.
|
||||
cooldown_seconds: 3600,
|
||||
max_sends_per_hour: 1000,
|
||||
},
|
||||
{
|
||||
trigger_id: 'event.run.failed',
|
||||
name: 'Events — a run failed',
|
||||
// `admin`, which is both the trigger's default and its ceiling. A failed run
|
||||
// names the deployment's own broken machinery — a sidecar that did not
|
||||
// answer, a step that ran out of attempts — and there is no widening of this
|
||||
// that is not a disclosure.
|
||||
audience: 'admin',
|
||||
// No push. An admin's phone buzzing at four in the morning for a step that
|
||||
// will still be failed at breakfast is a notification people switch off
|
||||
// wholesale, and switching it off wholesale is how the one that mattered is
|
||||
// missed. Mail and the inbox both wait.
|
||||
channels: ['email', 'inapp'],
|
||||
// The generic body plus the structural projection: `event.run.failed`
|
||||
// declares its own `title` and a `runUrl`, so an unauthored mail is already
|
||||
// headed with the event's name and buttoned through to the run console —
|
||||
// §4.6.1 property 1, working exactly as it promises.
|
||||
template_keys: { email: 'notify.event', inapp: 'inapp.event' },
|
||||
// **No cooldown, and this is the one rule in the file that must not have
|
||||
// one.** The subject is the run, so a cooldown would only ever suppress a
|
||||
// second failure of the SAME run — which is precisely the run an
|
||||
// administrator most needs the second line about.
|
||||
cooldown_seconds: 0,
|
||||
max_sends_per_hour: 200,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// **Claimed BEFORE the loop, atomically**, and the stamp is the claim. A
|
||||
// `get()` here with a `set()` after the inserts is not a guard when two
|
||||
// instances boot together — both read "absent", both seed — and a duplicate
|
||||
// rule is two mails per event. `claim()` is an `INSERT IGNORE` reporting its
|
||||
// own `affectedRows`, so exactly one caller proceeds. See the note below on
|
||||
// what a partial run costs: that trade is unchanged, only its ordering.
|
||||
if (!(await settingsDb.claim(key, new Date().toISOString()))) {
|
||||
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('rule seed failed', { key, trigger: rule.trigger_id, message: err.message })
|
||||
}
|
||||
}
|
||||
// Stamped even on a partial run — the claim above is the stamp. 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.
|
||||
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')
|
||||
|
||||
/** The two event-lifecycle rules (EVENTS.md Phase 10). */
|
||||
const seedEventRules = () =>
|
||||
seedGroup(EVENT_SEEDED_KEY, EVENT_RULES, 'Event notifications stay off until an operator enables one')
|
||||
|
||||
/**
|
||||
* 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()
|
||||
const events = await seedEventRules()
|
||||
return {
|
||||
inserted: team.inserted + news.inserted + events.inserted,
|
||||
skipped: team.skipped + news.skipped + events.skipped,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
seedCoreRules,
|
||||
seedTeamRules,
|
||||
seedNewsRule,
|
||||
seedEventRules,
|
||||
RULES,
|
||||
NEWS_RULES,
|
||||
EVENT_RULES,
|
||||
SEEDED_KEY,
|
||||
NEWS_SEEDED_KEY,
|
||||
EVENT_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 }
|
||||
@@ -37,10 +37,27 @@ 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?
|
||||
*
|
||||
@@ -49,26 +66,53 @@ const HOUR_MS = 60 * 60 * 1000
|
||||
* 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))
|
||||
const liveChannels = (rule) =>
|
||||
(rule.channels || [])
|
||||
.filter((c) => channels.has(c))
|
||||
.sort((a, b) => channelRank(a) - channelRank(b))
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to the users whose EFFECTIVE mode for (id, channel) is
|
||||
* not 'off'.
|
||||
* 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 'digest' preference is kept, not dropped. Digest delivery is Phase 6's, and
|
||||
* an outbox row for it is still the right record of "this person should be told";
|
||||
* what changes in Phase 6 is who drains it.
|
||||
* **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 subscribedTo(userIds, streamId, channel) {
|
||||
if (!userIds.length) return []
|
||||
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)
|
||||
return userIds.filter((id) => (stored.get(id) ?? fallback) !== 'off')
|
||||
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')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +120,18 @@ async function subscribedTo(userIds, streamId, channel) {
|
||||
* 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, skipped: null }
|
||||
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'
|
||||
@@ -95,13 +150,21 @@ async function applyRule(rule, event, now) {
|
||||
|
||||
// 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
|
||||
// declaration - and that was the only way this could fail, since the save path
|
||||
// ran the same check.
|
||||
if (!audiences.permitted(event.triggerId, resolved.ceiling)) {
|
||||
//
|
||||
// **Phase 10 gave it a second way, and it is the one that fires in practice:**
|
||||
// the event may carry a narrowing ceiling of its own. A rehearsal emits
|
||||
// `event.run.started` with `ceiling: 'staff'`, and every rule an operator wrote
|
||||
// for the real thing is then refused here rather than mailing subscribers about
|
||||
// an event that is not happening. Nothing about the rule changed; the occasion
|
||||
// did. See `audiences.permitted`.
|
||||
if (!audiences.permitted(event.triggerId, resolved.ceiling, event.ceiling)) {
|
||||
log.warn('rule audience exceeds its trigger ceiling - refusing', {
|
||||
rule: rule.id,
|
||||
trigger: event.triggerId,
|
||||
audience: resolved.ceiling,
|
||||
emitted: event.ceiling || null,
|
||||
})
|
||||
summary.skipped = 'ceiling'
|
||||
return summary
|
||||
@@ -132,15 +195,34 @@ async function applyRule(rule, event, now) {
|
||||
const dueAt = new Date(now.getTime() + Math.max(0, rule.delay_seconds) * 1000)
|
||||
|
||||
for (const channel of live) {
|
||||
const eligible = await subscribedTo(resolved.userIds, event.triggerId, channel)
|
||||
// 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
|
||||
}
|
||||
// One statement, guarded on the interval, so two concurrent emits cannot
|
||||
// both pass a read-then-write check (§4.1).
|
||||
const allowed = await cooldownsDb.claim(rule.id, userId, subjectKey, rule.cooldown_seconds, now)
|
||||
// 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
|
||||
@@ -151,6 +233,10 @@ async function applyRule(rule, event, now) {
|
||||
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.
|
||||
@@ -238,4 +324,13 @@ async function dispatch(event, now = new Date()) {
|
||||
return summary
|
||||
}
|
||||
|
||||
module.exports = { dispatch, applyRule, applyCancellations, subscribedTo, liveChannels, HOUR_MS }
|
||||
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 }
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
require('./transports/smtp')
|
||||
require('./coreChannels')
|
||||
require('./coreScopePrefs')
|
||||
|
||||
const transports = require('./transports')
|
||||
const channels = require('./channels')
|
||||
|
||||
194
server/src/engagement/moduleSeeds.js
Normal file
194
server/src/engagement/moduleSeeds.js
Normal file
@@ -0,0 +1,194 @@
|
||||
// ── 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 claim-before-insert
|
||||
* ordering and what it costs: 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. The guard is taken
|
||||
* atomically for the same reason; see the comment at the claim.
|
||||
*/
|
||||
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 {
|
||||
// **Claim BEFORE inserting, not after.** The guard used to be a `get()` here
|
||||
// and a `set()` after the loop, which is not a guard under concurrency: two
|
||||
// processes starting in the same moment both read "absent" and both insert
|
||||
// the whole group. That is not hypothetical — `docker compose up
|
||||
// --scale app=2` and a rolling restart both boot two instances deliberately,
|
||||
// and Phase 13's acceptance walk hit it with two, ending up with 52 module
|
||||
// rules where the module ships 26. `claim()` is an `INSERT IGNORE` reporting
|
||||
// its own `affectedRows`, so exactly one caller wins.
|
||||
//
|
||||
// The cost is the one this function already accepted below: a process that
|
||||
// dies mid-loop leaves the group stamped and partly seeded, and the missing
|
||||
// rules are an operator's visit to the "new rule" form. Duplicates are the
|
||||
// worse failure — two mails per event, for every rule in the group — which
|
||||
// is why the order is this way round rather than the other.
|
||||
if (!(await settings_.claim(key, new Date().toISOString()))) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
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 }
|
||||
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,
|
||||
}
|
||||
@@ -44,6 +44,27 @@ const AMBIENT_VARIABLES = Object.freeze([
|
||||
{ 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,
|
||||
@@ -236,21 +257,21 @@ const SEEDS = [
|
||||
name: 'Team post notification',
|
||||
channel: 'email',
|
||||
protected: false,
|
||||
seedVersion: 1,
|
||||
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: 'threadUrl', type: 'string', required: false, example: 'https://example.com/teams/1?thread=9' },
|
||||
{ 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', '{{threadUrl}}'),
|
||||
button('cta', 'Read the thread', '{{postUrl}}'),
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails for this team, use this link:'),
|
||||
],
|
||||
@@ -260,19 +281,90 @@ const SEEDS = [
|
||||
name: 'On-site notification',
|
||||
channel: 'inapp',
|
||||
protected: false,
|
||||
seedVersion: 1,
|
||||
// **seedVersion 2, and the bump is a correction rather than an improvement.**
|
||||
// Phase 5a wrote this template before the channel that renders it existed, and
|
||||
// named its variables `body` and `url` — names NOTHING supplies. A trigger
|
||||
// declares domain names (`teamName`, `threadTitle`), and `projection.project`
|
||||
// fills the gaps with the STRUCTURAL ones the generic seeds use: `title`,
|
||||
// `intro`, `actionUrl`. So every rendering of this template would have found
|
||||
// `body` and `url` missing and produced a title and nothing else. Renamed to
|
||||
// the vocabulary `notify.event` uses, which is the same property stated once:
|
||||
// a new trigger must render with no authoring at all.
|
||||
seedVersion: 2,
|
||||
// No subject: an inbox row has a title, and the title is a block. The column
|
||||
// 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: 'body', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
|
||||
{ name: 'url', type: 'string', required: false, example: 'https://example.com/houses' },
|
||||
{ name: '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('body', '{{body}}'),
|
||||
button('cta', 'Open', '{{url}}'),
|
||||
text('intro', '{{intro}}'),
|
||||
button('cta', 'Open', '{{actionUrl}}'),
|
||||
],
|
||||
},
|
||||
// ── The event system (EVENTS.md §J — Phase 10) ─────────────────────────
|
||||
//
|
||||
// **One body, not seven.** Six of the seven `event.` triggers render through
|
||||
// `notify.event` and the structural projection with no authoring at all
|
||||
// (§4.6.1 property 1) — they declare their own `title`, so an unauthored mail
|
||||
// is already headed with the event's name — and seeding a bespoke body per
|
||||
// trigger would be seven templates an operator has to maintain to change one
|
||||
// sentence.
|
||||
//
|
||||
// `event.run.started` gets one because it is the flagship: the mail that
|
||||
// answers §8.5's *"Come back for X — a scheduled event is starting"*, the one
|
||||
// an operator will actually enable, and the one where the generic body reads
|
||||
// visibly worse — `notify.event` renders the title over the TRIGGER's
|
||||
// description, while this reads the payload's own names and says what is
|
||||
// starting, when, and what arc it belongs to. Same argument `notify.team-post`
|
||||
// makes beside the generic body, one feature along.
|
||||
//
|
||||
// **Every optional line is one token on its own**, which is this template
|
||||
// language's whole conditional (see `email.text`: a block whose content is a
|
||||
// single absent variable renders nothing, in both parts). A standalone event
|
||||
// has no `seriesName` and its line disappears rather than reading "Part of .".
|
||||
//
|
||||
// **The button arrived with the page it points at** (Phase 14a). Until then
|
||||
// there was no public event page, the six public triggers declared no url
|
||||
// variable, and a button here would have rendered as an inert grey label in
|
||||
// every mail — worse than no button, because it advertises a link the reader
|
||||
// cannot follow. `eventUrl` is optional and `email.button` drops itself when
|
||||
// its url interpolates to nothing, so an event that is not public still mails
|
||||
// correctly: the block disappears rather than degrading.
|
||||
{
|
||||
key: 'notify.event-started',
|
||||
name: 'Event starting',
|
||||
channel: 'email',
|
||||
protected: false,
|
||||
// Bumped with the button. A deployment whose operator has not customized
|
||||
// this template gets the new one; one that has is left alone and reported as
|
||||
// stale, which is the whole mechanism.
|
||||
seedVersion: 2,
|
||||
subject: '{{title}} is starting',
|
||||
variables: [
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion' },
|
||||
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.' },
|
||||
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign' },
|
||||
{ name: 'startsAtLabel', type: 'string', required: false, example: 'Saturday 12 September at 8:00 pm (America/New_York)' },
|
||||
{ name: 'eventUrl', type: 'string', required: false, example: '/site/events/the-yew-invasion?run=3692' },
|
||||
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
|
||||
],
|
||||
blocks: [
|
||||
heading('h', '{{title}}'),
|
||||
text('summary', '{{summary}}'),
|
||||
text('when', '{{startsAtLabel}}', { muted: true }),
|
||||
text('series', '{{seriesName}}', { muted: true }),
|
||||
button('open', 'Read more', '{{eventUrl}}', 'Read more about it here:'),
|
||||
divider('rule'),
|
||||
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -282,4 +374,4 @@ function seedByKey(key) {
|
||||
return SEEDS.find((s) => s.key === key) || null
|
||||
}
|
||||
|
||||
module.exports = { SEEDS, AMBIENT_VARIABLES, seedByKey }
|
||||
module.exports = { SEEDS, AMBIENT_VARIABLES, DELIVERY_VARIABLES, seedByKey }
|
||||
|
||||
@@ -18,7 +18,7 @@ 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, seedByKey } = require('./templateSeeds')
|
||||
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')
|
||||
@@ -84,6 +84,11 @@ function variablesFor(template) {
|
||||
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)
|
||||
@@ -123,13 +128,17 @@ function renderTemplate(template, values, resolved) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 renderByKey(key, values = {}) {
|
||||
const resolved = await ambient()
|
||||
async function resolveTemplate(key) {
|
||||
let template = null
|
||||
try {
|
||||
template = await templatesDb.getByKey(key)
|
||||
@@ -158,9 +167,113 @@ async function renderByKey(key, values = {}) {
|
||||
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.
|
||||
@@ -215,4 +328,16 @@ async function seedTemplates() {
|
||||
const KEY_RE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
|
||||
const MAX_KEY = 96
|
||||
|
||||
module.exports = { ambient, variablesFor, renderTemplate, renderByKey, seedTemplates, baseUrl, KEY_RE, MAX_KEY }
|
||||
module.exports = {
|
||||
ambient,
|
||||
variablesFor,
|
||||
renderTemplate,
|
||||
resolveTemplate,
|
||||
renderByKey,
|
||||
renderInappByKey,
|
||||
relativeUrl,
|
||||
seedTemplates,
|
||||
baseUrl,
|
||||
KEY_RE,
|
||||
MAX_KEY,
|
||||
}
|
||||
|
||||
258
server/src/events/announce.js
Normal file
258
server/src/events/announce.js
Normal file
@@ -0,0 +1,258 @@
|
||||
// ── A run's lifecycle, told to the engagement engine ───────────────────────
|
||||
//
|
||||
// EVENTS.md §J, and Phase 10 of EVENTS_PLAN.md. Seven moments in a run's life
|
||||
// become seven `event.` triggers, and **Events owns none of the delivery**.
|
||||
//
|
||||
// That sentence is the whole design and it is worth being exact about what it
|
||||
// buys. Nothing in this file knows what email is, whether anyone is subscribed,
|
||||
// what a template says, or how often somebody may be mailed. It says a thing
|
||||
// happened, with the facts the declaration asked for; an operator's rule decides
|
||||
// the rest. Every announcement channel the platform has — email, the in-app
|
||||
// inbox, content-free push tickles, Discord and the town crier through the
|
||||
// announce legs — arrives for free the day a rule points at one, and none of
|
||||
// them arrives by anything in `events/` growing a second delivery path.
|
||||
//
|
||||
// **Nothing here throws and nothing here is awaited for its answer.** `emit`
|
||||
// itself is fire-and-forget by construction (see `engagementEmit`'s header) —
|
||||
// the whole point of the seam is that the emitter does not wait on rule lookups
|
||||
// and a dozen inserts. What IS awaited here is the read that assembles the
|
||||
// payload, and it is wrapped: a run must not fail to start because the row that
|
||||
// says what it is called could not be read.
|
||||
//
|
||||
// **A rehearsal narrows the ceiling rather than staying silent.** §I: "run for
|
||||
// real with announcements ceilinged to `staff`". Every emit below carries
|
||||
// `ceiling: 'staff'` when the run is a rehearsal, so the same triggers fire, the
|
||||
// same rules are evaluated, the same log lines are written — and the only rules
|
||||
// that survive the G24 gate are ones whose audience a staff member is in. A
|
||||
// rehearsal that emitted nothing would be a rehearsal of everything except the
|
||||
// announcements, which are the part most worth rehearsing.
|
||||
|
||||
const definitionsDb = require('../model/events/eventDefinitions.db')
|
||||
const participantsDb = require('../model/events/eventRunParticipants.db')
|
||||
const logDb = require('../model/events/eventRunLog.db')
|
||||
const engagementEmit = require('../utils/engagementEmit')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// §I, and the one place a rehearsal differs from the real thing on the announce
|
||||
// path. `staff` rather than `admin` because a rehearsal is the event team's
|
||||
// dress run and a moderator on it should see what an attendee would.
|
||||
const REHEARSAL_CEILING = 'staff'
|
||||
|
||||
/**
|
||||
* The start time written out in the shard-local zone, for a mail to read.
|
||||
*
|
||||
* **A presentational fragment computed at the emitter** (ENGAGEMENT.md §4.6.1
|
||||
* convention 1). `startsAt` also goes down the wire as a `datetime`, which the
|
||||
* seam normalises to an ISO string — right as data and wrong in a sentence — and
|
||||
* a template has no logic with which to format one. The zone is the shard's own,
|
||||
* because "8pm" means the shard's evening to everyone reading it and the
|
||||
* recipient's browser is not in the room when a mail is rendered.
|
||||
*
|
||||
* A bad zone answers null rather than throwing: `Intl` rejects an unknown
|
||||
* identifier, and an event whose timezone column holds a typo must still
|
||||
* announce. The variable is optional and its block is one token, so an absent
|
||||
* label renders as nothing at all rather than as a broken line.
|
||||
*/
|
||||
function startsAtLabel(at, zone) {
|
||||
const when = at instanceof Date ? at : new Date(at)
|
||||
if (Number.isNaN(when.getTime())) return undefined
|
||||
try {
|
||||
const text = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone || 'UTC',
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
// Explicit rather than left to the locale, because `en-GB` would otherwise
|
||||
// render midnight as "00:00" while the schedule editor beside it writes
|
||||
// "12:00 AM" — one event, two spellings of the same instant.
|
||||
//
|
||||
// `hourCycle: 'h12'` and NOT `hour12: true`, which is not the same request
|
||||
// and does not survive a Node upgrade. For a locale whose default cycle is
|
||||
// h23 — `en-GB` is one — Node 20 resolves `hour12: true` to **h11**, whose
|
||||
// hours run 0–11, so midnight comes out "0:00 am"; Node 22 and later
|
||||
// resolve it to h12 and it comes out "12:00 am". Same ICU on both, so this
|
||||
// is V8's ECMA-402 behaviour and not locale data, and the image ships
|
||||
// node:20-alpine while a dev machine is newer — which is how this rendered
|
||||
// correctly in front of everyone who wrote it and wrongly for every real
|
||||
// recipient. `recurrence.js` states the mirror-image rule for `h23`; there
|
||||
// is no `hour12` left in this repo and it should stay that way.
|
||||
hourCycle: 'h12',
|
||||
}).format(when)
|
||||
return `${text} (${zone || 'UTC'})`
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The facts every `event.` trigger shares, read once per emit.
|
||||
*
|
||||
* A run row from `findDue` is `SELECT *` over `event_runs` alone — no title, no
|
||||
* series, no summary — so the definition is fetched here rather than threaded
|
||||
* through every call site in the runner. It is one indexed read per lifecycle
|
||||
* transition, which is a handful per run.
|
||||
*/
|
||||
async function baseFor(run) {
|
||||
const definition = await definitionsDb.getById(run.definition_id)
|
||||
if (!definition) return null
|
||||
return {
|
||||
runId: String(run.id),
|
||||
title: definition.title,
|
||||
summary: definition.summary || undefined,
|
||||
seriesName: definition.series_name || undefined,
|
||||
timezone: run.timezone || definition.timezone || undefined,
|
||||
eventUrl: eventUrl(definition, run),
|
||||
definition,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The public page for one occurrence (Phase 14a).
|
||||
*
|
||||
* **Site-relative, and it carries the run.** The page lives at the definition's
|
||||
* slug — one stable address for a weekly event, which is what makes a link in
|
||||
* Discord survive a retitle — so the occurrence has to be in the query string or
|
||||
* a mail about last Friday's invasion would open next Friday's.
|
||||
*
|
||||
* **`undefined` when the event is not public**, rather than a path that answers
|
||||
* 404. An unlisted or not-yet-`ready` definition has no page, and `eventUrl` is
|
||||
* declared optional precisely so its block can disappear from a template instead
|
||||
* of rendering a dead button. That is `news.post`'s lesson applied before it
|
||||
* costs anything: a link nobody can follow is worse than no link, because it
|
||||
* advertises one.
|
||||
*/
|
||||
function eventUrl(definition, run) {
|
||||
if (!definition.slug) return undefined
|
||||
if (definition.state !== 'ready' || !definition.listed) return undefined
|
||||
return `/site/events/${encodeURIComponent(definition.slug)}?run=${run.id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire one lifecycle trigger.
|
||||
*
|
||||
* `extra` is merged over the shared facts and may drop any of them — a payload
|
||||
* key set to `undefined` is simply absent, and `validatePayload` treats absent
|
||||
* and null alike, so a trigger that declares fewer variables than this assembles
|
||||
* is not a problem: undeclared keys are dropped at the seam and logged as a
|
||||
* debug line rather than refused.
|
||||
*
|
||||
* Answers nothing. Every caller is a transition in the runner and none of them
|
||||
* has anything it could correctly do with a failure of core's own notification
|
||||
* bookkeeping.
|
||||
*/
|
||||
async function fire(run, triggerId, extra = {}) {
|
||||
try {
|
||||
const base = await baseFor(run)
|
||||
if (!base) {
|
||||
// The definition is gone. `event_runs.definition_id` cascades on delete, so
|
||||
// this is a race with an archive rather than an ordinary state — nothing to
|
||||
// announce and nothing broken.
|
||||
return
|
||||
}
|
||||
const { definition, ...facts } = base
|
||||
const ceiling = run.rehearsal ? REHEARSAL_CEILING : undefined
|
||||
|
||||
engagementEmit.emit('core', triggerId, {
|
||||
// The run, not the definition. Two occurrences of a weekly event are two
|
||||
// subjects, so last week's mail does not throttle this week's — and within
|
||||
// one run a cooldown means "at most one line an hour about THIS", which is
|
||||
// the sentence an operator writing `phase.changed` actually wants.
|
||||
subject: String(run.id),
|
||||
// Bounded and stable, because it ends up in a signed unsubscribe token that
|
||||
// will sit in a mailbox for months. A run id is both.
|
||||
scopeKey: `event:${run.id}`,
|
||||
ceiling,
|
||||
data: { ...facts, ...extra },
|
||||
})
|
||||
|
||||
// Written here rather than at each call site: what an operator wants in the
|
||||
// run log is that the run SAID something happened, and with what bound. How
|
||||
// many people were told is the engagement engine's own log line and its own
|
||||
// decision — a run log that claimed to know the number would be reporting a
|
||||
// decision it does not make.
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'announcement.emitted',
|
||||
phase: run.current_phase || null,
|
||||
detail: { trigger: triggerId, ...(ceiling ? { ceiling, because: 'rehearsal' } : {}) },
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('lifecycle announcement failed', { run: run.id, trigger: triggerId, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// ── One function per moment, so the runner names a moment and not a payload ──
|
||||
//
|
||||
// The alternative — `fire(run, 'event.run.started', { … })` at each call site —
|
||||
// would put the payload assembly in `eventRunner.js`, where a change to a
|
||||
// declaration becomes a change to the runner. These are the seam.
|
||||
|
||||
const runScheduled = (run) =>
|
||||
fire(run, 'event.run.scheduled', {
|
||||
startsAt: run.scheduled_for,
|
||||
startsAtLabel: startsAtLabel(run.scheduled_for, run.timezone),
|
||||
})
|
||||
|
||||
const runStarted = (run, startedAt) => {
|
||||
const at = startedAt || run.started_at || new Date()
|
||||
return fire(run, 'event.run.started', { startsAt: at, startsAtLabel: startsAtLabel(at, run.timezone) })
|
||||
}
|
||||
|
||||
const phaseChanged = (run, { phase, label, index, count }) =>
|
||||
fire(run, 'event.phase.changed', {
|
||||
phase,
|
||||
phaseLabel: label || phase,
|
||||
// One-based, because it is read by a human in a sentence. Every caller
|
||||
// passes the zero-based index it already has and the conversion is here, in
|
||||
// one place, rather than at three call sites where two of them would drift.
|
||||
phaseIndex: index + 1,
|
||||
phaseCount: count,
|
||||
})
|
||||
|
||||
const runEnding = (run) => fire(run, 'event.run.ending')
|
||||
|
||||
async function runCompleted(run, endedAt) {
|
||||
// Counted at emit rather than carried by the caller: the last thing a run does
|
||||
// before completing is its teardown, and a module's collect step may have
|
||||
// written rows within the same tick.
|
||||
let participantCount = 0
|
||||
try {
|
||||
participantCount = await participantsDb.countForRun(run.id)
|
||||
} catch (err) {
|
||||
// Declared `required`, so it has to be a number. Zero is the honest answer
|
||||
// for a count that could not be read, and it is also the answer for the far
|
||||
// more common case of a run nothing collected for.
|
||||
log.warn('participant count unavailable for announcement', { run: run.id, message: err.message })
|
||||
}
|
||||
const started = run.started_at ? new Date(run.started_at) : null
|
||||
const ended = endedAt ? new Date(endedAt) : new Date()
|
||||
const durationMinutes = started ? Math.max(0, Math.round((ended - started) / 60_000)) : 0
|
||||
return fire(run, 'event.run.completed', { participantCount, durationMinutes })
|
||||
}
|
||||
|
||||
const runCancelled = (run, reason) =>
|
||||
fire(run, 'event.run.cancelled', { reason: reason || undefined })
|
||||
|
||||
const runFailed = (run, error) =>
|
||||
fire(run, 'event.run.failed', {
|
||||
phase: run.current_phase || undefined,
|
||||
error: error || run.last_error || undefined,
|
||||
// The one destination that exists today. See `coreTriggers.js`'s note above
|
||||
// the six public declarations for why none of them has one.
|
||||
runUrl: `/admin/events/runs/${run.id}`,
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
fire,
|
||||
startsAtLabel,
|
||||
runScheduled,
|
||||
runStarted,
|
||||
phaseChanged,
|
||||
runEnding,
|
||||
runCompleted,
|
||||
runCancelled,
|
||||
runFailed,
|
||||
REHEARSAL_CEILING,
|
||||
}
|
||||
400
server/src/events/authorize.js
Normal file
400
server/src/events/authorize.js
Normal file
@@ -0,0 +1,400 @@
|
||||
// ── The whole authorisation decision, behind one function ──────────────────
|
||||
//
|
||||
// EVENTS.md §K, and Phase 6 of EVENTS_PLAN.md. Four layers stand between an
|
||||
// action being *declared* and an action being *carried out* — role, enablement,
|
||||
// cap, and the shard's own switch — and §K asks for them in one place rather
|
||||
// than spread across route middleware:
|
||||
//
|
||||
// > **Keep the check in one function.** Not for tidiness: it is what makes an
|
||||
// > EM-style delegation model a *later* option rather than a redesign. If a
|
||||
// > deployment ever wants named coordinators with their own budgets, that is
|
||||
// > one function learning to consult a second table, and nothing else in this
|
||||
// > document changes.
|
||||
//
|
||||
// So `mayInvoke()` below is the only thing in this codebase that answers "may
|
||||
// this happen". The router still calls `requireRole` — that gate is about
|
||||
// reaching the ROUTE — but whether a particular verb may be aimed at the world is
|
||||
// decided here, once, on every path that can cause it: authoring a step,
|
||||
// publishing, the dry run, starting a run, and the runner's own unattended
|
||||
// dispatch.
|
||||
//
|
||||
// ## Four layers, and where each one is actually enforced
|
||||
//
|
||||
// 1. **Declaration** — a module says a verb exists. Not a permission, and not
|
||||
// checked here: `dispatch.js` already answers a step whose action nobody
|
||||
// registers, and it answers it `dormant` rather than `refused`, because an
|
||||
// uninstalled module is a different fact from a forbidden one.
|
||||
// 2. **Role** — `change` and `irreversible` are `admin` only. See below.
|
||||
// 3. **Enablement and caps** — this file, against `event_action_settings` and
|
||||
// `event_run_budget`.
|
||||
// 4. **The shard's own switches** — `AdminWriteEnabled` and `AdminAccessFloor`
|
||||
// live on the shard host, outside the website's reach entirely, and core
|
||||
// deliberately does not duplicate them. A module honours them when it
|
||||
// translates an action into a sidecar command (P9); a second copy of that
|
||||
// decision in core would be a copy that could disagree with the shard about
|
||||
// whether the shard is accepting writes. It is named as a layer because
|
||||
// leaving it unnamed is how it comes to be re-implemented.
|
||||
//
|
||||
// ## The role line, and why it is drawn at `change`
|
||||
//
|
||||
// §K's table says "any step whose action is above `notify`, and the action
|
||||
// switchboard — `admin` only". Read literally that is the same sentence that made
|
||||
// `core.wait` — `risk: 'inspect'` — ship disabled by default, and the org lead
|
||||
// settled that on 2026-09-03: the line falls between `inspect` and `change`, not
|
||||
// between `notify` and `inspect`. An `inspect` action reads state and writes
|
||||
// nothing, so neither the default nor the role floor gains a deployment anything
|
||||
// by excluding it, and an editor who cannot author a step that WAITS has an
|
||||
// authoring role that cannot author.
|
||||
//
|
||||
// ## Why `user` may be null, and what that means
|
||||
//
|
||||
// The runner dispatches with nobody logged in. It is not "the system escalating":
|
||||
// the role was checked when a human published the version and again when a human
|
||||
// or the scheduler started the run, and **a run already in flight is not re-gated
|
||||
// against its starter's current role**. Re-checking would mean that demoting an
|
||||
// admin at midnight silently strands every event they started — an event stopping
|
||||
// halfway through because of an unrelated personnel change. §K's "a demoted user
|
||||
// loses access at once" is about reaching a route, and it still holds exactly
|
||||
// there. Cancel is the control for a run that should stop.
|
||||
//
|
||||
// ## Why the cap check can spend
|
||||
//
|
||||
// `mayInvoke` reads on every path but ONE, and on that one it must also write.
|
||||
// The cap is held by a conditional `UPDATE` whose WHERE carries the guard (§E), so
|
||||
// checking and then spending would be two statements with a race between them —
|
||||
// the exact race the conditional increment exists to remove. `spend: true` is
|
||||
// therefore a parameter rather than a separate function: one decision procedure,
|
||||
// one set of layers, and the authoritative check is the one that also commits.
|
||||
|
||||
const settingsDb = require('../model/events/eventActionSettings.db')
|
||||
const budgetDb = require('../model/events/eventRunBudget.db')
|
||||
const registries = require('../modules/registries')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// The risk classes that change the world, and the two things that follow from
|
||||
// being on this list: the action arrives DISABLED on a fresh deployment, and only
|
||||
// an admin may author a step that names it. Both were one sentence in §K and both
|
||||
// were settled together (org lead, 2026-09-03).
|
||||
const WORLD_CHANGING = ['change', 'irreversible']
|
||||
|
||||
/** Does this action alter the world, in the sense the switchboard and the role floor mean? */
|
||||
const changesWorld = (action) => WORLD_CHANGING.includes(action?.risk)
|
||||
|
||||
/**
|
||||
* Whether an action is enabled, given the deployment's stored opinion — or, when
|
||||
* it has none, its risk class.
|
||||
*
|
||||
* Exported because the switchboard renders the same answer, and a screen that
|
||||
* computed the default itself would be a second copy of the posture.
|
||||
*/
|
||||
function isEnabled(action, settingsRow) {
|
||||
if (settingsRow) return Boolean(settingsRow.enabled)
|
||||
return !changesWorld(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* What one invocation of `action` costs, as `{dimension: amount}`.
|
||||
*
|
||||
* **A module's `cost()` is called here and nowhere else.** It is declared as a
|
||||
* function of params (§F) and it is called with the params a step actually
|
||||
* carries, so the number core enforces is the number the module said. A `cost`
|
||||
* that throws, or that answers something other than a flat object of
|
||||
* non-negative finite numbers, is treated as an unpriceable action rather than a
|
||||
* free one: `null` comes back, and every caller reads `null` as a refusal. That
|
||||
* is the fail-closed direction, and it is the only honest one — an action whose
|
||||
* own accounting is broken is not an action whose consumption is zero.
|
||||
*/
|
||||
function priceOf(action, params) {
|
||||
if (typeof action?.cost !== 'function') return {}
|
||||
let raw
|
||||
try {
|
||||
raw = action.cost(params || {})
|
||||
} catch (err) {
|
||||
log.warn('event action cost() threw', { action: action.id, message: err.message })
|
||||
return null
|
||||
}
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
const out = {}
|
||||
for (const [dimension, amount] of Object.entries(raw)) {
|
||||
const n = Number(amount)
|
||||
if (!Number.isFinite(n) || n < 0) return null
|
||||
if (n > 0) out[dimension] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The dimensions an action can spend, discovered by pricing its declared
|
||||
* examples.
|
||||
*
|
||||
* **Phase 7 replaced half of this and deliberately kept the other half.** §F's
|
||||
* `registerEventBudgets` now declares a dimension's id, label and unit, so the
|
||||
* switchboard no longer has to invent a name for a box — see `budgetsOf` below,
|
||||
* which is what the screen reads. What a registry cannot answer is *which*
|
||||
* dimensions THIS action spends, because `cost` is a function of params (§F) and
|
||||
* the only honest way to ask it is to call it. So the discovery stays: core
|
||||
* prices each action's own `example` values, which is a use every param already
|
||||
* has a required `example` for.
|
||||
*
|
||||
* It is honest about its limits: a `cost()` that returns different dimension KEYS
|
||||
* for different params under-reports here. That costs an operator a cap box on
|
||||
* the switchboard, and it costs a run nothing at all — a run's budget is seeded
|
||||
* from the params its steps were actually authored with, never from examples.
|
||||
*/
|
||||
function dimensionsOf(action) {
|
||||
const params = {}
|
||||
for (const p of action?.params || []) {
|
||||
if (p.example !== undefined && p.example !== null) params[p.name] = p.example
|
||||
}
|
||||
const priced = priceOf(action, params)
|
||||
return priced ? Object.keys(priced).sort() : []
|
||||
}
|
||||
|
||||
/**
|
||||
* The same dimensions, dressed with what the registry says they are called.
|
||||
*
|
||||
* The switchboard's read (Phase 7). `registered: false` is the case worth having
|
||||
* a field for: an action that prices a dimension no module declares is a
|
||||
* DECLARATION ERROR — `mayInvoke` refuses it, the dry run fails on it, and the
|
||||
* save refuses it — so the screen has to be able to show the operator the reason
|
||||
* their action will not run, rather than silently listing one fewer cap box than
|
||||
* the action has dimensions. Hiding it would make a broken module look like a
|
||||
* cheap one.
|
||||
*/
|
||||
function budgetsOf(action) {
|
||||
return dimensionsOf(action).map((id) => {
|
||||
const declared = registries.eventBudget(id)
|
||||
return declared
|
||||
? { id, label: declared.label, unit: declared.unit, registered: true }
|
||||
: { id, label: id, unit: '', registered: false }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of these dimensions does nobody declare? (§F, org lead 2026-09-03.)
|
||||
*
|
||||
* Fail closed. §F's *"a module cannot spend a budget it did not declare"* is a
|
||||
* rule only if something asks, and this is what asks — from `mayInvoke` at
|
||||
* dispatch and at the dry run, and from the spec validator at save. Three places
|
||||
* because they answer at three different moments and only the first of them is
|
||||
* cheap: catching it at save costs an editor a red line, catching it at dispatch
|
||||
* costs a run a refused step at two in the morning.
|
||||
*
|
||||
* Not folded into `priceOf`, which answers *what does this cost* and should keep
|
||||
* answering only that: a cost of 12 creatures is a true statement about the
|
||||
* action whether or not anyone declared the dimension, and the two facts have
|
||||
* different fixes.
|
||||
*/
|
||||
function undeclaredDimensions(cost) {
|
||||
return Object.keys(cost || {}).filter((d) => !registries.isEventBudget(d))
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective per-run cap for each dimension a set of steps will spend.
|
||||
*
|
||||
* **The tightest cap wins** (org lead, 2026-09-03). `event_action_settings.caps`
|
||||
* is per action while `event_run_budget` is one row per dimension, so two actions
|
||||
* both spending `uo.creatures` have to agree on one number, and the number a
|
||||
* safety limit should settle on is the smaller. It is what keeps a dimension a
|
||||
* bound on the RUN's total effect rather than a per-verb allowance that two verbs
|
||||
* can each draw in full.
|
||||
*
|
||||
* A dimension no action caps comes back `{ cap: null }` — uncapped, and still
|
||||
* seeded, so the meter counts it and a missing row keeps its one meaning.
|
||||
*
|
||||
* `steps` are `{ actionId, params }`; the answer is `{dimension: {cap, from}}`.
|
||||
*/
|
||||
function effectiveCaps(steps, settingsByAction) {
|
||||
const out = {}
|
||||
for (const step of steps || []) {
|
||||
const action = registries.eventAction(step.actionId)
|
||||
if (!action) continue
|
||||
const priced = priceOf(action, step.params)
|
||||
if (!priced) continue
|
||||
const declared = (settingsByAction.get(action.id) || {}).caps || {}
|
||||
for (const dimension of Object.keys(priced)) {
|
||||
const raw = declared[dimension]
|
||||
const cap = Number.isFinite(Number(raw)) && Number(raw) >= 0 ? Number(raw) : null
|
||||
if (!(dimension in out)) {
|
||||
out[dimension] = { cap, from: cap === null ? null : action.id }
|
||||
continue
|
||||
}
|
||||
const held = out[dimension]
|
||||
// `null` is uncapped, so it never wins a minimum — an action that declines
|
||||
// to cap a dimension must not raise the ceiling another action set.
|
||||
if (cap !== null && (held.cap === null || cap < held.cap)) {
|
||||
out[dimension] = { cap, from: action.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* May this action be carried out, and — when asked — spend its cost.
|
||||
*
|
||||
* Answers an envelope, never throws, and never answers a bare boolean: every
|
||||
* refusal carries a `code` a caller can branch on and a `reason` a human reads.
|
||||
* The reason is written here rather than at the four call sites for the same
|
||||
* argument Phase 5 made about the diagnosis panel — one place the words are
|
||||
* written, so the dry run, the editor, the run console and the log all say the
|
||||
* same sentence about the same fact.
|
||||
*
|
||||
* `{ user }` null means the unattended runner; see the header. `{ run }` null
|
||||
* means there is no budget to draw on yet — authoring and the dry run — and the
|
||||
* cap layer then compares the cost against the effective cap instead of against
|
||||
* what is left of it.
|
||||
*/
|
||||
async function mayInvoke({
|
||||
user = null,
|
||||
action,
|
||||
params = {},
|
||||
run = null,
|
||||
settings = undefined,
|
||||
spend = false,
|
||||
} = {}) {
|
||||
if (!action) return { ok: false, code: 'unregistered', reason: 'no module registers this action' }
|
||||
|
||||
// ── Layer 2: the role ──
|
||||
if (user && changesWorld(action) && user.role !== 'admin') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'role',
|
||||
reason: `"${action.label}" changes the world, so only an administrator may use it`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Layer 3a: enablement ──
|
||||
const row = settings === undefined ? await settingsDb.get(action.id) : settings
|
||||
if (!isEnabled(action, row)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'disabled',
|
||||
reason: `"${action.label}" is not enabled on this deployment`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Layer 3b: the cap ──
|
||||
const cost = priceOf(action, params)
|
||||
if (cost === null) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unpriceable',
|
||||
reason: `"${action.label}" could not report what it costs`,
|
||||
}
|
||||
}
|
||||
const dimensions = Object.keys(cost)
|
||||
if (!dimensions.length) return { ok: true, cost }
|
||||
|
||||
// Before any cap arithmetic, because a dimension nobody declared has no cap to
|
||||
// be under and no meter to draw on — asking "is 12 within the limit" about a
|
||||
// resource core has never been told the name of would be answering a question
|
||||
// that has not been asked yet. It is also the honest reading of the refusal:
|
||||
// this is a module whose declaration is incomplete, not a deployment whose
|
||||
// allowance is spent, and an operator who is told the second will go and raise
|
||||
// a cap that changes nothing.
|
||||
const undeclared = undeclaredDimensions(cost)
|
||||
if (undeclared.length) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'undeclared',
|
||||
reason: `spends "${undeclared[0]}", which no module declares as a budget`,
|
||||
dimension: undeclared[0],
|
||||
requested: cost[undeclared[0]],
|
||||
}
|
||||
}
|
||||
|
||||
if (!run) {
|
||||
// No run, so nothing to draw on: the question is whether the cost could EVER
|
||||
// fit, which is what the dry run and the editor are asking. A cost larger
|
||||
// than the cap is an authoring error and it is answerable before anything is
|
||||
// scheduled — which is the entire value of catching it here.
|
||||
const caps = effectiveCaps([{ actionId: action.id, params }], new Map([[action.id, row || {}]]))
|
||||
for (const dimension of dimensions) {
|
||||
const { cap } = caps[dimension] || { cap: null }
|
||||
if (cap !== null && cost[dimension] > cap) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'cap',
|
||||
reason: `asks for ${cost[dimension]} of "${dimension}" and this deployment allows ${cap} per run`,
|
||||
dimension,
|
||||
requested: cost[dimension],
|
||||
cap,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true, cost }
|
||||
}
|
||||
|
||||
if (!spend) {
|
||||
// A read of the meter rather than a draw on it. Deliberately advisory: this
|
||||
// answer is stale the moment another step in the same tick spends, which is
|
||||
// exactly why the authoritative check is the one that commits.
|
||||
const rows = await budgetDb.forRun(run.id)
|
||||
const byDimension = new Map(rows.map((r) => [r.dimension, r]))
|
||||
for (const dimension of dimensions) {
|
||||
const held = byDimension.get(dimension)
|
||||
if (!held) return refusal(dimension, cost[dimension], null, 0, 'unbudgeted')
|
||||
if (held.cap !== null && held.consumed + cost[dimension] > held.cap) {
|
||||
return refusal(dimension, cost[dimension], held.cap, held.consumed, 'cap')
|
||||
}
|
||||
}
|
||||
return { ok: true, cost }
|
||||
}
|
||||
|
||||
// ── The committing path ──
|
||||
//
|
||||
// One statement per dimension, because the atomicity that matters is per
|
||||
// dimension: a cap is a bound on one thing, and a transaction spanning three of
|
||||
// them would serialise three unrelated counters to buy nothing. What it does
|
||||
// create is a partial spend — creatures taken, bosses refused — and a step that
|
||||
// did not run must not have spent anything, so the taken ones are given back.
|
||||
const taken = []
|
||||
for (const dimension of dimensions) {
|
||||
if (await budgetDb.spend(run.id, dimension, cost[dimension])) {
|
||||
taken.push(dimension)
|
||||
continue
|
||||
}
|
||||
for (const back of taken) await budgetDb.refund(run.id, back, cost[back])
|
||||
const rows = await budgetDb.forRun(run.id)
|
||||
const held = rows.find((r) => r.dimension === dimension)
|
||||
return held
|
||||
? refusal(dimension, cost[dimension], held.cap, held.consumed, 'cap')
|
||||
: refusal(dimension, cost[dimension], null, 0, 'unbudgeted')
|
||||
}
|
||||
return { ok: true, cost, spent: true }
|
||||
}
|
||||
|
||||
/** The two cap refusals, written once so they cannot drift apart. */
|
||||
function refusal(dimension, requested, cap, consumed, code) {
|
||||
if (code === 'unbudgeted') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unbudgeted',
|
||||
reason: `spends "${dimension}", which this run has no budget for`,
|
||||
dimension,
|
||||
requested,
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: 'cap',
|
||||
reason: `asks for ${requested} of "${dimension}"; ${consumed} of ${cap} is already spent this run`,
|
||||
dimension,
|
||||
requested,
|
||||
cap,
|
||||
consumed,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mayInvoke,
|
||||
isEnabled,
|
||||
priceOf,
|
||||
dimensionsOf,
|
||||
budgetsOf,
|
||||
undeclaredDimensions,
|
||||
effectiveCaps,
|
||||
changesWorld,
|
||||
WORLD_CHANGING,
|
||||
}
|
||||
437
server/src/events/cleanup.js
Normal file
437
server/src/events/cleanup.js
Normal file
@@ -0,0 +1,437 @@
|
||||
// ── Giving back what a run took ────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §C ("Cleanup is generated, never authored"), §L and its two ledger
|
||||
// rules, and Phase 8 of EVENTS_PLAN.md. `events/ledger.js` is the write half;
|
||||
// this is the undo half, plus the reconcile that answers "is any of it still
|
||||
// there?" after something outside core restarted.
|
||||
//
|
||||
// **Cleanup is derived from the ledger, never authored.** An operator cannot be
|
||||
// relied on to write the undo, and an aborted run never reaches the phase they
|
||||
// wrote it in — so there is no cleanup phase in a spec and no `on_teardown` on an
|
||||
// action. There is one function, it reads rows, and it runs on EVERY terminal
|
||||
// path: completion, cancellation and abort alike.
|
||||
//
|
||||
// **It is not built out of `event_run_steps` rows** (org lead, 2026-09-03). The
|
||||
// plan's phrase is "cleanup steps are generated from the ledger", and the
|
||||
// tempting reading is a synthetic phase of real step rows so the console's
|
||||
// per-step retry comes free. It is the wrong shape here for one concrete reason:
|
||||
// `event_run_resources` already carries `revert_attempts` and `last_error`, so
|
||||
// synthetic steps would put a second retry counter beside the first and the two
|
||||
// would disagree the first time a step reverted three of its four resources.
|
||||
// The manual retry the API surface promises is a route over the ledger —
|
||||
// `POST /admin/events/runs/:runId/cleanup` — rather than a step control.
|
||||
//
|
||||
// **Where it runs from.** One place: the runner's cleanup leg, which finds
|
||||
// terminal runs that still owe the world something and works their rows. Hooking
|
||||
// each terminal path instead would be four call sites, three of which are inside
|
||||
// a request, and none of which would survive the process dying mid-cleanup. The
|
||||
// leg is ordered AFTER advance in the tick, so a run that completes in one tick
|
||||
// is cleaned in the same one.
|
||||
//
|
||||
// **The scan's WHERE clause cost two live-walk findings, in opposite
|
||||
// directions.** A run whose only resource was a LEASE never went through
|
||||
// `ledger.markRunDirty` — `core.lease` reserves its own row — so its
|
||||
// `cleanup_status` stayed `not_required` and the lease was never given back at
|
||||
// all. And a run whose first sweep failed was moved to `incomplete` by that very
|
||||
// sweep, so it was never picked up again: `MAX_REVERT_ATTEMPTS` meant ONE attempt
|
||||
// rather than three. The first is why `not_required` is in the scan; the second
|
||||
// is why `incomplete` is written HERE only once nothing retryable is left.
|
||||
//
|
||||
// **Rule 2 is what the bounds are for.** A revert that never succeeds must stay
|
||||
// visible rather than cycle: `MAX_REVERT_ATTEMPTS` stops the automatic retry, the
|
||||
// run reaches `completed` with `cleanup_status = 'incomplete'`, and the rows stay
|
||||
// on the console with their last error. Only a human's cleanup clears the
|
||||
// counter — Engagement Phase 14's rule, whose defect was a sweep that reset every
|
||||
// stale row and made the ceiling unreachable for ever.
|
||||
|
||||
const resourcesDb = require('../model/events/eventRunResources.db')
|
||||
const runsDb = require('../model/events/eventRuns.db')
|
||||
const logDb = require('../model/events/eventRunLog.db')
|
||||
const stepsDb = require('../model/events/eventRunSteps.db')
|
||||
const registries = require('../modules/registries')
|
||||
const { withDeadline } = require('./dispatch')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// How many times the automatic sweep will ask before leaving a resource for a
|
||||
// human. Three, like a step's, and for the same reason: a fourth attempt against
|
||||
// a shard that has answered the same way three times is not new information.
|
||||
const MAX_REVERT_ATTEMPTS = Number(process.env.EVENT_REVERT_MAX_ATTEMPTS) || 3
|
||||
|
||||
// The bound on one revert call, when the action that made the resource is gone
|
||||
// and there is no `budgetMs` to read. A restore is a round trip like any other.
|
||||
const DEFAULT_REVERT_BUDGET_MS = 10_000
|
||||
|
||||
// How many runs one cleanup leg looks at, and how many resource groups it works
|
||||
// per run. Bounds rather than targets, exactly like `RUN_BATCH`: the tick runs
|
||||
// again, and an unbounded teardown is how one run's bad night stalls every other.
|
||||
const CLEANUP_RUN_BATCH = Number(process.env.EVENT_CLEANUP_RUN_BATCH) || 10
|
||||
const CLEANUP_GROUPS_PER_RUN = Number(process.env.EVENT_CLEANUP_GROUPS_PER_RUN) || 25
|
||||
|
||||
/**
|
||||
* Classify one revert answer, with `dispatch.classify`'s posture: no shape a
|
||||
* failure can take may read as success.
|
||||
*
|
||||
* The extra value here is `drifted`. It is NOT an error — the module did exactly
|
||||
* what it was asked and found somebody else's value in place — so it is a third
|
||||
* outcome rather than a failure with a flag, and the row it produces is the one
|
||||
* §L wants surfaced beside the unreverted ones.
|
||||
*/
|
||||
function classifyRevert(raw, what) {
|
||||
if (raw && raw.__timedOut) return { outcome: 'retry', error: raw.error }
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return { outcome: 'retry', error: `${what} answered with no envelope` }
|
||||
}
|
||||
if (raw.ok === true) {
|
||||
// §L, and the Rust wipe: "gone, and that is fine" is a successful revert. A
|
||||
// module never has to distinguish "I deleted it" from "it was not there".
|
||||
return { outcome: 'done', failed: Array.isArray(raw.failed) ? raw.failed.map(String) : [] }
|
||||
}
|
||||
if (raw.drifted === true) {
|
||||
return {
|
||||
outcome: 'drifted',
|
||||
error: `the value is now ${JSON.stringify(raw.current)} rather than what this run applied, so it was left alone`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
outcome: raw.retry === false ? 'terminal' : 'retry',
|
||||
error: raw.error ? String(raw.error) : `${what} refused`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Call a lease's `restore`, under a deadline, never throwing. */
|
||||
async function restoreLease(row) {
|
||||
// Through the ref parser, because a targeted lease's ref is `<id>#<target>`
|
||||
// (Phase 12b). A bare map lookup would miss every property lease and report
|
||||
// "no module registers" for one that is registered — leaving a spawner turned
|
||||
// up for good and blaming an uninstalled module for it.
|
||||
const found = registries.eventLeaseForRef(row.ref)
|
||||
const lease = found && found.lease
|
||||
if (!lease) {
|
||||
// The module that owned it is uninstalled or failed to boot. Not a failure to
|
||||
// retry away — nothing will change until an operator reinstalls it — and not
|
||||
// an orphan either, because core has no idea whether the value is still
|
||||
// applied. It stays unresolved with the reason on it, which is exactly what
|
||||
// `cleanup_status = 'incomplete'` is for.
|
||||
return { outcome: 'terminal', error: `no module registers the lease "${row.ref}"` }
|
||||
}
|
||||
const payload = row.payload || {}
|
||||
let raw
|
||||
try {
|
||||
raw = await withDeadline(
|
||||
() =>
|
||||
lease.restore(payload.baseline, {
|
||||
expected: payload.applied,
|
||||
runId: row.run_id,
|
||||
target: found.target,
|
||||
}),
|
||||
DEFAULT_REVERT_BUDGET_MS,
|
||||
row.ref,
|
||||
)
|
||||
} catch (err) {
|
||||
return { outcome: 'retry', error: err.message }
|
||||
}
|
||||
return classifyRevert(raw, row.ref)
|
||||
}
|
||||
|
||||
/** Call an action's `revert` over a group of its rows, under a deadline, never throwing. */
|
||||
async function revertGroup(actionId, rows, idempotencyKey) {
|
||||
const action = registries.eventAction(actionId)
|
||||
if (!action || typeof action.revert !== 'function') {
|
||||
return {
|
||||
outcome: 'terminal',
|
||||
error: action
|
||||
? `${actionId} declares no revert()`
|
||||
: `no module registers "${actionId}", so its resources cannot be given back`,
|
||||
}
|
||||
}
|
||||
const payload = rows
|
||||
.filter((r) => r.kind !== resourcesDb.STEP_KIND)
|
||||
.map((r) => ({ kind: r.kind, ref: r.ref, payload: r.payload || null, memberKey: r.member_key || null }))
|
||||
|
||||
let raw
|
||||
try {
|
||||
raw = await withDeadline(
|
||||
() => action.revert({ runId: rows[0].run_id, resources: payload, idempotencyKey }),
|
||||
action.budgetMs || DEFAULT_REVERT_BUDGET_MS,
|
||||
actionId,
|
||||
)
|
||||
} catch (err) {
|
||||
// A module should not throw from `revert` any more than from `perform`, and
|
||||
// one that does has produced a transient failure rather than a crashed sweep.
|
||||
log.warn('event revert threw', { action: actionId, message: err.message })
|
||||
return { outcome: 'retry', error: err.message }
|
||||
}
|
||||
return classifyRevert(raw, actionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Work one run's ledger once.
|
||||
*
|
||||
* Answers what it found and what it managed, and sets `cleanup_status` from the
|
||||
* rows that are left rather than from what it did — the two differ whenever
|
||||
* another writer touched the run, and the rows are the truth.
|
||||
*
|
||||
* `resetAttempts` is the human's flag. It is never set by the automatic leg.
|
||||
*/
|
||||
async function cleanupRun(run, { resetAttempts = false, actor = null } = {}) {
|
||||
const summary = { attempted: 0, reverted: 0, drifted: 0, failed: 0, remaining: 0 }
|
||||
|
||||
if (resetAttempts) {
|
||||
const cleared = await resourcesDb.resetAttempts(run.id)
|
||||
await runsDb.setCleanupStatus(run.id, 'pending')
|
||||
if (cleared) {
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'cleanup.retry',
|
||||
detail: { resources: cleared, by: actor },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await resourcesDb.unresolvedForRun(run.id, {
|
||||
maxAttempts: resetAttempts ? null : MAX_REVERT_ATTEMPTS,
|
||||
})
|
||||
|
||||
// **Grouped by the step that made them**, because that is what names the verb:
|
||||
// the resource row records the module and the opaque names, the step records
|
||||
// the action, and `revert()` takes a LIST so one round trip can give back
|
||||
// twelve creatures. A lease is its own group of one — core restores it through
|
||||
// the lease registry rather than through any action, which is the split §F
|
||||
// draws and the reason `core.lease` needs no `revert()` of its own.
|
||||
const leases = rows.filter((r) => r.kind === 'override')
|
||||
const byStep = new Map()
|
||||
for (const row of rows) {
|
||||
if (row.kind === 'override') continue
|
||||
const key = row.step_id === null ? `orphan:${row.id}` : `step:${row.step_id}`
|
||||
if (!byStep.has(key)) byStep.set(key, [])
|
||||
byStep.get(key).push(row)
|
||||
}
|
||||
|
||||
const groups = [...leases.map((r) => ({ lease: r })), ...[...byStep.values()].map((rs) => ({ rows: rs }))]
|
||||
|
||||
for (const group of groups.slice(0, CLEANUP_GROUPS_PER_RUN)) {
|
||||
if (group.lease) {
|
||||
const row = group.lease
|
||||
if (!(await resourcesDb.claimRevert(row.id))) continue
|
||||
summary.attempted += 1
|
||||
const verdict = await restoreLease(row)
|
||||
await applyVerdict(run, [row], verdict, summary, row.ref)
|
||||
continue
|
||||
}
|
||||
|
||||
const rs = group.rows
|
||||
// The step is what names the action and carries the idempotency key. A row
|
||||
// whose step was deleted keeps the action id in its own payload, which is why
|
||||
// the placeholder writes one.
|
||||
const step = rs[0].step_id === null ? null : await stepsDb.getById(rs[0].step_id)
|
||||
const actionId = step?.action_id || rs[0].payload?.action || null
|
||||
if (!actionId) {
|
||||
await noteUnrevertable(run, rs, 'nothing records which action created this', summary)
|
||||
continue
|
||||
}
|
||||
const claimed = []
|
||||
for (const row of rs) if (await resourcesDb.claimRevert(row.id)) claimed.push(row)
|
||||
if (!claimed.length) continue
|
||||
summary.attempted += claimed.length
|
||||
const verdict = await revertGroup(actionId, claimed, step?.idempotency_key || rs[0].ref)
|
||||
await applyVerdict(run, claimed, verdict, summary, actionId)
|
||||
}
|
||||
|
||||
summary.remaining = await resourcesDb.unresolvedCount(run.id)
|
||||
// **`incomplete` means "finished with, and not finished"**, so it is written
|
||||
// only once there is nothing left this sweep will try. Writing it after the
|
||||
// FIRST failure — which is what the first draft did — took the run straight out
|
||||
// of the leg's own scan, and `MAX_REVERT_ATTEMPTS` quietly meant one attempt
|
||||
// rather than three. Found by the live walk, watching `revert_attempts` sit at
|
||||
// 1 through half a minute of ticks.
|
||||
const retryable = await resourcesDb.unresolvedForRun(run.id, { maxAttempts: MAX_REVERT_ATTEMPTS })
|
||||
const status = summary.remaining === 0 ? 'complete' : retryable.length ? 'pending' : 'incomplete'
|
||||
await runsDb.setCleanupStatus(run.id, status)
|
||||
|
||||
if (summary.attempted > 0) {
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'cleanup.swept',
|
||||
detail: { ...summary, by: actor },
|
||||
})
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/** Write one verdict across the rows it covers, and count it. */
|
||||
async function applyVerdict(run, rows, verdict, summary, what) {
|
||||
for (const row of rows) {
|
||||
if (verdict.outcome === 'done' && !(verdict.failed || []).includes(row.ref)) {
|
||||
await resourcesDb.markReverted(row.id)
|
||||
summary.reverted += 1
|
||||
continue
|
||||
}
|
||||
if (verdict.outcome === 'drifted') {
|
||||
await resourcesDb.failRevert(row.id, verdict.error, 'drifted')
|
||||
summary.drifted += 1
|
||||
continue
|
||||
}
|
||||
const error =
|
||||
verdict.outcome === 'done'
|
||||
? `${what} could not give "${row.ref}" back`
|
||||
: verdict.error
|
||||
await resourcesDb.failRevert(row.id, error, 'confirmed')
|
||||
summary.failed += 1
|
||||
}
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: verdict.outcome === 'done' ? 'cleanup.reverted' : 'cleanup.failed',
|
||||
detail: {
|
||||
what,
|
||||
outcome: verdict.outcome,
|
||||
resources: rows.map((r) => `${r.kind}:${r.ref}`),
|
||||
...(verdict.error ? { error: verdict.error } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** A group core cannot even name a verb for. Counted as failed, and said once. */
|
||||
async function noteUnrevertable(run, rows, reason, summary) {
|
||||
for (const row of rows) {
|
||||
if (!(await resourcesDb.claimRevert(row.id))) continue
|
||||
await resourcesDb.failRevert(row.id, reason, 'confirmed')
|
||||
summary.attempted += 1
|
||||
summary.failed += 1
|
||||
}
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'cleanup.failed',
|
||||
detail: { what: null, outcome: 'terminal', resources: rows.map((r) => `${r.kind}:${r.ref}`), error: reason },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The cleanup leg of the tick: every TERMINAL run with something left to give
|
||||
* back.
|
||||
*
|
||||
* Terminal only. A run still in flight has a ledger that is still growing, and
|
||||
* reverting a resource the next step is about to use would be core undoing an
|
||||
* event while it is happening.
|
||||
*/
|
||||
async function sweep() {
|
||||
// The ceiling goes INTO the query, so a run whose rows are all spent is not
|
||||
// selected, worked over and found to have nothing to do on every tick for the
|
||||
// rest of its life. It is also what excludes a run an admin cancelled without
|
||||
// cleanup, whose counters were spent deliberately.
|
||||
const candidates = await resourcesDb.runsNeedingCleanup(CLEANUP_RUN_BATCH, MAX_REVERT_ATTEMPTS)
|
||||
let swept = 0
|
||||
for (const candidate of candidates) {
|
||||
if (!runsDb.TERMINAL.includes(candidate.status)) continue
|
||||
try {
|
||||
await cleanupRun(candidate)
|
||||
swept += 1
|
||||
} catch (err) {
|
||||
log.error('event cleanup failed', { run: candidate.id, message: err.message })
|
||||
}
|
||||
}
|
||||
return swept
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask one module which of its ledgered resources the game still has (§L, and
|
||||
* §N7's "the shard stays stateless about events").
|
||||
*
|
||||
* **Core cannot know when to ask**, and that is not an omission: §F says core has
|
||||
* no concept of the game being up, because a module with six sidecars cannot
|
||||
* answer that question in the singular. So the module triggers this, through
|
||||
* `ctx.events.reconcile()`, when it sees its own reconnect — module-uo already
|
||||
* watches `bootId` for exactly that. Core also asks once at boot, for its own
|
||||
* restart.
|
||||
*
|
||||
* **A resource the module no longer has becomes `orphaned`, never `reverted`.**
|
||||
* Reverting it would be core recording that it put something back when what
|
||||
* actually happened is that the thing vanished while nobody was looking, and the
|
||||
* two are different sentences to the operator reading the console afterwards.
|
||||
*
|
||||
* A module with no `reconcile()` on the action is not broken: core keeps
|
||||
* believing its own ledger, which is precisely the behaviour before this phase.
|
||||
*/
|
||||
async function reconcileModule(owner) {
|
||||
const rows = await resourcesDb.liveForModule(owner)
|
||||
const summary = { asked: 0, inForce: 0, orphaned: 0, unanswered: 0 }
|
||||
if (!rows.length) return summary
|
||||
|
||||
const byStep = new Map()
|
||||
for (const row of rows) {
|
||||
if (row.kind === resourcesDb.STEP_KIND) continue // nothing to ask about yet
|
||||
const key = row.step_id === null ? `orphan:${row.id}` : `step:${row.step_id}`
|
||||
if (!byStep.has(key)) byStep.set(key, [])
|
||||
byStep.get(key).push(row)
|
||||
}
|
||||
|
||||
for (const group of byStep.values()) {
|
||||
const step = group[0].step_id === null ? null : await stepsDb.getById(group[0].step_id)
|
||||
const actionId = step?.action_id || group[0].payload?.action || null
|
||||
const action = actionId ? registries.eventAction(actionId) : null
|
||||
if (!action || typeof action.reconcile !== 'function') {
|
||||
summary.unanswered += group.length
|
||||
continue
|
||||
}
|
||||
summary.asked += group.length
|
||||
let raw
|
||||
try {
|
||||
raw = await withDeadline(
|
||||
() =>
|
||||
action.reconcile({
|
||||
runId: group[0].run_id,
|
||||
resources: group.map((r) => ({ kind: r.kind, ref: r.ref, payload: r.payload || null })),
|
||||
}),
|
||||
action.budgetMs || DEFAULT_REVERT_BUDGET_MS,
|
||||
actionId,
|
||||
)
|
||||
} catch (err) {
|
||||
log.warn('event reconcile threw', { action: actionId, message: err.message })
|
||||
raw = null
|
||||
}
|
||||
// Same posture as everywhere else: nothing that is not an explicit answer
|
||||
// counts as one. A module that could not answer leaves the ledger alone,
|
||||
// because "I do not know" must never be read as "it is gone".
|
||||
if (!raw || raw.__timedOut || raw.ok !== true || !Array.isArray(raw.inForce)) {
|
||||
summary.unanswered += group.length
|
||||
continue
|
||||
}
|
||||
const held = new Set(raw.inForce.map(String))
|
||||
for (const row of group) {
|
||||
if (held.has(row.ref)) {
|
||||
summary.inForce += 1
|
||||
continue
|
||||
}
|
||||
await resourcesDb.markOrphaned(row.id, 'the module reports this is no longer in force')
|
||||
summary.orphaned += 1
|
||||
await logDb.write({
|
||||
runId: row.run_id,
|
||||
kind: 'resource.orphaned',
|
||||
detail: { module: owner, resource: `${row.kind}:${row.ref}`, action: actionId },
|
||||
})
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/** Ask every module that owns a live row. Core's own boot-time sweep. */
|
||||
async function reconcileAll() {
|
||||
const owners = await resourcesDb.modulesWithLiveRows()
|
||||
const out = {}
|
||||
for (const owner of owners) {
|
||||
try {
|
||||
out[owner] = await reconcileModule(owner)
|
||||
} catch (err) {
|
||||
log.error('event reconcile failed', { module: owner, message: err.message })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_REVERT_ATTEMPTS,
|
||||
classifyRevert,
|
||||
cleanupRun,
|
||||
sweep,
|
||||
reconcileModule,
|
||||
reconcileAll,
|
||||
}
|
||||
253
server/src/events/dispatch.js
Normal file
253
server/src/events/dispatch.js
Normal file
@@ -0,0 +1,253 @@
|
||||
// ── Dispatching one step to one action ─────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §F. This is the boundary between the runner and code core did not
|
||||
// write, and it exists as its own file because it has exactly one job: call
|
||||
// `perform()` and turn whatever comes back — an envelope, a lie, a throw, a
|
||||
// promise that never settles — into one of four classifications the runner knows
|
||||
// how to act on.
|
||||
//
|
||||
// **§F's load-bearing rule, and the reason none of this is inlined into the
|
||||
// runner: no shape a failure can take may read as success.** A rejected promise,
|
||||
// a throw, a timeout, a non-object and a missing `ok` are all
|
||||
// `{ ok: false, retry: true }`. That is the inverse of `registerTeamProvider`'s
|
||||
// default, deliberately — a team provider that refuses leaves core showing what
|
||||
// it already had, because staleness is cheap, whereas an action that half-ran and
|
||||
// was recorded as `done` is a world change nothing will ever come back for.
|
||||
//
|
||||
// **The timeout is the module contract's, not this file's opinion.** Every action
|
||||
// declares `budgetMs` at registration and the registry bounds it there; here it
|
||||
// is enforced. Without it a module whose `perform()` awaits a socket that never
|
||||
// answers holds a step's claim until the lease expires, and the reclaim then
|
||||
// re-dispatches it — which is how one wedged sidecar becomes an infinite loop
|
||||
// rather than a failed step.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// What a classification can be. `parked` is Phase 2's addition and it is the one
|
||||
// outcome that is neither terminal nor a retry: the action succeeded, and the
|
||||
// step is not finished, because something outside this system has to happen next.
|
||||
const OUTCOMES = ['done', 'parked', 'retry', 'terminal']
|
||||
|
||||
// The upper bound on `holdFor`, in seconds. A wait is a scheduling instruction,
|
||||
// not a lease, so this is generous — but it is bounded, because an action that
|
||||
// answers `holdFor: 1e9` would park the phase past the heat death of the shard
|
||||
// and the step that did it would look, in the console, exactly like one that
|
||||
// worked.
|
||||
const MAX_HOLD_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
// The upper bound on a module's `detail`, in bytes of serialised JSON. It lands
|
||||
// in `event_run_log.detail` and is read back by the run console, so it is a
|
||||
// diagnostic line rather than a data channel — a module with more to say than
|
||||
// this has a table of its own to say it in. Dropped rather than truncated when it
|
||||
// is over: a truncated JSON object is not a JSON object, and a console that
|
||||
// rendered half of one would be a second bug on top of the first.
|
||||
const MAX_DETAIL_BYTES = 4096
|
||||
|
||||
/**
|
||||
* A module's own account of what a successful step actually did.
|
||||
*
|
||||
* Optional, module-opaque, and **never interpreted by core** — it is carried to
|
||||
* the run log and rendered, and nothing here or in the runner reads a key out of
|
||||
* it. That is the whole contract: a module knows things about its own verb that
|
||||
* core cannot compute and has no other way to say. `uo.item.grant` is the case
|
||||
* that forced it — a grant reaches the players a run's ledger holds, and *which
|
||||
* of them missed out* is knowable only to the module and reported nowhere else,
|
||||
* so an operator saw a step marked `done` and never learned four of twelve got
|
||||
* nothing.
|
||||
*
|
||||
* **Anything wrong with it is dropped and logged, never a failure.** A step that
|
||||
* did what it was asked must not be re-run because its module's commentary was
|
||||
* malformed — that would be a world write repeated for a log line. Same posture
|
||||
* `participants` takes, and for the same reason.
|
||||
*/
|
||||
function safeDetail(detail, actionId) {
|
||||
if (detail === undefined || detail === null) return null
|
||||
|
||||
// Objects only. The column is JSON and the console renders keys, so a bare
|
||||
// string or a number has nothing to render under — and core inventing a key to
|
||||
// put it beneath would be core interpreting it after all.
|
||||
if (typeof detail !== 'object' || Array.isArray(detail)) {
|
||||
log.warn('action detail is not an object', { action: actionId, type: typeof detail })
|
||||
return null
|
||||
}
|
||||
|
||||
let encoded
|
||||
try {
|
||||
encoded = JSON.stringify(detail)
|
||||
} catch (err) {
|
||||
// A circular reference, or a `toJSON` that throws. Reaching the runner would
|
||||
// make the INSERT throw instead, inside the one write that is documented
|
||||
// never to.
|
||||
log.warn('action detail could not be serialised', { action: actionId, message: err.message })
|
||||
return null
|
||||
}
|
||||
if (encoded === undefined) {
|
||||
log.warn('action detail serialised to nothing', { action: actionId })
|
||||
return null
|
||||
}
|
||||
if (Buffer.byteLength(encoded, 'utf8') > MAX_DETAIL_BYTES) {
|
||||
log.warn('action detail is too large', {
|
||||
action: actionId,
|
||||
bytes: Buffer.byteLength(encoded, 'utf8'),
|
||||
max: MAX_DETAIL_BYTES,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// Re-parsed rather than passed through, so what the runner writes is a plain
|
||||
// JSON value with no getters, no prototype and no live reference into whatever
|
||||
// the module still holds.
|
||||
return JSON.parse(encoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn()` under a deadline.
|
||||
*
|
||||
* The loser of the race is not cancelled — JavaScript has no such thing, and a
|
||||
* `perform()` still awaiting a socket keeps awaiting it. What the deadline buys
|
||||
* is that the RUNNER stops waiting, which is the half that matters: the step is
|
||||
* classified, the claim is released, and the tick moves on. A late answer from
|
||||
* the abandoned call lands on a step that has already been written, and the
|
||||
* idempotency key is what makes the retry that follows safe on the game side.
|
||||
*/
|
||||
function withDeadline(fn, ms, actionId) {
|
||||
let timer = null
|
||||
const deadline = new Promise((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve({ __timedOut: true, error: `${actionId} exceeded its ${ms}ms budget` }),
|
||||
ms,
|
||||
)
|
||||
if (timer.unref) timer.unref()
|
||||
})
|
||||
return Promise.race([Promise.resolve().then(fn), deadline]).finally(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a raw `perform()` answer into
|
||||
* `{ outcome, error?, holdSeconds?, resources?, participants? }`.
|
||||
*
|
||||
* Exported and pure, so the classification rules are testable without a registry,
|
||||
* a database or a clock — which matters because they are the rules that decide
|
||||
* whether a world change is recorded as having happened.
|
||||
*/
|
||||
function classify(result, actionId) {
|
||||
if (result && result.__timedOut) {
|
||||
// Transient by default: a timeout says nothing about whether the action ran.
|
||||
// That ambiguity is exactly what the idempotency key exists to resolve, and
|
||||
// resolving it on the game side is Phase 11's protocol work — until then a
|
||||
// retry is the honest choice and the risk class decides what happens when the
|
||||
// retries run out.
|
||||
return { outcome: 'retry', error: result.error }
|
||||
}
|
||||
if (result === null || typeof result !== 'object' || Array.isArray(result)) {
|
||||
return { outcome: 'retry', error: `${actionId} answered with no envelope` }
|
||||
}
|
||||
if (result.ok !== true) {
|
||||
// `retry` must be opted into. An action that means "this will never work"
|
||||
// says `retry: false`, and an envelope that forgot to say anything gets the
|
||||
// benefit of the doubt on the transient question but not on the success one.
|
||||
const retry = result.retry !== false
|
||||
return {
|
||||
outcome: retry ? 'retry' : 'terminal',
|
||||
error: result.error ? String(result.error) : `${actionId} refused`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── The two success shapes that are not "finished" ──
|
||||
//
|
||||
// Both were settled by the org lead on 2026-09-02, and both are envelope
|
||||
// members rather than special cases keyed on an action id, so that the runner
|
||||
// never names a verb. `core.cue` and `core.wait` reach them through the same
|
||||
// door Phase 7 opens to a module's own long-running action.
|
||||
if (result.await === 'human') {
|
||||
return {
|
||||
outcome: 'parked',
|
||||
error: null,
|
||||
resources: result.resources || [],
|
||||
participants: result.participants || [],
|
||||
detail: safeDetail(result.detail, actionId),
|
||||
}
|
||||
}
|
||||
|
||||
let holdSeconds = 0
|
||||
if (result.holdFor !== undefined && result.holdFor !== null) {
|
||||
const n = Number(result.holdFor)
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
return { outcome: 'terminal', error: `${actionId} answered a bad holdFor "${result.holdFor}"` }
|
||||
}
|
||||
holdSeconds = Math.min(Math.floor(n), MAX_HOLD_SECONDS)
|
||||
}
|
||||
|
||||
// `participants` rides beside `resources` and on the same two success shapes
|
||||
// (Phase 10). It is carried rather than interpreted here: what a member key
|
||||
// means is the module's business, and this file's whole job is to know
|
||||
// nothing about the verb it just called.
|
||||
return {
|
||||
outcome: 'done',
|
||||
error: null,
|
||||
holdSeconds,
|
||||
resources: result.resources || [],
|
||||
participants: result.participants || [],
|
||||
// On the same two success shapes as `resources` and `participants`, and for
|
||||
// the same reason: `await: 'human'` is a success, and a cue's confirm
|
||||
// finishes the step without a second dispatch, so this is the only moment
|
||||
// its module could ever have said anything.
|
||||
detail: safeDetail(result.detail, actionId),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one step. Never throws.
|
||||
*
|
||||
* `verify` rides through to `perform()` unchanged (§I's dry run, Phase 6's
|
||||
* route): `verify === true` means validate and report, change nothing. It is
|
||||
* passed from here rather than being a separate code path so that the dry run
|
||||
* exercises the real dispatcher — a dry run down a second path is a dry run of
|
||||
* the second path.
|
||||
*/
|
||||
async function dispatchStep(step, { run, actor = null, verify = false } = {}) {
|
||||
const action = registries.eventAction(step.action_id)
|
||||
if (!action) {
|
||||
// §L, verbatim: "a step naming one fails terminal with the module named, and
|
||||
// the run degrades rather than claiming success. Never a silent skip." The
|
||||
// module was uninstalled or failed to boot between publish and now — publish
|
||||
// refuses a dormant step, so this cannot be an authoring mistake.
|
||||
return { outcome: 'terminal', error: `no module registers "${step.action_id}"`, dormant: true }
|
||||
}
|
||||
|
||||
const envelope = {
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
idempotencyKey: step.idempotency_key,
|
||||
scope: run.scope || '',
|
||||
params: step.params || {},
|
||||
actor,
|
||||
verify: Boolean(verify),
|
||||
}
|
||||
|
||||
let raw
|
||||
try {
|
||||
raw = await withDeadline(() => action.perform(envelope), action.budgetMs, action.id)
|
||||
} catch (err) {
|
||||
// A module should not throw, and if one does it is a transient failure rather
|
||||
// than a crashed tick — announceWorker's posture with its legs, and the
|
||||
// reason one bad module cannot stop every other run on the deployment.
|
||||
log.warn('event action threw', { action: action.id, run: run.id, step: step.id, message: err.message })
|
||||
return { outcome: 'retry', error: err.message }
|
||||
}
|
||||
|
||||
const classification = classify(raw, action.id)
|
||||
if (step.action_version && action.version !== step.action_version) {
|
||||
// Not a refusal: the step was authored against an older declaration and the
|
||||
// module has moved on. The editor is where that becomes a warning (§F); here
|
||||
// it is recorded, so a run that behaved oddly can be explained afterwards by
|
||||
// reading the log rather than by guessing.
|
||||
classification.actionVersionDrift = { authored: step.action_version, registered: action.version }
|
||||
}
|
||||
return classification
|
||||
}
|
||||
|
||||
module.exports = { dispatchStep, classify, withDeadline, safeDetail, OUTCOMES, MAX_HOLD_SECONDS, MAX_DETAIL_BYTES }
|
||||
244
server/src/events/gates.js
Normal file
244
server/src/events/gates.js
Normal file
@@ -0,0 +1,244 @@
|
||||
// ── Phase advance gates: the emit-path observer, and the words for the panel ─
|
||||
//
|
||||
// EVENTS.md §E and § Observability, and Phase 5 of EVENTS_PLAN.md. Two things
|
||||
// live here because they are two halves of one claim — that an operator can
|
||||
// answer *"why didn't phase 3 start?"* without reading a server log:
|
||||
//
|
||||
// `observe(event)` — the trigger stream's other subscriber. Beside
|
||||
// `engine.dispatch`, on the same seam, with the same
|
||||
// fire-and-forget posture.
|
||||
// `describe(gate)` — the same gate rendered in the condition builder's own
|
||||
// words, which is what the diagnosis panel shows.
|
||||
//
|
||||
// **Why the counting happens here and not on the runner's tick.** A gate that
|
||||
// waits for three boss spawns is counting things that happen *between* ticks. A
|
||||
// poller cannot count them: fifteen seconds after the third spawn there is
|
||||
// nothing left to observe, and a tally kept in a process's memory is a tally a
|
||||
// restart silently sets back to zero — with the phase then waiting for three
|
||||
// more of something that already happened. So the emit path writes, and the tick
|
||||
// reads. That division is the whole design of this file.
|
||||
//
|
||||
// **The cost of that, and its bound.** Every game event of every trigger some
|
||||
// run is waiting on costs one indexed lookup, and the common answer is zero
|
||||
// rows. Only when a gate is open does anything else happen, and then it is one
|
||||
// UPDATE per open gate — bounded by how many runs can be waiting on one trigger
|
||||
// at once, which is bounded by how many runs exist.
|
||||
//
|
||||
// **The words are rendered here, on the server, not in the client.** The panel's
|
||||
// entire value is that it reads the way the condition builder reads — `gte` as
|
||||
// *"is at least"*, `present` as *"is present"* — and those labels are defined in
|
||||
// `engagement/conditions.js`. A renderer in the browser would be a second
|
||||
// implementation of a grammar the server owns, and the first operator to meet a
|
||||
// clause it spelled differently would be the operator diagnosing a stalled run
|
||||
// at two in the morning.
|
||||
|
||||
const gatesDb = require('../model/events/eventPhaseGates.db')
|
||||
const runsDb = require('../model/events/eventRuns.db')
|
||||
const logDb = require('../model/events/eventRunLog.db')
|
||||
const conditions = require('../engagement/conditions')
|
||||
const log = require('../utils/logger')('event-gates')
|
||||
|
||||
// How long an `on` gate may wait before the run is called `stalled` (§E's third
|
||||
// health value, which nothing had ever written before this phase). It is a
|
||||
// VISIBILITY threshold and not a timeout: nothing advances, nothing fails, and
|
||||
// the operator decides. An hour is long enough that a champion spawn nobody has
|
||||
// killed yet is not an alarm, and short enough that a run which will wait for
|
||||
// ever is on the screen inside one shift.
|
||||
//
|
||||
// It does not apply to an `after` gate. A phase waiting out six hours it was
|
||||
// authored to wait is not stalled, it is working, and health that said otherwise
|
||||
// would train an operator to ignore it.
|
||||
const STALL_MS = Number(process.env.EVENT_PHASE_STALL_MS) || 60 * 60 * 1000
|
||||
|
||||
/** Every variable a condition tree names, in the order it names them. */
|
||||
function variablesIn(node, out = []) {
|
||||
if (!node || typeof node !== 'object') return out
|
||||
if (Array.isArray(node.nodes)) {
|
||||
node.nodes.forEach((child) => variablesIn(child, out))
|
||||
return out
|
||||
}
|
||||
if (node.variable && !out.includes(node.variable)) out.push(node.variable)
|
||||
return out
|
||||
}
|
||||
|
||||
const quote = (v) => (typeof v === 'string' ? `"${v}"` : String(v))
|
||||
|
||||
/**
|
||||
* One condition tree as a sentence, using the grammar's own operator labels.
|
||||
*
|
||||
* `null` answers null rather than "always": the caller renders *"on any
|
||||
* `uo.champ.boss_up`"* for a gate with no predicate, and a phrase saying
|
||||
* "everything is true" would be a clause an operator has to read past.
|
||||
*/
|
||||
function phrase(node) {
|
||||
if (!node || typeof node !== 'object') return null
|
||||
if (node.op === 'not') {
|
||||
const inner = phrase((node.nodes || [])[0])
|
||||
return inner ? `not (${inner})` : null
|
||||
}
|
||||
if (node.op === 'and' || node.op === 'or') {
|
||||
const parts = (node.nodes || []).map(phrase).filter(Boolean)
|
||||
if (!parts.length) return null
|
||||
// Parenthesised only where it changes the reading. A flat `and` of three
|
||||
// comparisons is a sentence; the same three wrapped in brackets is a
|
||||
// diagnosis an operator has to parse rather than read.
|
||||
const joined = parts.map((p) => (p.includes(' or ') || p.includes(' and ') ? `(${p})` : p))
|
||||
return joined.join(node.op === 'and' ? ' and ' : ' or ')
|
||||
}
|
||||
const operator = conditions.OPERATORS[node.cmp]
|
||||
if (!operator) return null
|
||||
if (operator.arity === 0) return `${node.variable} ${operator.label}`
|
||||
if (operator.arity === 'list') return `${node.variable} ${operator.label} ${node.value.map(quote).join(', ')}`
|
||||
return `${node.variable} ${operator.label} ${quote(node.value)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape the run console renders — a gate as an operator reads it.
|
||||
*
|
||||
* Derived rather than stored, every field of it. `stalled` in particular is a
|
||||
* comparison against the clock and not a column: a threshold that had been
|
||||
* written into a row at entry could not be changed by an operator raising
|
||||
* `EVENT_PHASE_STALL_MS`, and one written at the moment of stalling would be a
|
||||
* fourth writer on a row two already share.
|
||||
*/
|
||||
function describe(gate, now = new Date()) {
|
||||
if (!gate) return null
|
||||
const since = new Date(gate.entered_at)
|
||||
const satisfied = Boolean(gate.satisfied_at)
|
||||
// **A satisfied gate's clock stops when it was satisfied**, not at read time.
|
||||
// Live it answers "how long has this phase been waiting"; afterwards it
|
||||
// answers "how long did it wait", and those are the same number only while it
|
||||
// is still waiting. The walk caught it disagreeing with `phase.advanced`'s
|
||||
// own `waitedSeconds` by the age of the screen — 139s beside a logged 121.
|
||||
const until = satisfied ? new Date(gate.satisfied_at) : now
|
||||
const elapsedSeconds = Math.max(0, Math.round((until.getTime() - since.getTime()) / 1000))
|
||||
|
||||
return {
|
||||
phase: gate.phase,
|
||||
kind: gate.kind,
|
||||
satisfied,
|
||||
satisfiedAt: gate.satisfied_at || null,
|
||||
satisfiedBy: gate.satisfied_by || null,
|
||||
since: gate.entered_at,
|
||||
elapsedSeconds,
|
||||
// An `after` gate is never stalled; an `on` gate is stalled once it has
|
||||
// waited past the threshold and not before.
|
||||
stalled: !satisfied && gate.kind === 'on' && now.getTime() - since.getTime() >= STALL_MS,
|
||||
...(gate.kind === 'after'
|
||||
? { after: gate.after_seconds, dueAt: gate.due_at }
|
||||
: {
|
||||
waitingOn: gate.trigger_id,
|
||||
where: phrase(gate.conditions),
|
||||
seen: gate.tally,
|
||||
needed: gate.needed,
|
||||
lastEvent: gate.last_event,
|
||||
lastEventAt: gate.last_event_at || null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The trigger stream's second subscriber.
|
||||
*
|
||||
* Called from `engagementEmit.emit` beside `engine.dispatch` and, like it, never
|
||||
* awaited and never allowed to reject. An emit is a module saying something
|
||||
* happened in the game; whether some event run cared is core's business, and a
|
||||
* failure of core's must not become the module's control flow.
|
||||
*
|
||||
* **Every firing is logged, matched or not** (§ Observability: "trigger
|
||||
* evaluations that did and did not satisfy a condition"). The near miss is the
|
||||
* more valuable of the two on the night: *"the boss did spawn, in Britain"* and
|
||||
* *"no boss has spawned"* are different answers, and without this line they look
|
||||
* identical on the screen.
|
||||
*/
|
||||
async function observe(event) {
|
||||
const summary = { gates: 0, counted: 0, satisfied: 0 }
|
||||
try {
|
||||
const open = await gatesDb.openForTrigger(event.triggerId)
|
||||
summary.gates = open.length
|
||||
if (!open.length) return summary
|
||||
|
||||
const now = new Date()
|
||||
for (const gate of open) {
|
||||
const matched = conditions.evaluate(gate.conditions, event.data || {})
|
||||
|
||||
// ONLY the variables the condition names, never the payload. This row is
|
||||
// read back onto an admin screen, and a copy of a whole game event's data
|
||||
// is a second copy of exactly the content `engagement_sends` is careful
|
||||
// not to keep. The named variables are also the useful ones: they are the
|
||||
// reason it did or did not count.
|
||||
const named = variablesIn(gate.conditions)
|
||||
const lastEvent = {
|
||||
trigger: event.triggerId,
|
||||
at: event.occurredAt,
|
||||
subject: event.subject ?? null,
|
||||
matched,
|
||||
variables: Object.fromEntries(
|
||||
named.filter((n) => event.data && n in event.data).map((n) => [n, event.data[n]]),
|
||||
),
|
||||
}
|
||||
|
||||
const result = matched
|
||||
? await gatesDb.count(gate.id, { lastEvent, now })
|
||||
: { counted: false, satisfied: false, tally: gate.tally, near: await gatesDb.noteNearMiss(gate.id, { lastEvent, now }) }
|
||||
|
||||
if (matched && result.counted) summary.counted += 1
|
||||
if (result.satisfied) summary.satisfied += 1
|
||||
|
||||
await logDb.write({
|
||||
runId: gate.run_id,
|
||||
kind: 'condition.evaluated',
|
||||
phase: gate.phase,
|
||||
detail: {
|
||||
trigger: event.triggerId,
|
||||
matched,
|
||||
// The tally the DATABASE holds after the write, not the one this
|
||||
// process predicted — two emits arriving together each read the same
|
||||
// stale number, and only one of them is right about what it became.
|
||||
seen: result.tally ?? gate.tally,
|
||||
needed: gate.needed,
|
||||
satisfied: result.satisfied,
|
||||
variables: lastEvent.variables,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (summary.counted || summary.satisfied) {
|
||||
log.info('phase gate advanced', { trigger: event.triggerId, ...summary })
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('gate observation failed', { trigger: event.triggerId, message: err.message })
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this phase's gate open — and if it is not, why not?
|
||||
*
|
||||
* The runner's question, and the one place an `after` gate is closed: its
|
||||
* deadline passing is not an event anything emits, so the tick that finds it
|
||||
* past `due_at` is what records it. Doing that here rather than in the runner
|
||||
* keeps `satisfied_by` a fact one file writes.
|
||||
*
|
||||
* Answers `{ open, gate }`. `open: true` with a null gate is a phase with no
|
||||
* advance condition at all — every phase before this one, and most after it.
|
||||
*/
|
||||
async function check(runId, phase, now = new Date()) {
|
||||
const gate = await gatesDb.forPhase(runId, phase)
|
||||
if (!gate) return { open: true, gate: null }
|
||||
if (gate.satisfied_at) return { open: true, gate }
|
||||
|
||||
if (gate.kind === 'after' && gate.due_at && new Date(gate.due_at) <= now) {
|
||||
if (await gatesDb.satisfy(gate.id, 'elapsed', { now })) {
|
||||
return { open: true, gate: await gatesDb.byId(gate.id) }
|
||||
}
|
||||
// Somebody else closed it between the read and the write — a force, or the
|
||||
// tick that overran into this one. Either way it is open, and whichever
|
||||
// reason won is the one in the row.
|
||||
return { open: true, gate: await gatesDb.byId(gate.id) }
|
||||
}
|
||||
|
||||
return { open: false, gate }
|
||||
}
|
||||
|
||||
module.exports = { observe, check, describe, phrase, variablesIn, STALL_MS }
|
||||
224
server/src/events/ledger.js
Normal file
224
server/src/events/ledger.js
Normal file
@@ -0,0 +1,224 @@
|
||||
// ── Recording what a run changed in the world ──────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §L, and Phase 8 of EVENTS_PLAN.md. The write half of the
|
||||
// resource ledger; `events/cleanup.js` is the read-and-undo half.
|
||||
//
|
||||
// **Rule 1 is the whole reason this file is not two lines inside `drainStep`.**
|
||||
// A resource is recorded BEFORE it is confirmed. The obstacle is that a spawn's
|
||||
// serial does not exist until the module answers, so there is nothing to write a
|
||||
// row about yet — which is why what goes in before the dispatch is a PLACEHOLDER
|
||||
// keyed by the step's idempotency key rather than by the object:
|
||||
//
|
||||
// pre-dispatch INSERT pending { kind: '@step', ref: <idempotency key> }
|
||||
// answer INSERT confirmed { kind: 'creature', ref: '0x40001234' } × n
|
||||
// resolve the placeholder
|
||||
// ack lost the placeholder is still `pending`
|
||||
// cleanup revert({ idempotencyKey, resources: [] })
|
||||
//
|
||||
// That last line is why §F's `revert({ runId, resources, idempotencyKey })` takes
|
||||
// the key at all. A module that half-ran and never answered is reachable by its
|
||||
// key and by nothing else, and Phase 11's plugin-side key ledger is what makes
|
||||
// answering it exact. Until then the contract is still honest, because §L
|
||||
// requires reverting something that does not exist to be a SUCCESS.
|
||||
//
|
||||
// **Recording is idempotent, and the database is what makes it so.** A retry
|
||||
// re-dispatches the same idempotency key, and a module that answers with the same
|
||||
// resources twice must not produce two rows. `uq_evres_target` refuses the second
|
||||
// insert, and this file reads that refusal as "already recorded" rather than as an
|
||||
// error — the same posture `materialisePhase`'s INSERT IGNORE takes.
|
||||
//
|
||||
// **A lease does not use the placeholder.** Its target is knowable before the
|
||||
// dispatch — it is the lease id the step names — so `core.lease` reserves the
|
||||
// real row first, which is both a stronger form of rule 1 and the only place the
|
||||
// two-events-one-target refusal can happen before the world has been written to.
|
||||
|
||||
const resourcesDb = require('../model/events/eventRunResources.db')
|
||||
const runsDb = require('../model/events/eventRuns.db')
|
||||
const registries = require('../modules/registries')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// Which reversible classes get a pre-dispatch placeholder. `none` is gone once
|
||||
// done and `self` undoes itself, so neither has anything core could come back
|
||||
// for; `override` reserves its own target instead (see the header). That leaves
|
||||
// `ledger` — the class that declares `revert()`, which is exactly the class whose
|
||||
// refs core cannot know until the module speaks.
|
||||
const PLACEHOLDER_CLASSES = ['ledger']
|
||||
|
||||
// A resource `kind` may be anything a module likes except core's own reserved
|
||||
// one. Bounded to the column, and refused rather than truncated: a truncated ref
|
||||
// is a cleanup call naming the wrong object.
|
||||
const MAX_KIND = 64
|
||||
const MAX_REF = 190
|
||||
const MAX_MEMBER_KEY = 190
|
||||
|
||||
/** Does this action produce anything core will have to come back for? */
|
||||
const ledgers = (action) => action && (action.reversible === 'ledger' || action.reversible === 'override')
|
||||
|
||||
/**
|
||||
* Turn one entry of a module's `resources` array into a row, or say why not.
|
||||
*
|
||||
* Every failure here is the module's mistake rather than the world's, so none of
|
||||
* them is a retry: a badly shaped resource will be just as badly shaped on the
|
||||
* second attempt. They are logged and dropped, and the step still counts as done
|
||||
* — because it IS done; something happened in the world, and refusing to record
|
||||
* it would be the one outcome worse than recording it imperfectly.
|
||||
*/
|
||||
function normalise(entry, actionId) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return { ok: false, reason: `${actionId} reported a resource that is not an object` }
|
||||
}
|
||||
const kind = String(entry.kind || '')
|
||||
const ref = String(entry.ref === undefined || entry.ref === null ? '' : entry.ref)
|
||||
if (!kind || kind.length > MAX_KIND) {
|
||||
return { ok: false, reason: `${actionId} reported a resource with a bad kind "${entry.kind}"` }
|
||||
}
|
||||
if (kind === resourcesDb.STEP_KIND) {
|
||||
// Core's own. A module that could write one would be a module that could make
|
||||
// its own step's placeholder look resolved.
|
||||
return { ok: false, reason: `${actionId} reported a resource of the reserved kind "${kind}"` }
|
||||
}
|
||||
if (!ref || ref.length > MAX_REF) {
|
||||
return { ok: false, reason: `${actionId} reported a resource with a bad ref "${entry.ref}"` }
|
||||
}
|
||||
const memberKey = entry.memberKey === undefined || entry.memberKey === null ? null : String(entry.memberKey)
|
||||
if (memberKey !== null && memberKey.length > MAX_MEMBER_KEY) {
|
||||
return { ok: false, reason: `${actionId} reported a resource with an over-long memberKey` }
|
||||
}
|
||||
|
||||
let leaseUntil = null
|
||||
if (entry.until !== undefined && entry.until !== null) {
|
||||
const at = new Date(entry.until)
|
||||
if (Number.isNaN(at.getTime())) {
|
||||
return { ok: false, reason: `${actionId} reported a resource with a bad until "${entry.until}"` }
|
||||
}
|
||||
leaseUntil = at
|
||||
}
|
||||
|
||||
// A borrowed value must name a lease core knows how to give back. Core restores
|
||||
// an `override` through the lease registry — that is the split §F draws — so a
|
||||
// ref naming nothing registered is a resource core would be recording with no
|
||||
// way to undo it, which is the promise rule 2 exists to stop core making.
|
||||
// Through the ref parser: a targeted lease's ref carries its target after a
|
||||
// `#` (Phase 12b), and matching the whole string against the registry would
|
||||
// reject a lease that IS registered.
|
||||
if (kind === 'override' && !registries.eventLeaseForRef(ref)) {
|
||||
return { ok: false, reason: `${actionId} reported a lease "${ref}" no module registers` }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
row: {
|
||||
kind,
|
||||
ref,
|
||||
payload: entry.payload === undefined ? null : entry.payload,
|
||||
leaseUntil,
|
||||
memberKey,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the pre-dispatch placeholder for a step that is about to change the
|
||||
* world. Answers the row id, or null when this action ledgers nothing.
|
||||
*
|
||||
* **A duplicate is not a failure.** A retry re-uses its step's idempotency key, so
|
||||
* the second attempt's placeholder collides with the first's — and finding it
|
||||
* already there is the correct answer, not an error. The existing row is reused.
|
||||
*/
|
||||
async function reserveStep(run, step, action) {
|
||||
if (!ledgers(action) || !PLACEHOLDER_CLASSES.includes(action.reversible)) return null
|
||||
|
||||
const owner = action.owner || 'core'
|
||||
const reserved = await resourcesDb.reserve({
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
owner,
|
||||
kind: resourcesDb.STEP_KIND,
|
||||
ref: step.idempotency_key,
|
||||
payload: { action: action.id, phase: step.phase, seq: step.seq },
|
||||
})
|
||||
if (reserved.ok) {
|
||||
await markRunDirty(run.id)
|
||||
return reserved.id
|
||||
}
|
||||
// The only way a '@step' row collides is with this step's own earlier attempt,
|
||||
// because an idempotency key is minted once per step and never varies by
|
||||
// attempt (§E). Reuse it.
|
||||
const existing = await resourcesDb.findByTarget(owner, resourcesDb.STEP_KIND, step.idempotency_key)
|
||||
return existing ? existing.id : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what a module said it made, and close out the placeholder.
|
||||
*
|
||||
* Answers `{ recorded, rejected }` — how many rows went in, and the reasons any
|
||||
* entry was dropped. Never throws: a step that changed the world has changed it,
|
||||
* and a ledger that threw would turn a bookkeeping problem into a failed step and
|
||||
* then into a retry of a world write that already happened.
|
||||
*/
|
||||
async function recordAnswer({ run, step, action, placeholderId, resources }) {
|
||||
const out = { recorded: 0, rejected: [] }
|
||||
if (!ledgers(action)) return out
|
||||
|
||||
const owner = action.owner || 'core'
|
||||
const list = Array.isArray(resources) ? resources : []
|
||||
|
||||
for (const entry of list) {
|
||||
const parsed = normalise(entry, action.id)
|
||||
if (!parsed.ok) {
|
||||
out.rejected.push(parsed.reason)
|
||||
log.warn('event resource rejected', { run: run.id, step: step.id, reason: parsed.reason })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const reserved = await resourcesDb.reserve({
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
owner,
|
||||
kind: parsed.row.kind,
|
||||
ref: parsed.row.ref,
|
||||
payload: parsed.row.payload,
|
||||
leaseUntil: parsed.row.leaseUntil,
|
||||
memberKey: parsed.row.memberKey,
|
||||
})
|
||||
if (!reserved.ok) {
|
||||
// Already ledgered — by this step's own earlier attempt, or (a module bug
|
||||
// rather than a race) by another run that still holds the same target.
|
||||
// Either way there is a live row for it and a second would be the double
|
||||
// cleanup the unique key exists to prevent.
|
||||
if (reserved.holder && reserved.holder.run_id !== run.id) {
|
||||
out.rejected.push(`${parsed.row.kind} "${parsed.row.ref}" is already held by run ${reserved.holder.run_id}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
await resourcesDb.confirm(reserved.id)
|
||||
out.recorded += 1
|
||||
} catch (err) {
|
||||
// Bookkeeping must not become the step's control flow.
|
||||
out.rejected.push(err.message)
|
||||
log.error('event resource insert failed', { run: run.id, step: step.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
if (out.recorded > 0) await markRunDirty(run.id)
|
||||
|
||||
// The placeholder's job is over the moment the real rows exist. It is resolved
|
||||
// even when the module reported nothing at all — an action that ledgers and
|
||||
// then answers `ok` with an empty list is saying "I made nothing", and holding
|
||||
// its placeholder open would make cleanup call `revert()` for a step that has
|
||||
// nothing to give back on every terminal path for ever.
|
||||
if (placeholderId) await resourcesDb.resolvePlaceholder(placeholderId)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* There is now something to clean up. Idempotent and guarded, so it can never
|
||||
* walk a run back from `complete` or `incomplete` to `pending` — only a human's
|
||||
* cleanup does that, and it does it deliberately.
|
||||
*/
|
||||
async function markRunDirty(runId) {
|
||||
await runsDb.setCleanupStatus(runId, 'pending', ['not_required'])
|
||||
}
|
||||
|
||||
module.exports = { ledgers, normalise, reserveStep, recordAnswer, markRunDirty, PLACEHOLDER_CLASSES }
|
||||
162
server/src/events/participants.js
Normal file
162
server/src/events/participants.js
Normal file
@@ -0,0 +1,162 @@
|
||||
// ── Recording who took part ────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §J, and Phase 10 of EVENTS_PLAN.md. The twin of
|
||||
// `events/ledger.js`: that file records what a run did to the world, this one
|
||||
// records who it happened to.
|
||||
//
|
||||
// **Participants ride the SAME envelope resources do** (org lead, 2026-09-04). An
|
||||
// action answers `{ ok: true, participants: [...] }` and the runner writes them
|
||||
// beside the resources, on the same two success shapes, through the same
|
||||
// classify → record path. There is no `ctx.events.participants` API and no route:
|
||||
// a second write path into a run core is mid-tick on would be a second thing that
|
||||
// can race the claim, for a caller that does not exist until a module can source
|
||||
// the data at all (Phase 11's plugin-side participation ledger; Phase 12's
|
||||
// collect step is the first consumer).
|
||||
//
|
||||
// **Core cannot source a participant and does not try.** §J: `member_key` is
|
||||
// module-opaque, `user_id` is filled in by whoever knows the link table. For
|
||||
// module-uo that is `shard_links`; for another game it is something else, and a
|
||||
// core that guessed would be one game's identity model compiled into core. So a
|
||||
// module reports both halves, or reports the key alone and the row stays
|
||||
// anonymous — which is the honest record of an unlinked player who turned up.
|
||||
//
|
||||
// **A bad entry is dropped, never a retry.** Exactly `ledger.normalise`'s
|
||||
// posture and for exactly its reason: a malformed participant will be just as
|
||||
// malformed on the second attempt, and failing the step would re-dispatch a
|
||||
// world write that already happened. Rejections are logged and surfaced on the
|
||||
// run log so an author can see what their module sent.
|
||||
|
||||
const participantsDb = require('../model/events/eventRunParticipants.db')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// Bounded to the column, and refused rather than truncated: a truncated member
|
||||
// key is a different participant, and under `uq_evpart_member` it would silently
|
||||
// merge two people into one row.
|
||||
const MAX_MEMBER_KEY = 190
|
||||
|
||||
// The most one step may report. A run's participants are people, and a step
|
||||
// answering with a hundred thousand of them is a module bug rather than a very
|
||||
// popular event — one that would otherwise spend a tick's whole budget on
|
||||
// inserts while holding the step's claim. `MAX_AUDIENCE` in the engagement
|
||||
// engine is 5000 for the same class of reason and this matches it deliberately:
|
||||
// the two bound the same thing, a list of users one call may assert.
|
||||
const MAX_PER_STEP = 5000
|
||||
|
||||
/**
|
||||
* Turn one entry of a module's `participants` array into a row, or say why not.
|
||||
*/
|
||||
function normalise(entry, actionId) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return { ok: false, reason: `${actionId} reported a participant that is not an object` }
|
||||
}
|
||||
|
||||
const memberKey = String(entry.memberKey === undefined || entry.memberKey === null ? '' : entry.memberKey)
|
||||
if (!memberKey || memberKey.length > MAX_MEMBER_KEY) {
|
||||
return { ok: false, reason: `${actionId} reported a participant with a bad memberKey "${entry.memberKey}"` }
|
||||
}
|
||||
|
||||
// Optional, and checked rather than coerced. A `userId` is a foreign key into
|
||||
// `users`, so a module that passed a character serial here would either fail
|
||||
// the insert or — worse, if the number happened to be a real user — attribute
|
||||
// somebody else's attendance to a stranger.
|
||||
let userId = null
|
||||
if (entry.userId !== undefined && entry.userId !== null) {
|
||||
if (!Number.isInteger(entry.userId) || entry.userId < 1) {
|
||||
return { ok: false, reason: `${actionId} reported a participant with a bad userId "${entry.userId}"` }
|
||||
}
|
||||
userId = entry.userId
|
||||
}
|
||||
|
||||
// `score` is optional and defaults to 0 — a run that only records attendance
|
||||
// is a run where everybody scored nothing, which is a true statement and a
|
||||
// renderable table. Non-finite is refused rather than coerced: `NaN` written
|
||||
// into a DECIMAL would either throw at the driver or land as 0, and a 0 that
|
||||
// meant "the module sent nonsense" is indistinguishable from an honest zero.
|
||||
let score = 0
|
||||
if (entry.score !== undefined && entry.score !== null) {
|
||||
const n = Number(entry.score)
|
||||
if (!Number.isFinite(n)) {
|
||||
return { ok: false, reason: `${actionId} reported a participant with a bad score "${entry.score}"` }
|
||||
}
|
||||
score = n
|
||||
}
|
||||
|
||||
let joinedAt = null
|
||||
if (entry.joinedAt !== undefined && entry.joinedAt !== null) {
|
||||
const at = entry.joinedAt instanceof Date ? entry.joinedAt : new Date(entry.joinedAt)
|
||||
if (Number.isNaN(at.getTime())) {
|
||||
return { ok: false, reason: `${actionId} reported a participant with a bad joinedAt "${entry.joinedAt}"` }
|
||||
}
|
||||
joinedAt = at
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
row: {
|
||||
memberKey,
|
||||
userId,
|
||||
score,
|
||||
// Opaque, exactly like a resource's payload: core stores it and never
|
||||
// reads it. Anything but an object is dropped rather than refused —
|
||||
// `meta` is decoration on a row whose identity is already valid, and
|
||||
// losing an event's whole attendance over a stray string would be the
|
||||
// wrong trade.
|
||||
meta: entry.meta && typeof entry.meta === 'object' && !Array.isArray(entry.meta) ? entry.meta : null,
|
||||
joinedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what a module said about who took part.
|
||||
*
|
||||
* Answers `{ recorded, rejected }`. Never throws, for `recordAnswer`'s reason: a
|
||||
* step that changed the world has changed it, and a bookkeeping failure must not
|
||||
* become a retry of a world write.
|
||||
*/
|
||||
async function recordAnswer({ run, step, action, participants }) {
|
||||
const out = { recorded: 0, rejected: [] }
|
||||
const list = Array.isArray(participants) ? participants : []
|
||||
if (!list.length) return out
|
||||
|
||||
if (list.length > MAX_PER_STEP) {
|
||||
// Refused whole rather than truncated. Half a leaderboard silently cut at
|
||||
// five thousand is worse than none: the table would look complete and be
|
||||
// wrong, and nothing downstream could tell.
|
||||
const reason = `${action.id} reported ${list.length} participants, more than the ${MAX_PER_STEP} one step may`
|
||||
log.warn('event participants refused', { run: run.id, step: step.id, reason })
|
||||
return { recorded: 0, rejected: [reason] }
|
||||
}
|
||||
|
||||
// **Deduplicated in memory before the write.** One step reporting the same
|
||||
// member twice is a module bug, and letting both reach the upsert would make
|
||||
// the LAST one win silently. Refusing the step would be worse — the other
|
||||
// ninety-nine participants are fine — so the first wins and the duplicate is
|
||||
// named, which is a thing an author can act on.
|
||||
const seen = new Set()
|
||||
|
||||
for (const entry of list) {
|
||||
const parsed = normalise(entry, action.id)
|
||||
if (!parsed.ok) {
|
||||
out.rejected.push(parsed.reason)
|
||||
log.warn('event participant rejected', { run: run.id, step: step.id, reason: parsed.reason })
|
||||
continue
|
||||
}
|
||||
if (seen.has(parsed.row.memberKey)) {
|
||||
out.rejected.push(`${action.id} reported "${parsed.row.memberKey}" twice in one step`)
|
||||
continue
|
||||
}
|
||||
seen.add(parsed.row.memberKey)
|
||||
try {
|
||||
await participantsDb.record({ runId: run.id, ...parsed.row })
|
||||
out.recorded += 1
|
||||
} catch (err) {
|
||||
out.rejected.push(err.message)
|
||||
log.error('event participant insert failed', { run: run.id, step: step.id, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { normalise, recordAnswer, MAX_MEMBER_KEY, MAX_PER_STEP }
|
||||
185
server/src/events/price.js
Normal file
185
server/src/events/price.js
Normal file
@@ -0,0 +1,185 @@
|
||||
// ── The live cap meter ─────────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §I, Phase 13: the step editor's "live cap meter". What would this
|
||||
// plan spend, and what does this deployment allow?
|
||||
//
|
||||
// **It is not the dry run, and the difference is the whole reason it exists.**
|
||||
// `verify.js` dispatches every step with `verify: true` — through the module,
|
||||
// and through the module to a sidecar and a game tick — and a pass against a
|
||||
// published version is RECORDED, because that record is what §K's gate reads
|
||||
// before letting a schedule start something unattended. Both of those are right
|
||||
// for an act an author performs once, deliberately, when the plan is finished.
|
||||
// Neither is right for a number that has to move while somebody types: a meter
|
||||
// on the dry run's path would put a shard round trip behind every keystroke and
|
||||
// would stamp `verified_at` from a form still being edited.
|
||||
//
|
||||
// So this file answers the half of the question core can answer ON ITS OWN:
|
||||
// `cost()` is a pure function of params (§F), and the caps come from the
|
||||
// switchboard. Nothing is dispatched, nothing is written, and no definition need
|
||||
// exist — the body is the spec in the author's hands, saved or not.
|
||||
//
|
||||
// **What it therefore cannot tell you** is everything the module knows: whether
|
||||
// the landmark exists, whether the creature is on the allowlist, whether the
|
||||
// shard is reachable. That is the dry run's, and the meter must not read as a
|
||||
// substitute for it — which is why the editor keeps both and labels them apart.
|
||||
//
|
||||
// ## Why the per-phase subtotal is here rather than computed in the browser
|
||||
//
|
||||
// §I asks the timeline for "its cap draw" per phase, and the arithmetic is
|
||||
// trivial — but the *inputs* are not in the browser. `cost()` runs on the server
|
||||
// and only on the server; a client that summed anything would first have to be
|
||||
// handed per-step costs, which is this same call. Returning the phase rollup
|
||||
// beside the total costs one pass over a list core has already walked.
|
||||
|
||||
const authorize = require('./authorize')
|
||||
const settingsDb = require('../model/events/eventActionSettings.db')
|
||||
const registries = require('../modules/registries')
|
||||
const spec = require('./spec')
|
||||
|
||||
/**
|
||||
* Flatten `{ phases: [{ key, steps: [...] }] }` into the priceable steps.
|
||||
*
|
||||
* Bounded by the spec's own limits rather than by a number invented here: this
|
||||
* route takes an unsaved spec, so it is reachable with a body the save path
|
||||
* would refuse, and the paste guard has to be the same one.
|
||||
*/
|
||||
function flatten(body) {
|
||||
const phases = Array.isArray(body?.phases) ? body.phases : []
|
||||
if (phases.length > spec.MAX_PHASES) {
|
||||
return { ok: false, error: `at most ${spec.MAX_PHASES} phases` }
|
||||
}
|
||||
const flat = []
|
||||
for (const [index, phase] of phases.entries()) {
|
||||
const steps = Array.isArray(phase?.steps) ? phase.steps : []
|
||||
if (steps.length > spec.MAX_STEPS_PER_PHASE) {
|
||||
return { ok: false, error: `at most ${spec.MAX_STEPS_PER_PHASE} steps in one phase` }
|
||||
}
|
||||
for (const [seq, step] of steps.entries()) {
|
||||
flat.push({
|
||||
// The key is what the editor groups by, and an unsaved phase may not
|
||||
// have a valid one yet — so the ordinal is what is echoed back. A meter
|
||||
// that could only address a phase whose key already validates would go
|
||||
// blank exactly while somebody is naming it.
|
||||
phase: index,
|
||||
phaseKey: typeof phase?.key === 'string' ? phase.key : null,
|
||||
seq,
|
||||
actionId: typeof step?.actionId === 'string' ? step.actionId : '',
|
||||
params: step && typeof step.params === 'object' && !Array.isArray(step.params) ? step.params : {},
|
||||
})
|
||||
}
|
||||
}
|
||||
if (flat.length > spec.MAX_STEPS) {
|
||||
return { ok: false, error: `at most ${spec.MAX_STEPS} steps in one definition` }
|
||||
}
|
||||
return { ok: true, flat }
|
||||
}
|
||||
|
||||
/**
|
||||
* Price a spec.
|
||||
*
|
||||
* **A step core cannot price is reported, never treated as free.** Three things
|
||||
* make one: no module registers the action, the action's `cost()` failed its own
|
||||
* contract (`priceOf` answers `null`), or it prices a dimension nobody declared.
|
||||
* All three make the totals below an UNDER-count, and a meter that silently
|
||||
* under-counts is worse than no meter — it is a number an author trusts that is
|
||||
* smaller than what will happen. So each one comes back in `unpriced` with the
|
||||
* step it belongs to, and the client shows the meter as incomplete.
|
||||
*
|
||||
* The third is not a refusal to price: an action that spends `uo.creatures`
|
||||
* spends it whether or not a module declared the dimension, so the amount is
|
||||
* still counted and the entry says the total is *unenforceable* rather than
|
||||
* unknown. Same split `authorize.undeclaredDimensions` makes for the same
|
||||
* reason.
|
||||
*/
|
||||
async function priceSpec(body) {
|
||||
const flattened = flatten(body)
|
||||
if (!flattened.ok) return { ok: false, error: flattened.error }
|
||||
const { flat } = flattened
|
||||
|
||||
const settings = await settingsDb.byIds(flat.map((s) => s.actionId))
|
||||
const totals = {}
|
||||
const byPhase = new Map()
|
||||
const unpriced = []
|
||||
let priced = 0
|
||||
|
||||
const addTo = (bag, dimension, amount) => {
|
||||
bag[dimension] = (bag[dimension] || 0) + amount
|
||||
}
|
||||
|
||||
for (const step of flat) {
|
||||
if (!byPhase.has(step.phase)) {
|
||||
byPhase.set(step.phase, { phase: step.phase, key: step.phaseKey, steps: 0, draw: {} })
|
||||
}
|
||||
const phase = byPhase.get(step.phase)
|
||||
phase.steps += 1
|
||||
|
||||
const where = { phase: step.phase, seq: step.seq, actionId: step.actionId || null }
|
||||
const action = step.actionId ? registries.eventAction(step.actionId) : null
|
||||
if (!action) {
|
||||
// A step with no action chosen yet is not a problem — it is a form being
|
||||
// filled in — so it is not reported. A step naming an action nothing
|
||||
// registers is, because that is the dormant case and it under-counts.
|
||||
if (step.actionId) {
|
||||
unpriced.push({ ...where, code: 'dormant', message: `no module registers "${step.actionId}"` })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const cost = authorize.priceOf(action, step.params)
|
||||
if (cost === null) {
|
||||
unpriced.push({ ...where, code: 'unpriceable', message: `"${action.label}" could not report what it costs` })
|
||||
continue
|
||||
}
|
||||
priced += 1
|
||||
for (const [dimension, amount] of Object.entries(cost)) {
|
||||
addTo(totals, dimension, amount)
|
||||
addTo(phase.draw, dimension, amount)
|
||||
}
|
||||
for (const dimension of authorize.undeclaredDimensions(cost)) {
|
||||
unpriced.push({
|
||||
...where,
|
||||
code: 'undeclared',
|
||||
message: `spends "${dimension}", which no installed module declares as a budget — this step is refused at dispatch`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const caps = authorize.effectiveCaps(
|
||||
flat.map((s) => ({ actionId: s.actionId, params: s.params })),
|
||||
settings,
|
||||
)
|
||||
|
||||
const cost = Object.entries(totals)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([dimension, total]) => {
|
||||
const cap = (caps[dimension] || {}).cap ?? null
|
||||
return {
|
||||
dimension,
|
||||
total,
|
||||
cap,
|
||||
from: (caps[dimension] || {}).from || null,
|
||||
over: cap !== null && total > cap,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
steps: flat.length,
|
||||
priced,
|
||||
cost,
|
||||
// Ordinal order, because that is timeline order and the client draws it
|
||||
// beside each phase. A phase whose steps price to nothing still appears, so
|
||||
// the rollup and the timeline have the same number of rows.
|
||||
phases: [...byPhase.values()].map((p) => ({
|
||||
phase: p.phase,
|
||||
key: p.key,
|
||||
steps: p.steps,
|
||||
draw: Object.entries(p.draw)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([dimension, total]) => ({ dimension, total })),
|
||||
})),
|
||||
unpriced,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { priceSpec }
|
||||
335
server/src/events/recurrence.js
Normal file
335
server/src/events/recurrence.js
Normal file
@@ -0,0 +1,335 @@
|
||||
// ── Occurrence arithmetic, in the event's own timezone ─────────────────────
|
||||
//
|
||||
// EVENTS.md §E, "Two scheduling decisions the calendar forces", and Phase 4 of
|
||||
// EVENTS_PLAN.md. Given a closed recurrence shape, an IANA zone and a window,
|
||||
// this file answers *which UTC instants* an event happens at. Nothing else
|
||||
// computes an occurrence; the runner materialises what this returns and the
|
||||
// calendar projects what this returns, so there is exactly one arithmetic to be
|
||||
// wrong.
|
||||
//
|
||||
// **Why there is no library here.** The server's dependency tree has no date
|
||||
// library at all — no luxon, no date-fns, no tz package (check `package.json`
|
||||
// before adding one). What it does have is Node's own full tzdata behind
|
||||
// `Intl.DateTimeFormat`, which is the same database a library would ship a copy
|
||||
// of and is already what `eventDefinitions.model.js` validates a zone name
|
||||
// against. So the arithmetic is: *format an instant into the zone's wall clock*
|
||||
// (which `Intl` does exactly) and invert that mapping by search. Everything
|
||||
// below is that one idea.
|
||||
//
|
||||
// **Why not cron.** Decided in §E and restated in the plan: there is no parser
|
||||
// in the tree, the only precedent is in the bot (another process), and a cron
|
||||
// string is the one field an operator cannot proofread. Four closed shapes
|
||||
// render as a form, and a form is checkable.
|
||||
//
|
||||
// **The two DST rules** (org lead, 2026-09-02), which exist because a weekly
|
||||
// 02:30 event in `Europe/Berlin` is a real thing an operator will author:
|
||||
//
|
||||
// - A **nonexistent** local time — the spring-forward gap — steps forward to the
|
||||
// first wall clock that does exist. 02:30 becomes 03:00, not 03:30: the event
|
||||
// happens as close to the authored time as the calendar allows.
|
||||
// - An **ambiguous** local time — the fall-back hour, which happens twice —
|
||||
// takes the FIRST, the pre-transition offset.
|
||||
//
|
||||
// Both are reported back as `adjusted`, so a run can record why its clock reads
|
||||
// oddly rather than leaving an operator to discover DST for themselves at 3am.
|
||||
// Neither rule ever drops an occurrence: a weekly event happens every week.
|
||||
|
||||
// Indexed to match `Date#getUTCDay`, which is what the civil-calendar helpers
|
||||
// below return. Names rather than numbers everywhere an operator can see them —
|
||||
// `days: ['friday']` is proofreadable and `days: [5]` is not, which is the same
|
||||
// argument that rejected cron.
|
||||
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
|
||||
|
||||
// `nth: -1` is "the last one in the month", and it is not a synonym for 4: a
|
||||
// month with five Fridays has a last Friday that is not the fourth. 1..4 always
|
||||
// exist in every month (1 + 6 + 21 = 28), so there is deliberately no fifth and
|
||||
// therefore no absent-occurrence case to define (org lead, 2026-09-02).
|
||||
const MONTHLY_NTH = [1, 2, 3, 4, -1]
|
||||
|
||||
const TIME_RE = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
|
||||
const AT_RE = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[T ]([01][0-9]|2[0-3]):([0-5][0-9])$/
|
||||
|
||||
const MINUTE_MS = 60_000
|
||||
const DAY_MS = 86_400_000
|
||||
|
||||
// No real DST gap exceeds two hours (Lord Howe's is 30 minutes; the largest
|
||||
// historical jumps are a day, and those are line-of-date changes rather than
|
||||
// gaps in the local clock). Four hours is a bound, not an expectation: it stops
|
||||
// a malformed zone turning the search into a hang.
|
||||
const MAX_GAP_MINUTES = 240
|
||||
|
||||
// Bounds on what one call may return. A projection window is operator-supplied
|
||||
// (the calendar's month, the horizon), and an unbounded expansion of a daily
|
||||
// schedule across a decade is how a calendar request becomes an outage.
|
||||
const MAX_OCCURRENCES = 500
|
||||
|
||||
const formatters = new Map()
|
||||
|
||||
function formatterFor(zone) {
|
||||
let f = formatters.get(zone)
|
||||
if (!f) {
|
||||
// `hourCycle: 'h23'` rather than `hour12: false`, which renders midnight as
|
||||
// hour 24 in some ICU versions and would put every midnight event on the
|
||||
// previous day.
|
||||
f = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
formatters.set(zone, f)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
/** The wall clock an instant reads as, in this zone. */
|
||||
function wallPartsAt(zone, ms) {
|
||||
const parts = formatterFor(zone).formatToParts(new Date(ms))
|
||||
const get = (type) => Number(parts.find((p) => p.type === type)?.value)
|
||||
return {
|
||||
y: get('year'),
|
||||
m: get('month'),
|
||||
d: get('day'),
|
||||
h: get('hour'),
|
||||
mi: get('minute'),
|
||||
s: get('second'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* That same wall clock as a number, by reading it as though it were UTC.
|
||||
*
|
||||
* This is the trick the whole file rests on: two wall clocks are equal exactly
|
||||
* when these numbers are, and `wallMs - instant` is the zone's offset at that
|
||||
* instant. It is never a real instant and must not be used as one.
|
||||
*/
|
||||
function wallMs(zone, ms) {
|
||||
const p = wallPartsAt(zone, ms)
|
||||
return Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s)
|
||||
}
|
||||
|
||||
const offsetMs = (zone, ms) => wallMs(zone, ms) - ms
|
||||
|
||||
/**
|
||||
* Every instant that reads as this wall clock in this zone, earliest first.
|
||||
*
|
||||
* Ordinarily one. Two in the fall-back hour, none in the spring-forward gap —
|
||||
* and the length of this array is how the caller tells those three apart.
|
||||
*
|
||||
* Sampling the offset a day either side is what makes it correct across a
|
||||
* transition: subtracting each candidate offset gives the two instants worth
|
||||
* testing, and the test is whether the instant formats back to what was asked.
|
||||
*/
|
||||
function instantsForWall(zone, target) {
|
||||
const candidates = new Set([
|
||||
target - offsetMs(zone, target - DAY_MS),
|
||||
target - offsetMs(zone, target + DAY_MS),
|
||||
])
|
||||
const valid = []
|
||||
for (const ms of candidates) {
|
||||
if (wallMs(zone, ms) === target) valid.push(ms)
|
||||
}
|
||||
return valid.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a local wall clock to a UTC instant, applying the two DST rules.
|
||||
*
|
||||
* `{ at, adjusted, shiftMinutes }` — `adjusted` is `null` on an ordinary day,
|
||||
* `'gap'` when the authored time did not exist and was stepped forward, and
|
||||
* `'ambiguous'` when it happened twice and the first was taken.
|
||||
*/
|
||||
function resolveWall(zone, y, m, d, h, mi) {
|
||||
const target = Date.UTC(y, m - 1, d, h, mi, 0)
|
||||
const valid = instantsForWall(zone, target)
|
||||
if (valid.length === 1) return { at: new Date(valid[0]), adjusted: null, shiftMinutes: 0 }
|
||||
if (valid.length > 1) return { at: new Date(valid[0]), adjusted: 'ambiguous', shiftMinutes: 0 }
|
||||
|
||||
// The gap. Step the WALL CLOCK forward — not the instant — until it lands on
|
||||
// a time that exists, which is the first instant after the transition.
|
||||
for (let step = 1; step <= MAX_GAP_MINUTES; step += 1) {
|
||||
const shifted = instantsForWall(zone, target + step * MINUTE_MS)
|
||||
if (shifted.length) return { at: new Date(shifted[0]), adjusted: 'gap', shiftMinutes: step }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── The civil calendar ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Dates with no zone attached: "the 14th of September" as a thing to iterate,
|
||||
// before any question of what instant it starts at. `Date.UTC` is used purely
|
||||
// as calendar arithmetic here and none of these numbers is an instant.
|
||||
|
||||
const dayIndex = (y, m, d) => Date.UTC(y, m - 1, d) / DAY_MS
|
||||
|
||||
function civilFromIndex(n) {
|
||||
const dt = new Date(n * DAY_MS)
|
||||
return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate() }
|
||||
}
|
||||
|
||||
const weekdayOf = (y, m, d) => new Date(Date.UTC(y, m - 1, d)).getUTCDay()
|
||||
|
||||
const daysInMonth = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate()
|
||||
|
||||
/** Is this a real date? `2026-02-30` parses as a string and is not a day. */
|
||||
const isRealDate = (y, m, d) => m >= 1 && m <= 12 && d >= 1 && d <= daysInMonth(y, m)
|
||||
|
||||
/**
|
||||
* The day of the month that is the nth (or last) given weekday.
|
||||
*
|
||||
* `nth` is 1..4 or -1. Answers `null` only for an nth that cannot exist, which
|
||||
* the validated shapes never produce — the guard is here so that a spec written
|
||||
* by hand into the database cannot make the runner throw.
|
||||
*/
|
||||
function nthWeekdayDay(y, m, weekday, nth) {
|
||||
const last = daysInMonth(y, m)
|
||||
if (nth === -1) {
|
||||
const back = (weekdayOf(y, m, last) - weekday + 7) % 7
|
||||
return last - back
|
||||
}
|
||||
const forward = (weekday - weekdayOf(y, m, 1) + 7) % 7
|
||||
const day = 1 + forward + (nth - 1) * 7
|
||||
return day <= last ? day : null
|
||||
}
|
||||
|
||||
// ── Expansion ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every occurrence of `schedule` in `[from, to)`, earliest first.
|
||||
*
|
||||
* `[{ at: Date, adjusted, shiftMinutes }]`. `manual` answers `[]` — it is the
|
||||
* shape that means "there is no recurrence", and an admin's own
|
||||
* `POST /:id/runs` is the only thing that creates one of its occurrences.
|
||||
*
|
||||
* The window is in INSTANTS and the walk is in LOCAL DAYS, which is why each
|
||||
* walk starts a day early and ends a day late: a local day can begin up to
|
||||
* fourteen hours either side of the same UTC day.
|
||||
*/
|
||||
function occurrencesBetween(schedule, zone, from, to, { limit = MAX_OCCURRENCES } = {}) {
|
||||
const fromMs = from instanceof Date ? from.getTime() : Number(from)
|
||||
const toMs = to instanceof Date ? to.getTime() : Number(to)
|
||||
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) return []
|
||||
if (!schedule || typeof schedule !== 'object') return []
|
||||
|
||||
const cap = Math.min(Math.max(Number(limit) || MAX_OCCURRENCES, 1), MAX_OCCURRENCES)
|
||||
const out = []
|
||||
const keep = (resolved) => {
|
||||
if (!resolved) return
|
||||
const t = resolved.at.getTime()
|
||||
if (t >= fromMs && t < toMs && out.length < cap) out.push(resolved)
|
||||
}
|
||||
|
||||
if (schedule.kind === 'manual') return []
|
||||
|
||||
if (schedule.kind === 'once') {
|
||||
const m = AT_RE.exec(String(schedule.at || ''))
|
||||
if (!m) return []
|
||||
keep(resolveWall(zone, Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5])))
|
||||
return out
|
||||
}
|
||||
|
||||
const time = TIME_RE.exec(String(schedule.time || ''))
|
||||
if (!time) return []
|
||||
const hour = Number(time[1])
|
||||
const minute = Number(time[2])
|
||||
|
||||
if (schedule.kind === 'weekly') {
|
||||
const wanted = new Set(
|
||||
(schedule.days || []).map((d) => WEEKDAYS.indexOf(String(d))).filter((i) => i >= 0),
|
||||
)
|
||||
if (!wanted.size) return []
|
||||
const first = wallPartsAt(zone, fromMs)
|
||||
const last = wallPartsAt(zone, toMs)
|
||||
const startDay = dayIndex(first.y, first.m, first.d) - 1
|
||||
const endDay = dayIndex(last.y, last.m, last.d) + 1
|
||||
for (let n = startDay; n <= endDay && out.length < cap; n += 1) {
|
||||
const { y, m, d } = civilFromIndex(n)
|
||||
if (wanted.has(weekdayOf(y, m, d))) keep(resolveWall(zone, y, m, d, hour, minute))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
if (schedule.kind === 'monthly') {
|
||||
const weekday = WEEKDAYS.indexOf(String(schedule.weekday))
|
||||
const nth = Number(schedule.nth)
|
||||
if (weekday < 0 || !MONTHLY_NTH.includes(nth)) return []
|
||||
const first = wallPartsAt(zone, fromMs)
|
||||
const last = wallPartsAt(zone, toMs)
|
||||
// Months as a single running count, so a window crossing a new year is not
|
||||
// a special case.
|
||||
const startMonth = first.y * 12 + (first.m - 1) - 1
|
||||
const endMonth = last.y * 12 + (last.m - 1) + 1
|
||||
for (let n = startMonth; n <= endMonth && out.length < cap; n += 1) {
|
||||
const y = Math.floor(n / 12)
|
||||
const m = (n % 12) + 1
|
||||
const day = nthWeekdayDay(y, m, weekday, nth)
|
||||
if (day) keep(resolveWall(zone, y, m, day, hour, minute))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/** The next occurrence at or after `from`, or null. A bounded look-ahead. */
|
||||
function nextOccurrence(schedule, zone, from, { withinDays = 400 } = {}) {
|
||||
const fromMs = from instanceof Date ? from.getTime() : Number(from)
|
||||
const [first] = occurrencesBetween(schedule, zone, fromMs, fromMs + withinDays * DAY_MS, {
|
||||
limit: 1,
|
||||
})
|
||||
return first || null
|
||||
}
|
||||
|
||||
/**
|
||||
* How a schedule reads to a person, in the event's own zone.
|
||||
*
|
||||
* Server-side because two surfaces need the same sentence — the calendar's list
|
||||
* and the run's own record of why it exists — and because the client's copy in
|
||||
* `eventAuthoring.js` is a mirror that is allowed to drift on wording but not on
|
||||
* meaning.
|
||||
*/
|
||||
function describe(schedule, zone = 'UTC') {
|
||||
if (!schedule || typeof schedule !== 'object') return 'No schedule'
|
||||
const cap = (s) => String(s).charAt(0).toUpperCase() + String(s).slice(1)
|
||||
const nthLabel = { 1: 'first', 2: 'second', 3: 'third', 4: 'fourth', '-1': 'last' }
|
||||
switch (schedule.kind) {
|
||||
case 'manual':
|
||||
return 'Started by hand'
|
||||
case 'once':
|
||||
return `Once, on ${String(schedule.at).replace('T', ' ')} (${zone})`
|
||||
case 'weekly': {
|
||||
const days = (schedule.days || []).map(cap)
|
||||
const list =
|
||||
days.length <= 1
|
||||
? days.join('')
|
||||
: `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}`
|
||||
return `Every ${list} at ${schedule.time} (${zone})`
|
||||
}
|
||||
case 'monthly':
|
||||
return `The ${nthLabel[String(schedule.nth)]} ${cap(schedule.weekday)} of every month at ${schedule.time} (${zone})`
|
||||
default:
|
||||
return 'No schedule'
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WEEKDAYS,
|
||||
MONTHLY_NTH,
|
||||
TIME_RE,
|
||||
AT_RE,
|
||||
MAX_OCCURRENCES,
|
||||
DAY_MS,
|
||||
wallPartsAt,
|
||||
offsetMs,
|
||||
instantsForWall,
|
||||
resolveWall,
|
||||
isRealDate,
|
||||
nthWeekdayDay,
|
||||
occurrencesBetween,
|
||||
nextOccurrence,
|
||||
describe,
|
||||
}
|
||||
622
server/src/events/spec.js
Normal file
622
server/src/events/spec.js
Normal file
@@ -0,0 +1,622 @@
|
||||
// ── The event spec, and the one place it is validated ──────────────────────
|
||||
//
|
||||
// EVENTS.md §C ("phases and actions are configuration inside a version snapshot,
|
||||
// not tables") and §D. A spec is the authored tree a definition carries and a
|
||||
// version freezes: phases, and the steps inside them. It is stored as JSON in
|
||||
// `event_definitions`' working copy and in `event_versions.spec`, and this file
|
||||
// is the only thing that decides whether one is well formed.
|
||||
//
|
||||
// **Every check here is a boundary, not a convenience.** The authoring UI (Phase
|
||||
// 3, then Phase 13) will re-implement some of them for the sake of a good
|
||||
// inline error, and that second copy is expected to drift — so this one is the
|
||||
// one that decides. A spec arriving by any other route (a restore, a fixture, a
|
||||
// module shipping a definition as content) gets the same answer.
|
||||
//
|
||||
// **What this file knows, and what it deliberately refuses.** Two top-level keys
|
||||
// exist today: `schedule` and `phases`. Unknown top-level keys are REFUSED rather
|
||||
// than preserved: a spec that silently carries `announcements` today is a spec
|
||||
// whose author believes announcements work, and the later phase that gives the
|
||||
// key meaning would inherit a corpus of unvalidated ones. The refusal list is the
|
||||
// changelog — Phase 4 added the recurrence shapes, Phase 5 added a phase's
|
||||
// `advance`, Phase 10 adds `announcements`.
|
||||
//
|
||||
// **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`,
|
||||
// `weekly`, `monthly` — and every check on them is a check on SHAPE. The
|
||||
// arithmetic they describe lives in `events/recurrence.js`, and the zone they are
|
||||
// computed in is `event_definitions.timezone`, a sibling column this file cannot
|
||||
// see and does not need to: a well-formed wall clock resolves in every zone (a
|
||||
// DST gap shifts it, it is never rejected), so a schedule that validates here
|
||||
// computes there.
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
// For `priceOf` and `undeclaredDimensions` only — the save-time half of §F's
|
||||
// fail-closed budget rule (Phase 7). Nothing here reaches the database:
|
||||
// `authorize` requires two `.db.js` modules and requiring one opens no
|
||||
// connection, which is the same rule this file already lives under.
|
||||
const authorize = require('./authorize')
|
||||
const recurrence = require('./recurrence')
|
||||
const conditionGrammar = require('../engagement/conditions')
|
||||
const { checkLiteral } = conditionGrammar
|
||||
|
||||
// A phase key is a slug: it is stored in `event_run_steps.phase`, it is what the
|
||||
// run console groups by, and it is what an operator reads in "phase 3 has not
|
||||
// started". Same grammar as a template key's segment.
|
||||
const PHASE_KEY = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/
|
||||
const MAX_PHASE_KEY = 64
|
||||
|
||||
// Bounds, not guesses. They exist so that a paste of the wrong JSON is a refusal
|
||||
// with a number in it rather than a run that materialises fifty thousand step
|
||||
// rows — the same argument `MAX_SENDS_PER_HOUR` makes on the engagement side.
|
||||
const MAX_PHASES = 40
|
||||
const MAX_STEPS_PER_PHASE = 100
|
||||
const MAX_STEPS = 500
|
||||
|
||||
// The two shapes a phase's `advance` may take (§E). There is deliberately no
|
||||
// third: a gate that never opens is held, made visible and left to an operator
|
||||
// (org lead, 2026-09-02), so there is no authored timeout and no disposition to
|
||||
// validate. Adding one later is one key and one branch, and this is the list a
|
||||
// reader should find it missing from.
|
||||
const ADVANCE_KINDS = ['after', 'on']
|
||||
|
||||
// `after: '30m'` — one integer and one unit, and nothing else. No `1h30m`, no
|
||||
// fractions: the whole reason a duration is a string here rather than the plain
|
||||
// integer seconds `core.wait` takes is that an operator proofreads it, and a
|
||||
// grammar that admits two spellings of ninety minutes is one an operator has to
|
||||
// parse rather than read.
|
||||
const AFTER_RE = /^(\d{1,6})(s|m|h|d)$/
|
||||
const AFTER_UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86_400 }
|
||||
const MIN_AFTER_SECONDS = 1
|
||||
// A paste guard rather than a policy, in the spirit of MAX_PHASES: thirty days
|
||||
// is longer than any event this system is for, and a phase gate of ten years is
|
||||
// a typo that would otherwise hold a run — and its concurrency key — for ever.
|
||||
const MAX_AFTER_SECONDS = 30 * 86_400
|
||||
|
||||
// How many firings one `on` gate may wait for. Bounded for the reason MAX_LIST
|
||||
// is: it is authored into a JSON column, and "count: 100000" is a phase that
|
||||
// never advances written as one that eventually does.
|
||||
const MAX_ADVANCE_COUNT = 1000
|
||||
|
||||
// The four closed shapes of §E. `manual` is first because it is the default and
|
||||
// what an unscheduled draft carries; the other three are recurrences the runner
|
||||
// expands into occurrences ahead of time.
|
||||
const SCHEDULE_KINDS = ['manual', 'once', 'weekly', 'monthly']
|
||||
|
||||
// The keys each shape may carry, and the ONLY ones. A `weekly` that also names
|
||||
// an `at` is an author who believes something about it that is not true — the
|
||||
// same argument the top-level refusal makes, one level down.
|
||||
const SCHEDULE_KEYS = {
|
||||
manual: [],
|
||||
once: ['at'],
|
||||
weekly: ['days', 'time'],
|
||||
monthly: ['nth', 'weekday', 'time'],
|
||||
}
|
||||
|
||||
// What a step does when its attempts are exhausted (§L). The disposition only —
|
||||
// retry is not one of the values, it is what happens BEFORE one of them. Each
|
||||
// risk class has a default, which is the whole reason `risk` is required at
|
||||
// registration: a `change` action that fell back to `skip` would leave a run
|
||||
// advancing over a half-changed world.
|
||||
const ON_FAILURE = ['skip', 'pause', 'abort_run']
|
||||
const ON_FAILURE_BY_RISK = {
|
||||
notify: 'skip',
|
||||
inspect: 'skip',
|
||||
change: 'pause',
|
||||
irreversible: 'abort_run',
|
||||
}
|
||||
|
||||
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/** The default disposition for an action whose risk class core knows. */
|
||||
const defaultOnFailure = (risk) => ON_FAILURE_BY_RISK[risk] || 'pause'
|
||||
|
||||
/**
|
||||
* Check one schedule shape and answer the normalised form of it.
|
||||
*
|
||||
* Always answers a valid schedule — `{ kind: 'manual' }` when the input was not
|
||||
* one — because `validate` collects every error and carries on, and a caller
|
||||
* reading `spec.schedule.days` of a refused spec should find an empty recurrence
|
||||
* rather than a half-built one.
|
||||
*
|
||||
* **`days` is normalised into week order**, not into the order they were typed.
|
||||
* The spec is compared, described and diffed, and `['friday','monday']` and
|
||||
* `['monday','friday']` naming the same schedule while differing as JSON is a
|
||||
* version history that reports edits nobody made.
|
||||
*/
|
||||
function validateSchedule(kind, raw, errors) {
|
||||
const at = (key) => `spec.schedule.${key}`
|
||||
|
||||
if (kind === 'once') {
|
||||
const m = recurrence.AT_RE.exec(String(raw.at ?? ''))
|
||||
if (!m) {
|
||||
errors.push(`${at('at')}: expected a local date and time as YYYY-MM-DDTHH:MM`)
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
const [, y, mo, d, h, mi] = m.map(Number)
|
||||
// The regex admits `2026-02-30`, which is a string and not a day.
|
||||
if (!recurrence.isRealDate(y, mo, d)) {
|
||||
errors.push(`${at('at')}: "${raw.at}" is not a real date`)
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
// Stored as the operator wrote it — a wall clock in the definition's own
|
||||
// zone, never a UTC instant. §E: the schedule belongs to the event, and the
|
||||
// instant is derived at materialisation.
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return { kind: 'once', at: `${y}-${pad(mo)}-${pad(d)}T${pad(h)}:${pad(mi)}` }
|
||||
}
|
||||
|
||||
if (kind === 'weekly' || kind === 'monthly') {
|
||||
const time = recurrence.TIME_RE.test(String(raw.time ?? '')) ? String(raw.time) : null
|
||||
if (!time) errors.push(`${at('time')}: expected a 24-hour time as HH:MM`)
|
||||
|
||||
if (kind === 'weekly') {
|
||||
const rawDays = Array.isArray(raw.days) ? raw.days : null
|
||||
if (!rawDays || rawDays.length === 0) {
|
||||
errors.push(`${at('days')}: expected a non-empty array of weekday names`)
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
const unknown = rawDays.filter((d) => !recurrence.WEEKDAYS.includes(String(d).toLowerCase()))
|
||||
if (unknown.length) {
|
||||
errors.push(
|
||||
`${at('days')}: unknown weekday(s) ${unknown.join(', ')} — expected ${recurrence.WEEKDAYS.join(', ')}`,
|
||||
)
|
||||
}
|
||||
const days = recurrence.WEEKDAYS.filter((name) =>
|
||||
rawDays.some((d) => String(d).toLowerCase() === name),
|
||||
)
|
||||
if (!time || !days.length) return { kind: 'manual' }
|
||||
return { kind: 'weekly', days, time }
|
||||
}
|
||||
|
||||
const weekday = String(raw.weekday ?? '').toLowerCase()
|
||||
if (!recurrence.WEEKDAYS.includes(weekday)) {
|
||||
errors.push(
|
||||
`${at('weekday')}: expected one of ${recurrence.WEEKDAYS.join(', ')}`,
|
||||
)
|
||||
}
|
||||
const nth = Number(raw.nth)
|
||||
if (!recurrence.MONTHLY_NTH.includes(nth)) {
|
||||
// -1 is "last", which a month with five Fridays makes different from 4.
|
||||
// There is no 5: every month has a first through fourth of every weekday,
|
||||
// so the closed set has no absent case (org lead, 2026-09-02).
|
||||
errors.push(`${at('nth')}: expected 1, 2, 3, 4 or -1 (last)`)
|
||||
}
|
||||
if (!time || !recurrence.WEEKDAYS.includes(weekday) || !recurrence.MONTHLY_NTH.includes(nth)) {
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
return { kind: 'monthly', nth, weekday, time }
|
||||
}
|
||||
|
||||
return { kind: 'manual' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `'30m'` into seconds, or answer null.
|
||||
*
|
||||
* Exported because the run console renders the same duration back and must not
|
||||
* grow a second opinion about what `'2h'` means.
|
||||
*/
|
||||
function parseAfter(raw) {
|
||||
const m = AFTER_RE.exec(String(raw ?? ''))
|
||||
if (!m) return null
|
||||
const seconds = Number(m[1]) * AFTER_UNIT_SECONDS[m[2]]
|
||||
if (seconds < MIN_AFTER_SECONDS || seconds > MAX_AFTER_SECONDS) return null
|
||||
return seconds
|
||||
}
|
||||
|
||||
/** Seconds back to the largest whole unit that expresses them exactly. */
|
||||
function formatAfter(seconds) {
|
||||
for (const unit of ['d', 'h', 'm']) {
|
||||
const size = AFTER_UNIT_SECONDS[unit]
|
||||
if (seconds % size === 0) return `${seconds / size}${unit}`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a phase's `advance` gate and answer the normalised form of it.
|
||||
*
|
||||
* Returns `null` for a phase with no gate — the common case, and the behaviour
|
||||
* every phase had before Phase 5: it advances when its steps go terminal and on
|
||||
* nothing else. A gate is an ADDITIONAL condition, never a replacement, so a
|
||||
* phase whose steps are still running is not advanced by a satisfied gate.
|
||||
*
|
||||
* **The duration is normalised the way `days` is** — `'120m'` is stored as
|
||||
* `'2h'` — because the spec is diffed between versions, and two spellings of one
|
||||
* delay differing as JSON is a version history that reports edits nobody made.
|
||||
*
|
||||
* **`where` is validated against the trigger's DECLARATION, at save, with the
|
||||
* offending variable named.** This is the whole trap of this phase, and it is
|
||||
* `engagement/conditions.js`'s own argument one system across: a predicate that
|
||||
* silently reads `undefined` is a phase that silently never advances, and the
|
||||
* night you find out is the night of the event.
|
||||
*
|
||||
* **A trigger nobody registers makes the gate DORMANT, not invalid.** Same rule
|
||||
* as a step naming an action no installed module declares: it saves, so
|
||||
* uninstalling a module is not destructive to an author's work, and it refuses
|
||||
* to publish, because a version runs are pinned to must not wait on a trigger
|
||||
* that can never fire.
|
||||
*/
|
||||
function validateAdvance(raw, path, errors) {
|
||||
if (raw === undefined || raw === null) return null
|
||||
if (!isPlainObject(raw)) {
|
||||
errors.push(`${path}: expected an object`)
|
||||
return null
|
||||
}
|
||||
|
||||
const keys = Object.keys(raw)
|
||||
const named = ADVANCE_KINDS.filter((k) => keys.includes(k))
|
||||
if (named.length !== 1) {
|
||||
errors.push(`${path}: expected exactly one of "after" or "on"`)
|
||||
return null
|
||||
}
|
||||
const kind = named[0]
|
||||
|
||||
if (kind === 'after') {
|
||||
const extra = keys.filter((k) => k !== 'after')
|
||||
if (extra.length) {
|
||||
errors.push(`${path}: unknown key(s) ${extra.join(', ')} for an "after" gate`)
|
||||
return null
|
||||
}
|
||||
const seconds = parseAfter(raw.after)
|
||||
if (seconds === null) {
|
||||
errors.push(
|
||||
`${path}.after: expected a duration like "30m" — a whole number of s, m, h or d, ` +
|
||||
`between ${MIN_AFTER_SECONDS}s and ${formatAfter(MAX_AFTER_SECONDS)}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
// Only the canonical string is stored. The seconds are re-derived by the one
|
||||
// caller that needs them (the runner, when it opens the gate) through the
|
||||
// exported `parseAfter`, rather than kept beside it as a second field two
|
||||
// versions of the spec could disagree about.
|
||||
return { after: formatAfter(seconds) }
|
||||
}
|
||||
|
||||
// `dormant` is in this list for the reason `actionVersion` and `dormant` are
|
||||
// in a step's — **validate must accept its own output.** A saved spec is
|
||||
// re-validated on every later save and again at publish, so a field the
|
||||
// validator itself added and then refused would make the second save of any
|
||||
// gated definition impossible. It is accepted and then RECOMPUTED below,
|
||||
// never trusted: dormancy is whether anybody registers that trigger right
|
||||
// now, not what was true when the spec was last written.
|
||||
const extra = keys.filter((k) => !['on', 'where', 'count', 'dormant'].includes(k))
|
||||
if (extra.length) {
|
||||
errors.push(`${path}: unknown key(s) ${extra.join(', ')} for an "on" gate`)
|
||||
return null
|
||||
}
|
||||
|
||||
const triggerId = raw.on
|
||||
if (typeof triggerId !== 'string' || !triggerId) {
|
||||
errors.push(`${path}.on: expected a trigger id`)
|
||||
return null
|
||||
}
|
||||
|
||||
let count = 1
|
||||
if (raw.count !== undefined && raw.count !== null) {
|
||||
if (!Number.isInteger(raw.count) || raw.count < 1 || raw.count > MAX_ADVANCE_COUNT) {
|
||||
errors.push(`${path}.count: expected a whole number between 1 and ${MAX_ADVANCE_COUNT}`)
|
||||
return null
|
||||
}
|
||||
count = raw.count
|
||||
}
|
||||
|
||||
const declaration = registries.eventTrigger(triggerId)
|
||||
if (!declaration) {
|
||||
// Dormant, exactly as an unregistered action is. `where` is carried through
|
||||
// unvalidated and unnormalised — there is no declaration to check it
|
||||
// against, and dropping it would silently delete an author's predicate the
|
||||
// moment a module was uninstalled.
|
||||
return { on: triggerId, where: raw.where ?? null, count, dormant: true }
|
||||
}
|
||||
|
||||
const checked = conditionGrammar.validate(declaration, raw.where ?? null)
|
||||
if (!checked.ok) {
|
||||
// The grammar paths its own errors from the root token `conditions`; this
|
||||
// re-roots them at the phase so an author reading five of them at once can
|
||||
// tell which phase each belongs to. The text after the path — the part that
|
||||
// names the variable — is the grammar's, unchanged.
|
||||
checked.errors.forEach((e) =>
|
||||
errors.push(`${path}.where${e.startsWith('conditions') ? e.slice('conditions'.length) : `: ${e}`}`),
|
||||
)
|
||||
return null
|
||||
}
|
||||
return { on: triggerId, where: checked.conditions, count, dormant: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check one authored param object against an action's declared params.
|
||||
*
|
||||
* Returns `{ params, errors }`. Unknown params are an ERROR rather than a silent
|
||||
* drop: an author who typed `creatures` where the action declares `creature` has
|
||||
* written a step that would dispatch with the count missing, and dropping the key
|
||||
* makes that look like it saved cleanly.
|
||||
*/
|
||||
function checkParams(declaration, raw, path) {
|
||||
const errors = []
|
||||
const params = {}
|
||||
const given = isPlainObject(raw) ? raw : {}
|
||||
|
||||
if (raw !== undefined && raw !== null && !isPlainObject(raw)) {
|
||||
return { params, errors: [`${path}.params: expected an object`] }
|
||||
}
|
||||
|
||||
const declared = new Map((declaration.params || []).map((p) => [p.name, p]))
|
||||
for (const name of Object.keys(given)) {
|
||||
if (!declared.has(name)) {
|
||||
errors.push(`${path}.params: "${name}" is not a param of ${declaration.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of declaration.params || []) {
|
||||
const value = given[p.name]
|
||||
if (value === undefined || value === null || value === '') {
|
||||
if (p.required) errors.push(`${path}.params: "${p.name}" is required`)
|
||||
continue
|
||||
}
|
||||
const checked = checkLiteral(p.type, value)
|
||||
if (checked.error) {
|
||||
errors.push(`${path}.params: "${p.name}" ${checked.error}`)
|
||||
continue
|
||||
}
|
||||
params[p.name] = checked.value
|
||||
}
|
||||
|
||||
return { params, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalise a whole spec.
|
||||
*
|
||||
* `{ ok: true, spec }` with a new normalised tree, or `{ ok: false, errors }`
|
||||
* listing EVERY problem rather than the first — the posture `conditions.validate`
|
||||
* takes, and for the same reason: an author fixing one step at a time is an
|
||||
* author making six round trips through a form.
|
||||
*
|
||||
* `knownActionIds` widens what may be named beyond what is registered right now,
|
||||
* and it is how a definition survives its module being uninstalled. The rule,
|
||||
* lifted verbatim from `engagementRules.model`'s treatment of a dormant trigger:
|
||||
* **a step already in the saved spec may keep an unregistered action; a new step
|
||||
* may not add one.** Refusing to save the whole definition would make an
|
||||
* uninstall destructive after the fact, and silently dropping the step would
|
||||
* delete authored work to make a form submit. A kept step is marked
|
||||
* `dormant: true` and its params pass through unvalidated, because the only
|
||||
* thing that could validate them left with the module.
|
||||
*/
|
||||
function validate(raw, { knownActionIds = [] } = {}) {
|
||||
const errors = []
|
||||
const known = new Set(knownActionIds)
|
||||
|
||||
if (!isPlainObject(raw)) return { ok: false, errors: ['spec: expected an object'] }
|
||||
|
||||
const allowed = new Set(['schedule', 'phases'])
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (!allowed.has(key)) {
|
||||
errors.push(`spec: unknown key "${key}" (Phase 1 understands ${[...allowed].join(', ')})`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── schedule ──
|
||||
const rawSchedule =
|
||||
raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule
|
||||
let schedule = { kind: 'manual' }
|
||||
if (!isPlainObject(rawSchedule)) {
|
||||
errors.push('spec.schedule: expected an object')
|
||||
} else if (!SCHEDULE_KINDS.includes(rawSchedule.kind)) {
|
||||
errors.push(`spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')}`)
|
||||
} else {
|
||||
const kind = rawSchedule.kind
|
||||
const allowedKeys = new Set(['kind', ...SCHEDULE_KEYS[kind]])
|
||||
const extra = Object.keys(rawSchedule).filter((k) => !allowedKeys.has(k))
|
||||
if (extra.length) {
|
||||
errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')} for kind "${kind}"`)
|
||||
}
|
||||
schedule = validateSchedule(kind, rawSchedule, errors)
|
||||
}
|
||||
|
||||
// ── phases ──
|
||||
const rawPhases = raw.phases
|
||||
const phases = []
|
||||
if (!Array.isArray(rawPhases) || rawPhases.length === 0) {
|
||||
errors.push('spec.phases: expected a non-empty array')
|
||||
return { ok: false, errors }
|
||||
}
|
||||
if (rawPhases.length > MAX_PHASES) {
|
||||
errors.push(`spec.phases: at most ${MAX_PHASES} phases`)
|
||||
return { ok: false, errors }
|
||||
}
|
||||
|
||||
const seenKeys = new Set()
|
||||
let totalSteps = 0
|
||||
|
||||
rawPhases.forEach((rawPhase, pi) => {
|
||||
const path = `spec.phases[${pi}]`
|
||||
if (!isPlainObject(rawPhase)) {
|
||||
errors.push(`${path}: expected an object`)
|
||||
return
|
||||
}
|
||||
const extra = Object.keys(rawPhase).filter((k) => !['key', 'label', 'steps', 'advance'].includes(k))
|
||||
if (extra.length) {
|
||||
errors.push(`${path}: unknown key(s) ${extra.join(', ')}`)
|
||||
}
|
||||
|
||||
const advance = validateAdvance(rawPhase.advance, `${path}.advance`, errors)
|
||||
|
||||
const key = rawPhase.key
|
||||
if (typeof key !== 'string' || !PHASE_KEY.test(key) || key.length > MAX_PHASE_KEY) {
|
||||
errors.push(`${path}.key: bad phase key "${key}"`)
|
||||
} else if (seenKeys.has(key)) {
|
||||
// Not cosmetic: `event_run_steps` is UNIQUE on (run_id, phase, seq), so two
|
||||
// phases sharing a key would silently collapse into one at materialisation
|
||||
// and half the authored steps would never exist.
|
||||
errors.push(`${path}.key: "${key}" is used by more than one phase`)
|
||||
} else {
|
||||
seenKeys.add(key)
|
||||
}
|
||||
|
||||
if (!rawPhase.label) errors.push(`${path}.label: a phase needs a label`)
|
||||
|
||||
const rawSteps = rawPhase.steps
|
||||
if (!Array.isArray(rawSteps)) {
|
||||
errors.push(`${path}.steps: expected an array`)
|
||||
return
|
||||
}
|
||||
if (rawSteps.length > MAX_STEPS_PER_PHASE) {
|
||||
errors.push(`${path}.steps: at most ${MAX_STEPS_PER_PHASE} steps in one phase`)
|
||||
return
|
||||
}
|
||||
totalSteps += rawSteps.length
|
||||
|
||||
const steps = []
|
||||
rawSteps.forEach((rawStep, si) => {
|
||||
const spath = `${path}.steps[${si}]`
|
||||
if (!isPlainObject(rawStep)) {
|
||||
errors.push(`${spath}: expected an object`)
|
||||
return
|
||||
}
|
||||
// `actionVersion` and `dormant` are in this list because **validate must
|
||||
// accept its own output**. A saved spec is re-validated on every later
|
||||
// save and again at publish, so a normalised field that the validator
|
||||
// itself added and then refused would make the second save of any
|
||||
// definition impossible. They are accepted and then RECOMPUTED below
|
||||
// rather than trusted: the version comes from the declaration, and
|
||||
// dormancy from whether anyone registers the action right now.
|
||||
const stepExtra = Object.keys(rawStep).filter(
|
||||
(k) => !['actionId', 'params', 'onFailure', 'label', 'actionVersion', 'dormant'].includes(k),
|
||||
)
|
||||
if (stepExtra.length) errors.push(`${spath}: unknown key(s) ${stepExtra.join(', ')}`)
|
||||
|
||||
const actionId = rawStep.actionId
|
||||
const declaration = typeof actionId === 'string' ? registries.eventAction(actionId) : null
|
||||
|
||||
if (typeof actionId !== 'string' || !actionId) {
|
||||
errors.push(`${spath}.actionId: a step needs an action`)
|
||||
return
|
||||
}
|
||||
if (!declaration && !known.has(actionId)) {
|
||||
errors.push(`${spath}.actionId: no module registers "${actionId}"`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!declaration) {
|
||||
// Dormant: kept verbatim, params untouched, and flagged so the editor and
|
||||
// the run console can both say WHY rather than showing an empty step.
|
||||
steps.push({
|
||||
actionId,
|
||||
label: rawStep.label || actionId,
|
||||
params: isPlainObject(rawStep.params) ? rawStep.params : {},
|
||||
actionVersion: Number.isInteger(rawStep.actionVersion) ? rawStep.actionVersion : 1,
|
||||
onFailure: ON_FAILURE.includes(rawStep.onFailure) ? rawStep.onFailure : 'pause',
|
||||
dormant: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const { params, errors: paramErrors } = checkParams(declaration, rawStep.params, spath)
|
||||
errors.push(...paramErrors)
|
||||
|
||||
// §F, fail closed (org lead, 2026-09-03): a step may not spend a dimension
|
||||
// no module declares as a budget. Refused HERE as well as at dispatch
|
||||
// because this is the cheap moment — the editor is open, the author is
|
||||
// looking at the step, and the alternative is a run that refuses at two in
|
||||
// the morning for a reason that was decidable when it was written.
|
||||
//
|
||||
// Only when the params validated. Pricing a step whose params were just
|
||||
// refused would run a module's `cost()` over values core has already said
|
||||
// are wrong, and report its answer as a second, confusing error about the
|
||||
// same mistake.
|
||||
if (!paramErrors.length) {
|
||||
const priced = authorize.priceOf(declaration, params)
|
||||
if (priced === null) {
|
||||
errors.push(`${spath}: "${declaration.label}" could not report what it costs`)
|
||||
} else {
|
||||
for (const dimension of authorize.undeclaredDimensions(priced)) {
|
||||
errors.push(
|
||||
`${spath}: "${declaration.label}" spends "${dimension}", which no module declares as a budget`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rawStep.onFailure !== undefined && !ON_FAILURE.includes(rawStep.onFailure)) {
|
||||
errors.push(`${spath}.onFailure: must be one of ${ON_FAILURE.join(', ')}`)
|
||||
}
|
||||
|
||||
steps.push({
|
||||
actionId,
|
||||
label: rawStep.label || declaration.label,
|
||||
params,
|
||||
// Captured at SAVE time, from the declaration this step was authored
|
||||
// against (§F). It is what lets a later bump render a warning in the
|
||||
// editor instead of dispatching a mistyped parameter.
|
||||
actionVersion: declaration.version,
|
||||
onFailure: ON_FAILURE.includes(rawStep.onFailure)
|
||||
? rawStep.onFailure
|
||||
: defaultOnFailure(declaration.risk),
|
||||
dormant: false,
|
||||
})
|
||||
})
|
||||
|
||||
// `advance` is omitted rather than written as null when there is no gate:
|
||||
// the overwhelming majority of phases have none, and a spec full of
|
||||
// `"advance": null` is a diff between two versions that says something
|
||||
// changed about every phase the first time one phase gained a gate.
|
||||
phases.push(advance ? { key, label: rawPhase.label, steps, advance } : { key, label: rawPhase.label, steps })
|
||||
})
|
||||
|
||||
if (totalSteps > MAX_STEPS) errors.push(`spec: at most ${MAX_STEPS} steps in one definition`)
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
return { ok: true, spec: { schedule, phases } }
|
||||
}
|
||||
|
||||
/** Every action id a spec names, dormant ones included. */
|
||||
const actionIdsIn = (spec) =>
|
||||
(spec?.phases || []).flatMap((p) => (p.steps || []).map((s) => s.actionId)).filter(Boolean)
|
||||
|
||||
/**
|
||||
* A spec is publishable when nothing in it is dormant.
|
||||
*
|
||||
* Separate from `validate` on purpose: a dormant step must not stop an author
|
||||
* SAVING (that is what makes an uninstall non-destructive), and it must stop
|
||||
* them PUBLISHING, because publishing is what makes a version a thing runs are
|
||||
* pinned to and a run cannot dispatch a verb nobody registers.
|
||||
*
|
||||
* **A phase's advance gate is dormant on the same rule** (Phase 5), and it is in
|
||||
* the same list because it fails for the same reason and the message already
|
||||
* reads correctly for both: a version that waits on a trigger nothing can emit
|
||||
* is a run that would never leave that phase.
|
||||
*/
|
||||
function publishable(spec) {
|
||||
const phases = spec?.phases || []
|
||||
const dormant = [
|
||||
...phases.flatMap((p) => (p.steps || []).filter((s) => s.dormant).map((s) => s.actionId)),
|
||||
...phases.filter((p) => p.advance?.dormant).map((p) => p.advance.on),
|
||||
]
|
||||
return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] }
|
||||
}
|
||||
|
||||
/** An empty, valid spec — what a newly created draft carries. */
|
||||
const emptySpec = () => ({
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [] }],
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
publishable,
|
||||
actionIdsIn,
|
||||
emptySpec,
|
||||
defaultOnFailure,
|
||||
parseAfter,
|
||||
formatAfter,
|
||||
PHASE_KEY,
|
||||
SCHEDULE_KINDS,
|
||||
ADVANCE_KINDS,
|
||||
MAX_ADVANCE_COUNT,
|
||||
MAX_AFTER_SECONDS,
|
||||
ON_FAILURE,
|
||||
ON_FAILURE_BY_RISK,
|
||||
MAX_PHASES,
|
||||
MAX_STEPS_PER_PHASE,
|
||||
MAX_STEPS,
|
||||
}
|
||||
153
server/src/events/verify.js
Normal file
153
server/src/events/verify.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// ── The dry run ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §I ("four affordances worth building in from the start") and §K's
|
||||
// last bound, in Phase 6. Materialise nothing, dispatch every step with
|
||||
// `verify: true`, and report what would happen and what it would cost.
|
||||
//
|
||||
// > **Dry run before anything unattended.** A scheduled definition that has never
|
||||
// > been verified is the case worth refusing to start; verification is cheap and
|
||||
// > it is the last point a human sees the plan.
|
||||
//
|
||||
// **What it verifies depends on the definition's state, and that is not a
|
||||
// compromise.** A `ready` definition is verified against its PUBLISHED VERSION,
|
||||
// because a published version is the only thing that ever actually runs and §K's
|
||||
// gate is about letting one run unattended. A draft is verified against its
|
||||
// working spec, because §API's note is explicit that an author prices their work
|
||||
// *before* asking an admin to publish it. The two readings do not conflict — they
|
||||
// are the same act at two moments — and the answer says which one it did.
|
||||
//
|
||||
// **Only a pass against a version is recorded.** A version is immutable, so a dry
|
||||
// run that passed against one stays true; a draft changes under the author's
|
||||
// hands, so a pass on it would be a claim about a spec that no longer exists.
|
||||
//
|
||||
// ## The finding that only exists here
|
||||
//
|
||||
// Every per-step check — is the action registered, is it enabled, does this one
|
||||
// invocation fit the cap — is a check something else also makes, at save or at
|
||||
// dispatch. **The TOTAL is not.** Three steps each spawning 15 creatures under a
|
||||
// cap of 30 pass every individual check and breach the cap on the third, at two
|
||||
// in the morning, with the world half-changed. Adding the costs up across the
|
||||
// whole version is the one thing that can only be done by looking at the plan as
|
||||
// a whole, and it is the reason a dry run is worth more than the sum of its
|
||||
// step checks.
|
||||
|
||||
const { dispatchStep } = require('./dispatch')
|
||||
const authorize = require('./authorize')
|
||||
const settingsDb = require('../model/events/eventActionSettings.db')
|
||||
const registries = require('../modules/registries')
|
||||
|
||||
/**
|
||||
* Dry-run a spec.
|
||||
*
|
||||
* `user` is the caller, so the role layer answers for *them* — an editor gets
|
||||
* told that a step needs an administrator, at the moment they can still do
|
||||
* something about it, rather than at the moment it does not run.
|
||||
*
|
||||
* Never throws: a `perform()` that explodes under `verify: true` is a finding
|
||||
* about that action, not a 500 on the author's screen. `dispatchStep` already
|
||||
* guarantees that, and this file adds no path around it.
|
||||
*/
|
||||
async function verifySpec(spec, { user = null, scope = '' } = {}) {
|
||||
const phases = spec?.phases || []
|
||||
const flat = []
|
||||
for (const phase of phases) {
|
||||
for (const [seq, step] of (phase.steps || []).entries()) {
|
||||
flat.push({ phase: phase.key, seq, step })
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await settingsDb.byIds(flat.map(({ step }) => step.actionId))
|
||||
const findings = []
|
||||
const totals = {}
|
||||
|
||||
for (const { phase, seq, step } of flat) {
|
||||
const where = { phase, seq, actionId: step.actionId, label: step.label || null }
|
||||
const action = registries.eventAction(step.actionId)
|
||||
if (!action) {
|
||||
// The same fact `publishable()` refuses on, said in the dry run's voice.
|
||||
// Reported rather than thrown so that an author sees EVERY problem in one
|
||||
// pass — a verification that stops at the first finding makes fixing a
|
||||
// twelve-step definition twelve round trips.
|
||||
findings.push({ ...where, level: 'error', code: 'dormant', message: `no module registers "${step.actionId}"` })
|
||||
continue
|
||||
}
|
||||
|
||||
const verdict = await authorize.mayInvoke({
|
||||
user,
|
||||
action,
|
||||
params: step.params || {},
|
||||
settings: settings.get(action.id) || null,
|
||||
})
|
||||
if (!verdict.ok) {
|
||||
findings.push({ ...where, level: 'error', code: verdict.code, message: verdict.reason })
|
||||
continue
|
||||
}
|
||||
|
||||
for (const [dimension, amount] of Object.entries(verdict.cost || {})) {
|
||||
totals[dimension] = (totals[dimension] || 0) + amount
|
||||
}
|
||||
|
||||
// The module's own answer. This is the half core cannot compute: whether the
|
||||
// landmark exists, whether the creature is on the allowlist, whether the
|
||||
// shard is reachable at all. `verify: true` rides through the real
|
||||
// dispatcher rather than down a second path, because a dry run down a second
|
||||
// path is a dry run OF the second path.
|
||||
const result = await dispatchStep(
|
||||
{
|
||||
id: null,
|
||||
run_id: null,
|
||||
phase,
|
||||
seq,
|
||||
action_id: step.actionId,
|
||||
params: step.params || {},
|
||||
action_version: step.actionVersion || null,
|
||||
idempotency_key: null,
|
||||
attempts: 0,
|
||||
},
|
||||
{ run: { id: null, scope }, actor: user ? user.id : null, verify: true },
|
||||
)
|
||||
if (result.outcome === 'retry' || result.outcome === 'terminal') {
|
||||
findings.push({ ...where, level: 'error', code: 'refused', message: result.error })
|
||||
} else if (result.actionVersionDrift) {
|
||||
findings.push({
|
||||
...where,
|
||||
level: 'warning',
|
||||
code: 'version-drift',
|
||||
message: `authored against version ${result.actionVersionDrift.authored}; ${step.actionId} is now version ${result.actionVersionDrift.registered}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── The whole-plan check ──
|
||||
const caps = authorize.effectiveCaps(
|
||||
flat.map(({ step }) => ({ actionId: step.actionId, params: step.params || {} })),
|
||||
settings,
|
||||
)
|
||||
const cost = Object.entries(totals)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([dimension, total]) => {
|
||||
const cap = (caps[dimension] || {}).cap ?? null
|
||||
const over = cap !== null && total > cap
|
||||
if (over) {
|
||||
findings.push({
|
||||
phase: null,
|
||||
seq: null,
|
||||
actionId: null,
|
||||
label: null,
|
||||
level: 'error',
|
||||
code: 'cap-total',
|
||||
message: `this event asks for ${total} of "${dimension}" across all its steps, and this deployment allows ${cap} per run`,
|
||||
})
|
||||
}
|
||||
return { dimension, total, cap, from: (caps[dimension] || {}).from || null, over }
|
||||
})
|
||||
|
||||
return {
|
||||
ok: !findings.some((f) => f.level === 'error'),
|
||||
steps: flat.length,
|
||||
findings,
|
||||
cost,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { verifySpec }
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS = 'id, post_id, status, created_at, updated_at'
|
||||
const COLS = 'id, post_id, run_id, status, created_at, updated_at'
|
||||
const LEG_COLS = 'job_id, leg, status, attempts, last_error, next_attempt_at'
|
||||
|
||||
async function legsFor(jobIds) {
|
||||
@@ -34,8 +34,15 @@ async function attachLegs(jobs) {
|
||||
|
||||
// Create a job and its leg rows in one go. `legs` is the registered leg id list —
|
||||
// an empty list is legal and yields a job with nothing to deliver.
|
||||
async function create(postId, legs = []) {
|
||||
const res = await query('INSERT INTO announce_jobs (post_id) VALUES (?)', [postId])
|
||||
//
|
||||
// `runId` is Phase 10's (EVENTS.md §J): a job an EVENT asked for, rather than
|
||||
// the one a news publish enqueues. It changes nothing about how the job is
|
||||
// dispatched, retried or rolled up — the whole point of reusing this pipeline is
|
||||
// that an event announcement gets the legs, the backoff and the classification
|
||||
// already written — and everything it does change is in the two places below
|
||||
// that ask "whose job is this".
|
||||
async function create(postId, legs = [], { runId = null } = {}) {
|
||||
const res = await query('INSERT INTO announce_jobs (post_id, run_id) VALUES (?, ?)', [postId, runId])
|
||||
const jobId = Number(res.insertId)
|
||||
if (legs.length > 0) {
|
||||
const values = legs.map(() => '(?, ?)').join(', ')
|
||||
@@ -65,9 +72,15 @@ async function findById(id) {
|
||||
return (await attachLegs(rows))[0]
|
||||
}
|
||||
|
||||
// **The post's OWN job, which is what `run_id IS NULL` means here.** A post may
|
||||
// now have more than one — the news publish enqueued one, and an event linked the
|
||||
// same post later — and every caller of this function is the post admin panel or
|
||||
// its retry button, which are about the news announcement. Without the clause the
|
||||
// panel would silently start rendering an event's job the moment one existed, and
|
||||
// the retry button would retry that instead.
|
||||
async function findByPostId(postId) {
|
||||
const rows = await query(
|
||||
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
|
||||
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? AND run_id IS NULL ORDER BY id DESC LIMIT 1`,
|
||||
[postId],
|
||||
)
|
||||
if (rows.length === 0) return null
|
||||
|
||||
@@ -45,6 +45,33 @@ async function enqueueIfNeeded(post, transition) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue an announcement a RUN asked for (EVENTS.md §J, Phase 10).
|
||||
*
|
||||
* The same job, the same legs, the same worker — so the town crier and Discord
|
||||
* come free, with their retry and their classification, rather than an event
|
||||
* growing a second delivery pipeline that would need both again and get them
|
||||
* subtly wrong. Two things differ, and both are about not standing on the news
|
||||
* pipeline's toes:
|
||||
*
|
||||
* **The post's back-pointer is written only when it has none.** `announce_job_id`
|
||||
* is what the post admin panel reads and what `shouldEnqueue` guards on, so
|
||||
* moving it to an event's job would make a re-published post announce itself
|
||||
* again. A post that has never been announced gains the pointer, because then
|
||||
* this job IS its announcement and the panel should show it.
|
||||
*
|
||||
* **`announced_at` is not stamped by a run's job** — see `refreshStatus`.
|
||||
*
|
||||
* Returns the new job id.
|
||||
*/
|
||||
async function enqueueForRun(postId, runId) {
|
||||
const jobId = await db.create(postId, registries.announceLegIds(), { runId })
|
||||
const post = await posts.getById(postId)
|
||||
if (post && !post.announce_job_id) await posts.linkAnnounceJob(postId, jobId)
|
||||
log.info('announce job enqueued for a run', { jobId, postId, runId })
|
||||
return jobId
|
||||
}
|
||||
|
||||
// Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of a
|
||||
// leg's classify() results: 'done' | 'retry' | 'terminal'. For 'retry' we bump the
|
||||
// attempt count and schedule the next run (or fail the leg once the cap is hit).
|
||||
@@ -82,7 +109,12 @@ async function refreshStatus(jobId) {
|
||||
const status = logic.rollupStatus(job.legs.map((l) => l.status))
|
||||
if (status !== job.status) await db.setStatus(jobId, status)
|
||||
job.status = status
|
||||
if (status === 'done') {
|
||||
// **A run's job does not stamp the post** (Phase 10). `announced_at` means
|
||||
// "when this post was announced", and an event that links a three-week-old
|
||||
// news article would otherwise rewrite that to today — making the post admin
|
||||
// panel report a publication date it does not have. The event's own record of
|
||||
// having announced is the run log line and the job's `run_id`.
|
||||
if (status === 'done' && !job.run_id) {
|
||||
try {
|
||||
await posts.markAnnounced(job.post_id)
|
||||
} catch (err) {
|
||||
@@ -127,6 +159,7 @@ async function getByPostId(postId) {
|
||||
|
||||
module.exports = {
|
||||
enqueue,
|
||||
enqueueForRun,
|
||||
shouldEnqueue,
|
||||
enqueueIfNeeded,
|
||||
recordOutcome,
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* Claim a fire for (rule, user, subject), or refuse it because the pair is still
|
||||
* cooling. ENGAGEMENT.md §4.1.
|
||||
* 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
|
||||
@@ -37,28 +43,29 @@ const { query } = require('../../utils/db')
|
||||
* `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, cooldownSeconds, now = new Date()) {
|
||||
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 = ?
|
||||
WHERE rule_id = ? AND user_id = ? AND subject_key = ? AND channel = ?
|
||||
AND last_fired_at <= ? - INTERVAL ? SECOND`,
|
||||
[now, ruleId, userId, subjectKey, now, cooldownSeconds],
|
||||
[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, last_fired_at, fire_count)
|
||||
VALUES (?, ?, ?, ?, 1)`,
|
||||
[ruleId, userId, subjectKey, now],
|
||||
`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) => {
|
||||
const get = async (ruleId, userId, subjectKey, channel) => {
|
||||
const [row] = await query(
|
||||
'SELECT * FROM engagement_cooldowns WHERE rule_id = ? AND user_id = ? AND subject_key = ?',
|
||||
[ruleId, userId, subjectKey],
|
||||
`SELECT * FROM engagement_cooldowns
|
||||
WHERE rule_id = ? AND user_id = ? AND subject_key = ? AND channel = ?`,
|
||||
[ruleId, userId, subjectKey, channel],
|
||||
)
|
||||
return row || null
|
||||
}
|
||||
@@ -71,8 +78,23 @@ const get = async (ruleId, userId, subjectKey) => {
|
||||
* 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.
|
||||
* Phase 14 gave it that caller (`engagementRetentionPrune`), which also enforces
|
||||
* the horizon-versus-longest-cooldown rule this comment names.
|
||||
*
|
||||
* `limit` bounds one statement, for `userNotificationsPrune`'s reason: a first
|
||||
* sweep after a long outage must be a series of bounded DELETEs rather than one
|
||||
* that holds locks over a million rows. Omitting it keeps the original
|
||||
* unbounded behaviour, so existing callers and tests are unaffected.
|
||||
*
|
||||
* @returns {Promise<number>} rows deleted
|
||||
*/
|
||||
const prune = (olderThan) =>
|
||||
query('DELETE FROM engagement_cooldowns WHERE last_fired_at < ?', [olderThan])
|
||||
const prune = async (olderThan, limit = 0) => {
|
||||
const bounded = Number(limit) > 0
|
||||
const result = await query(
|
||||
`DELETE FROM engagement_cooldowns WHERE last_fired_at < ?${bounded ? ' LIMIT ?' : ''}`,
|
||||
bounded ? [olderThan, Math.floor(limit)] : [olderThan],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
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 }
|
||||
@@ -22,14 +22,18 @@ const hydrate = (row) => row && { ...row, payload: parseJson(row.payload, {}) }
|
||||
async function enqueue(row) {
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO engagement_outbox
|
||||
(rule_id, trigger_id, user_id, channel, subject_key, payload, dedupe_key, due_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(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,
|
||||
@@ -128,11 +132,61 @@ async function cancel(ruleId, subjectKey, userId = null) {
|
||||
* 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(
|
||||
const reclaimStale = async (before, maxAttempts = 0) => {
|
||||
// Give up first, reclaim second, and in that order: a row that has already
|
||||
// burned its attempts must leave 'sending' as `failed`, or the reclaim below
|
||||
// hands it straight back to `findDue` and it is retried forever.
|
||||
//
|
||||
// **This is what makes `pruneTerminal` a bound at all** (Phase 14). Attempts
|
||||
// are incremented by `claim`, but `MAX_ATTEMPTS` is only consulted on a
|
||||
// graceful `retry` outcome — a send that kills the process mid-flight never
|
||||
// reaches that branch, so before this the row cycled sending → scheduled →
|
||||
// sending forever, never reached a terminal status, and was therefore never
|
||||
// eligible for any retention sweep. One poisoned payload was an outbox row
|
||||
// that outlived every horizon.
|
||||
let failed = 0
|
||||
if (Number(maxAttempts) > 0) {
|
||||
const gaveUp = await query(
|
||||
`UPDATE engagement_outbox
|
||||
SET status = 'failed', last_error = 'gave up after repeated interruptions'
|
||||
WHERE status = 'sending' AND updated_at < ? AND attempts >= ?`,
|
||||
[before, Math.floor(maxAttempts)],
|
||||
)
|
||||
failed = Number(gaveUp?.affectedRows || 0)
|
||||
}
|
||||
const reclaimed = await query(
|
||||
"UPDATE engagement_outbox SET status = 'scheduled' WHERE status = 'sending' AND updated_at < ?",
|
||||
[before],
|
||||
)
|
||||
return { failed, reclaimed: Number(reclaimed?.affectedRows || 0) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete terminal rows older than `before` (Phase 14).
|
||||
*
|
||||
* **Terminal only, and the status list is the whole policy.** A `scheduled` row
|
||||
* is a promise the engine has not kept yet — `delay_seconds` can legitimately
|
||||
* put one up to a day out (`MAX_DELAY_SECONDS`) — and a `sending` row may be a
|
||||
* worker mid-flight. Deleting either is not retention, it is cancelling a send
|
||||
* nobody asked to cancel. Only `sent`, `failed`, `cancelled` and `suppressed`
|
||||
* are outcomes that have already happened.
|
||||
*
|
||||
* `created_at` rather than `updated_at` is the clock deliberately: the horizon
|
||||
* an operator sets means "how long we keep the record of a delivery", which is
|
||||
* measured from when it was enqueued, not from whenever it was last touched.
|
||||
*
|
||||
* @returns {Promise<number>} rows deleted
|
||||
*/
|
||||
const pruneTerminal = async (before, limit = 1000) => {
|
||||
const result = await query(
|
||||
`DELETE FROM engagement_outbox
|
||||
WHERE status IN ('sent', 'failed', 'cancelled', 'suppressed')
|
||||
AND created_at < ?
|
||||
LIMIT ?`,
|
||||
[before, Math.floor(limit)],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM engagement_outbox WHERE id = ?', [id])
|
||||
@@ -153,6 +207,7 @@ module.exports = {
|
||||
finish,
|
||||
cancel,
|
||||
reclaimStale,
|
||||
pruneTerminal,
|
||||
getById,
|
||||
listForRule,
|
||||
}
|
||||
|
||||
@@ -122,4 +122,91 @@ const storedModes = async (userIds, streamId, channel) => {
|
||||
return new Map(rows.map((r) => [Number(r.user_id), r.mode]))
|
||||
}
|
||||
|
||||
module.exports = { active, staff, subscribers, filterActive, storedModes, MAX_AUDIENCE }
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
|
||||
175
server/src/model/engagement/engagementRetention.model.js
Normal file
175
server/src/model/engagement/engagementRetention.model.js
Normal file
@@ -0,0 +1,175 @@
|
||||
// ── Engagement retention policy ────────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 14. Three horizons, one place to read and write them, so
|
||||
// the nightly worker and Admin → Engagement → Retention cannot disagree about
|
||||
// what this deployment keeps — and so `/privacy` has one thing to describe.
|
||||
//
|
||||
// **The fourth engagement table is deliberately absent from this file.**
|
||||
// `engagement_suppressions` does not expire (org lead, 2026-09-01): a
|
||||
// suppression is a standing decision, and ageing out a hard bounce re-mails an
|
||||
// address that already bounced, which is how a sender loses a domain's
|
||||
// reputation. The one way out of that table stays what Phase 9 built — a
|
||||
// deliberate act by an admin, which Phase 14 only made reachable per row.
|
||||
//
|
||||
// Settings rows rather than env, for `teamActivityPrune`'s reason: an operator
|
||||
// tightening a busy shard should not need a deploy. Unlike the two workers this
|
||||
// copies, these three get a screen — the send log's horizon changes what an
|
||||
// operator-facing page can show, so it cannot be an invisible key.
|
||||
|
||||
const settings = require('../settings/settings.model')
|
||||
const rulesDb = require('./engagementRules.db')
|
||||
const log = require('../../utils/logger')('engagement')
|
||||
|
||||
/**
|
||||
* One entry per horizon. `min` is not a UI nicety — each is the point below
|
||||
* which the sweep breaks something that is not retention:
|
||||
*
|
||||
* - **cooldowns**: a pruned row makes the next fire a FIRST fire, i.e. a
|
||||
* duplicate send. `MAX_COOLDOWN_SECONDS` is a validated 86 400 (one day), so
|
||||
* 2 days is the smallest provably-safe value against any rule that can be
|
||||
* saved. `checkCooldownHorizon` re-checks it against the rules that exist.
|
||||
* - **outbox**: `MAX_DELAY_SECONDS` is also 86 400, so no `scheduled` row is
|
||||
* ever more than a day out; terminal rows younger than that are still the
|
||||
* most recent thing an operator would look at.
|
||||
* - **sends**: the per-rule hourly ceiling (§7.1 Q3) counts this table, so a
|
||||
* horizon under an hour would silently disable it. The floor is set far
|
||||
* above that, at the point the Send Log stops being worth opening.
|
||||
*/
|
||||
const HORIZONS = {
|
||||
sends: {
|
||||
key: 'engagement_sends_retain_days',
|
||||
default: 180,
|
||||
min: 7,
|
||||
max: 3650,
|
||||
label: 'Send log',
|
||||
},
|
||||
cooldowns: {
|
||||
key: 'engagement_cooldowns_retain_days',
|
||||
default: 30,
|
||||
min: 2,
|
||||
max: 3650,
|
||||
label: 'Cooldowns',
|
||||
},
|
||||
outbox: {
|
||||
key: 'engagement_outbox_retain_days',
|
||||
default: 30,
|
||||
min: 2,
|
||||
max: 3650,
|
||||
label: 'Outbox',
|
||||
},
|
||||
}
|
||||
|
||||
const NAMES = Object.keys(HORIZONS)
|
||||
|
||||
/**
|
||||
* The current policy, as `{ sends, cooldowns, outbox }` in days.
|
||||
*
|
||||
* Wrapped in a try like `teamActivity.retentionConfig` and for its reason: the
|
||||
* nightly worker calls this with nobody watching, so a settings table that is
|
||||
* briefly unavailable must yield defaults rather than an exception that kills
|
||||
* the job. An unreadable, absent, non-numeric or out-of-range value all mean
|
||||
* the same thing — use the default — because none of them is a horizon.
|
||||
*/
|
||||
async function get() {
|
||||
const out = {}
|
||||
for (const name of NAMES) {
|
||||
const spec = HORIZONS[name]
|
||||
let days = spec.default
|
||||
try {
|
||||
const raw = await settings.get(spec.key)
|
||||
const n = Number(raw)
|
||||
if (Number.isFinite(n) && n >= spec.min && n <= spec.max) days = Math.floor(n)
|
||||
} catch (err) {
|
||||
log.debug('retention setting unreadable; using the default', {
|
||||
key: spec.key,
|
||||
message: err.message,
|
||||
})
|
||||
}
|
||||
out[name] = days
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one or more horizons. Unknown names are ignored rather than rejected, so
|
||||
* a client sending the whole object back is not coupled to this list; an
|
||||
* out-of-range value IS rejected, because silently clamping a number an operator
|
||||
* typed would leave the screen showing something the deployment is not doing.
|
||||
*
|
||||
* @returns {Promise<object>} the policy as it now stands
|
||||
*/
|
||||
async function set(patch = {}, updatedBy = null) {
|
||||
for (const name of NAMES) {
|
||||
if (!(name in patch) || patch[name] === undefined || patch[name] === null) continue
|
||||
const spec = HORIZONS[name]
|
||||
const n = Number(patch[name])
|
||||
if (!Number.isFinite(n) || Math.floor(n) !== n) {
|
||||
throw Object.assign(new Error(`${spec.label} retention must be a whole number of days`), {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
if (n < spec.min || n > spec.max) {
|
||||
throw Object.assign(
|
||||
new Error(`${spec.label} retention must be between ${spec.min} and ${spec.max} days`),
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
await settings.set(spec.key, String(n), updatedBy)
|
||||
}
|
||||
return get()
|
||||
}
|
||||
|
||||
/**
|
||||
* The longest cooldown any ENABLED rule configures, in seconds — see
|
||||
* `engagementRules.db.maxEnabledCooldownSeconds` for why enabled only.
|
||||
*
|
||||
* Swallows its error rather than propagating: this is consulted by a worker
|
||||
* running on a timer, and a database hiccup must degrade the WARNING, never the
|
||||
* sweep. Zero reads as "no enabled rule has a cooldown", which produces no
|
||||
* warning — the same answer as an unreadable table, and the safe one, because
|
||||
* the alternative is a nightly alarm nobody can act on.
|
||||
*/
|
||||
async function longestEnabledCooldownSeconds() {
|
||||
try {
|
||||
return await rulesDb.maxEnabledCooldownSeconds()
|
||||
} catch (err) {
|
||||
log.debug('could not read the longest enabled cooldown', { message: err.message })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 14's acceptance line: the cooldown horizon is CHECKED against the
|
||||
* longest enabled rule's cooldown rather than picked.
|
||||
*
|
||||
* It returns a warning rather than throwing, and the worker sweeps anyway. The
|
||||
* alternative — refusing to prune — trades a bounded, describable fault (one
|
||||
* rule may re-fire early once) for the unbounded one this phase exists to end.
|
||||
* The screen surfaces the same warning, which is where an operator can act on it.
|
||||
*
|
||||
* @returns {Promise<{ ok: boolean, longestCooldownSeconds: number, message: string|null }>}
|
||||
*/
|
||||
async function checkCooldownHorizon(days) {
|
||||
const longest = await longestEnabledCooldownSeconds()
|
||||
const horizonSeconds = days * 24 * 60 * 60
|
||||
if (longest > 0 && horizonSeconds <= longest) {
|
||||
return {
|
||||
ok: false,
|
||||
longestCooldownSeconds: longest,
|
||||
message:
|
||||
`Cooldown retention is ${days} day(s), but an enabled rule has a cooldown of `
|
||||
+ `${longest} second(s). Pruning a cooldown row that is still in force makes the next `
|
||||
+ 'fire count as a first fire, so that rule can send twice. Raise the horizon.',
|
||||
}
|
||||
}
|
||||
return { ok: true, longestCooldownSeconds: longest, message: null }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HORIZONS,
|
||||
NAMES,
|
||||
get,
|
||||
set,
|
||||
longestEnabledCooldownSeconds,
|
||||
checkCooldownHorizon,
|
||||
}
|
||||
@@ -135,9 +135,25 @@ const countUsingSegment = async (segmentId) => {
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The longest cooldown any ENABLED rule configures, in seconds (Phase 14).
|
||||
*
|
||||
* Enabled only, deliberately: a disabled rule fires nothing, so it writes no
|
||||
* cooldown row a retention sweep could destroy, and letting a forgotten disabled
|
||||
* rule with a 24-hour cooldown veto a tighter horizon would make the warning
|
||||
* advice nobody can act on.
|
||||
*/
|
||||
const maxEnabledCooldownSeconds = async () => {
|
||||
const [row] = await query(
|
||||
'SELECT MAX(cooldown_seconds) AS n FROM engagement_rules WHERE enabled = 1',
|
||||
)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
getById,
|
||||
maxEnabledCooldownSeconds,
|
||||
enabledForTrigger,
|
||||
enabledCancelledBy,
|
||||
insert,
|
||||
|
||||
@@ -40,6 +40,12 @@ const MAX_DELAY_SECONDS = 86_400
|
||||
// what makes rules-as-data safe (§7.1 Q3).
|
||||
const MAX_SENDS_PER_HOUR = 10_000
|
||||
|
||||
// The one key in `template_keys` that is not a delivery channel. `teamDigestWorker`
|
||||
// renders it for a rule whose email channel an individual has set to digest mode,
|
||||
// so it belongs to a MODE rather than to the rule's channel list and can never
|
||||
// appear there.
|
||||
const DIGEST_SLOT = 'digest'
|
||||
|
||||
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/**
|
||||
@@ -91,7 +97,17 @@ async function validate(input, { existing = null } = {}) {
|
||||
errors.push('templateKeys must be an object of { channel: templateKey }')
|
||||
} else {
|
||||
for (const [channel, key] of Object.entries(raw.templateKeys || {})) {
|
||||
if (!wanted.includes(channel)) {
|
||||
// **`digest` is a template SLOT, not a channel**, and it is legal here for
|
||||
// exactly the reason `registries.js` `checkSeedRule` says it is: it names
|
||||
// the body `teamDigestWorker` renders for a rule whose email channel an
|
||||
// individual has set to digest mode, so it never appears in `channels` and
|
||||
// never could. Rejecting it made every rule that ships one unsaveable from
|
||||
// the Rules screen — core's own team and news rules included, and sixteen
|
||||
// of module-uo's — with a 400 naming a key the operator never typed, whose
|
||||
// only remedy was deleting the digest body and silently dropping digest
|
||||
// support. Found by Phase 13's acceptance walk; the two validators now
|
||||
// agree about what `digest` is.
|
||||
if (channel !== DIGEST_SLOT && !wanted.includes(channel)) {
|
||||
errors.push(`templateKeys names "${channel}", which is not one of this rule's channels`)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -107,4 +107,26 @@ const count = async (opts = {}) => {
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
module.exports = { record, countSentSince, list, count, TEST_SEND_TRIGGER }
|
||||
/**
|
||||
* Delete send-log rows older than `before` (Phase 14).
|
||||
*
|
||||
* **Every row here is terminal**, which is why this has no status filter and the
|
||||
* outbox's sweep does: `engagement_sends` records an attempt that has already
|
||||
* resolved. The care is entirely in the horizon, because this table has two live
|
||||
* readers and they pull in opposite directions — `countSentSince` implements the
|
||||
* per-rule hourly ceiling (§7.1 Q3), so any horizon under an hour silently
|
||||
* disables that ceiling, and Admin -> Engagement -> Send Log is the operator's
|
||||
* only answer to "was this person told", so a short one blinds it. Both are the
|
||||
* caller's problem, and `engagementRetention` is where that judgement lives.
|
||||
*
|
||||
* @returns {Promise<number>} rows deleted
|
||||
*/
|
||||
const prune = async (before, limit = 1000) => {
|
||||
const result = await query('DELETE FROM engagement_sends WHERE created_at < ? LIMIT ?', [
|
||||
before,
|
||||
Math.floor(limit),
|
||||
])
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { record, countSentSince, list, count, prune, TEST_SEND_TRIGGER }
|
||||
|
||||
139
server/src/model/engagement/engagementSuppressions.db.js
Normal file
139
server/src/model/engagement/engagementSuppressions.db.js
Normal file
@@ -0,0 +1,139 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* The suppression list (ENGAGEMENT.md G16, Phase 9).
|
||||
*
|
||||
* Every function here takes an ALREADY HASHED address. Hashing lives in
|
||||
* `engagement/suppressions.js` beside the masking, so the two can never disagree
|
||||
* about what a row for one address looks like; this file only reads and writes.
|
||||
*/
|
||||
|
||||
/** Is this address suppressed on this channel? The row, or null. */
|
||||
const get = async (addressHash, channel = 'email') => {
|
||||
const rows = await query(
|
||||
'SELECT * FROM engagement_suppressions WHERE address_hash = ? AND channel = ?',
|
||||
[addressHash, channel],
|
||||
)
|
||||
return rows.length ? rows[0] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress an address, or leave an existing row exactly as it is.
|
||||
*
|
||||
* `INSERT IGNORE`, deliberately, rather than an upsert. The FIRST reason an
|
||||
* address was suppressed is the true one and the one an operator needs: an
|
||||
* address that hard-bounced in March and was then manually re-added in June
|
||||
* should still read `bounce`, because that is the fact that explains the mail
|
||||
* stopping. An upsert would let the most recent write overwrite the diagnosis.
|
||||
*
|
||||
* @returns {Promise<boolean>} true when this call created the row
|
||||
*/
|
||||
const add = async (entry) => {
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO engagement_suppressions
|
||||
(address_hash, address_masked, channel, reason, detail, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
entry.address_hash,
|
||||
entry.address_masked ?? null,
|
||||
entry.channel || 'email',
|
||||
entry.reason,
|
||||
entry.detail ? String(entry.detail).slice(0, 500) : null,
|
||||
entry.created_by ?? null,
|
||||
],
|
||||
)
|
||||
return Number(result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Un-suppress an address. The one way out of this table, which is why the admin
|
||||
* screen exists at all (Phase 9 decision 3).
|
||||
*
|
||||
* @returns {Promise<boolean>} true when a row was removed
|
||||
*/
|
||||
const remove = async (addressHash, channel = 'email') => {
|
||||
const result = await query(
|
||||
'DELETE FROM engagement_suppressions WHERE address_hash = ? AND channel = ?',
|
||||
[addressHash, channel],
|
||||
)
|
||||
return Number(result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/** WHERE-clause builder shared by `list` and `count`, so the two cannot disagree. */
|
||||
const filters = ({ reason = null, channel = null, search = null } = {}) => {
|
||||
const where = []
|
||||
const params = []
|
||||
if (reason) {
|
||||
where.push('reason = ?')
|
||||
params.push(reason)
|
||||
}
|
||||
if (channel) {
|
||||
where.push('channel = ?')
|
||||
params.push(channel)
|
||||
}
|
||||
if (search) {
|
||||
// Against the MASKED column only. Searching the hash would need the caller to
|
||||
// hash first, which makes a partial search impossible, and there is no
|
||||
// plaintext column to search — that is the point of the table. A domain
|
||||
// ("example.com") is what an operator actually types here, and the mask keeps
|
||||
// the domain intact precisely so this works.
|
||||
where.push('address_masked LIKE ?')
|
||||
params.push(`%${String(search).slice(0, 120)}%`)
|
||||
}
|
||||
return { clause: where.length ? `WHERE ${where.join(' AND ')}` : '', params }
|
||||
}
|
||||
|
||||
/** The admin list, newest first. */
|
||||
const list = (opts = {}) => {
|
||||
const { clause, params } = filters(opts)
|
||||
return query(
|
||||
`SELECT * FROM engagement_suppressions ${clause} ORDER BY created_at DESC, address_hash LIMIT ? OFFSET ?`,
|
||||
[...params, opts.limit || 50, opts.offset || 0],
|
||||
)
|
||||
}
|
||||
|
||||
/** How many rows match the same filters — the paged screen's total. */
|
||||
const count = async (opts = {}) => {
|
||||
const { clause, params } = filters(opts)
|
||||
const [row] = await query(`SELECT COUNT(*) AS n FROM engagement_suppressions ${clause}`, params)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* How many suppressions per reason — the summary the screen leads with.
|
||||
*
|
||||
* A count by reason is the difference between "eleven addresses are suppressed"
|
||||
* and "eleven addresses hard-bounced", and only the second tells an operator
|
||||
* whether to go and look at their relay.
|
||||
*/
|
||||
const countsByReason = async () => {
|
||||
const rows = await query(
|
||||
'SELECT reason, COUNT(*) AS n FROM engagement_suppressions GROUP BY reason',
|
||||
)
|
||||
const out = {}
|
||||
for (const r of rows) out[r.reason] = Number(r.n || 0)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of these hashes that is suppressed, as a Set.
|
||||
*
|
||||
* One query for a whole audience rather than one per recipient. Not used by the
|
||||
* delivery path — that checks a single address at send time, after the delay
|
||||
* window, which is the only check that can be correct — but by the admin reach
|
||||
* preview, which is asked about thousands of users at once and must not become
|
||||
* thousands of round trips.
|
||||
*/
|
||||
const suppressedAmong = async (addressHashes, channel = 'email') => {
|
||||
const wanted = [...new Set((addressHashes || []).filter(Boolean))]
|
||||
if (!wanted.length) return new Set()
|
||||
const marks = wanted.map(() => '?').join(', ')
|
||||
const rows = await query(
|
||||
`SELECT address_hash FROM engagement_suppressions
|
||||
WHERE channel = ? AND address_hash IN (${marks})`,
|
||||
[channel, ...wanted],
|
||||
)
|
||||
return new Set(rows.map((r) => r.address_hash))
|
||||
}
|
||||
|
||||
module.exports = { get, add, remove, list, count, countsByReason, suppressedAmong }
|
||||
86
server/src/model/events/eventActionSettings.db.js
Normal file
86
server/src/model/events/eventActionSettings.db.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// ── event_action_settings — SQL only ───────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D/§K, and Phase 6 of EVENTS_PLAN.md. The deployment's switchboard:
|
||||
// one row per action an admin has an opinion about, and **nothing else in the
|
||||
// permission model beyond the role**.
|
||||
//
|
||||
// **A missing row is not "disabled".** It is "the default for this action's risk
|
||||
// class", and that default is computed in `eventActionSettings.model.js` from the
|
||||
// registry rather than stored here. The reason is structural: the registry is
|
||||
// assembled by `registerCore()` and by module `register()`, both of which run
|
||||
// under `routeManifest.js` and `swagger.js` against a dead pool (MODULE_API.md
|
||||
// §2.2), so a boot-time seed of one row per registered action would be precisely
|
||||
// the database write those two forbid. A deployment that never opens the
|
||||
// switchboard has no rows at all and behaves correctly.
|
||||
//
|
||||
// **Rows outlive their actions on purpose.** Uninstalling a module leaves its
|
||||
// settings standing, so re-installing restores the caps the operator chose
|
||||
// instead of silently resetting them to the default. The switchboard lists what
|
||||
// is registered *now*, so a stranded row is invisible until its action returns.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const hydrate = (row) => row && { ...row, caps: parseJson(row.caps, {}) || {} }
|
||||
|
||||
/** Every stored row, action id first. Stranded rows included — the caller filters. */
|
||||
async function all() {
|
||||
const rows = await query(
|
||||
`SELECT s.action_id, s.enabled, s.caps, s.updated_by, s.updated_at, u.username AS updated_by_username
|
||||
FROM event_action_settings s
|
||||
LEFT JOIN users u ON u.id = s.updated_by
|
||||
ORDER BY s.action_id`,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** One row, or null when the deployment has never had an opinion about this action. */
|
||||
async function get(actionId) {
|
||||
const rows = await query(
|
||||
`SELECT action_id, enabled, caps, updated_by, updated_at
|
||||
FROM event_action_settings WHERE action_id = ?`,
|
||||
[actionId],
|
||||
)
|
||||
return rows.length ? hydrate(rows[0]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows for a set of action ids, as a Map keyed by id.
|
||||
*
|
||||
* The shape the authorisation path wants: `mayInvoke` is asked about one action
|
||||
* at a time but the run start prices a whole version at once, and one query per
|
||||
* step of a twelve-step definition is twelve round trips to answer a question
|
||||
* about a table with one row per action in the process.
|
||||
*/
|
||||
async function byIds(actionIds) {
|
||||
const ids = [...new Set(actionIds || [])].filter(Boolean)
|
||||
if (!ids.length) return new Map()
|
||||
const rows = await query(
|
||||
`SELECT action_id, enabled, caps, updated_by, updated_at
|
||||
FROM event_action_settings
|
||||
WHERE action_id IN (${ids.map(() => '?').join(',')})`,
|
||||
ids,
|
||||
)
|
||||
return new Map(rows.map((r) => [r.action_id, hydrate(r)]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one action's switch and caps.
|
||||
*
|
||||
* An upsert rather than a read-then-write, for the ordinary reason: two admins on
|
||||
* the switchboard at once should leave one of their two opinions standing, not an
|
||||
* error and not a row that never appeared. There is no compare-and-set here
|
||||
* because there is nothing to race — this is configuration, and the value that
|
||||
* matters is the last one a human chose.
|
||||
*/
|
||||
async function put(actionId, { enabled, caps }, userId = null) {
|
||||
await query(
|
||||
`INSERT INTO event_action_settings (action_id, enabled, caps, updated_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), caps = VALUES(caps), updated_by = VALUES(updated_by)`,
|
||||
[actionId, enabled ? 1 : 0, JSON.stringify(caps || {}), userId],
|
||||
)
|
||||
return get(actionId)
|
||||
}
|
||||
|
||||
module.exports = { all, get, byIds, put }
|
||||
170
server/src/model/events/eventCalendar.model.js
Normal file
170
server/src/model/events/eventCalendar.model.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// ── The calendar ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §I: "month and list view, filtered by category, scope and series",
|
||||
// and Phase 4's stated deliverable — *the thing this feature exists to replace*
|
||||
// is a WordPress calendar plugin with no series field and no recurrence.
|
||||
//
|
||||
// **A calendar entry is one of two things, and the difference is not cosmetic.**
|
||||
//
|
||||
// - A **run**: a real `event_runs` row. It has an id, a status, a health, a
|
||||
// pinned version and a console. Somebody can cancel it. It exists because the
|
||||
// runner materialised it inside its fourteen-day horizon, or because an admin
|
||||
// started it by hand.
|
||||
// - A **projection**: arithmetic. There is no row, nothing to cancel, and
|
||||
// nothing has been committed to. It exists so that a monthly event is visible
|
||||
// three weeks out instead of the calendar simply ending at the horizon (org
|
||||
// lead, 2026-09-02).
|
||||
//
|
||||
// The API says which each is and the UI renders them differently, because an
|
||||
// operator acting on a projection as though it were a booking is the failure
|
||||
// this distinction exists to prevent. A projection is a forecast of what the
|
||||
// runner *will* materialise, computed by the same `occurrencesBetween` the
|
||||
// runner itself calls — one arithmetic, so the forecast cannot disagree with
|
||||
// what later appears.
|
||||
//
|
||||
// **A projection is never emitted for an instant a run already occupies**, which
|
||||
// is what keeps the fortnight inside the horizon from showing everything twice.
|
||||
// That rule also does the right thing for a CANCELLED occurrence: the row is
|
||||
// still there, so nothing re-projects it, and an event an operator called off
|
||||
// does not reappear on the calendar as though it were still coming.
|
||||
|
||||
const runsDb = require('./eventRuns.db')
|
||||
const definitionsDb = require('./eventDefinitions.db')
|
||||
const recurrence = require('../../events/recurrence')
|
||||
|
||||
// A calendar request is operator-supplied, and a year-wide window across forty
|
||||
// weekly definitions is how a month view becomes an outage. Ninety-two days is
|
||||
// a three-month view — more than the month grid and the list either need.
|
||||
const MAX_WINDOW_DAYS = 92
|
||||
const MAX_ENTRIES = 1000
|
||||
|
||||
const runEntry = (run) => ({
|
||||
kind: 'run',
|
||||
runId: run.id,
|
||||
definitionId: run.definition_id,
|
||||
title: run.definition_title,
|
||||
slug: run.definition_slug,
|
||||
seriesId: run.series_id || null,
|
||||
seriesName: run.series_name || null,
|
||||
seriesSlug: run.series_slug || null,
|
||||
scheduledFor: run.scheduled_for,
|
||||
timezone: run.timezone,
|
||||
scope: run.scope,
|
||||
status: run.status,
|
||||
health: run.health,
|
||||
version: run.version_number,
|
||||
rehearsal: Boolean(run.rehearsal),
|
||||
waitingSteps: Number(run.waiting_steps || 0),
|
||||
})
|
||||
|
||||
const projectedEntry = (definition, occurrence) => ({
|
||||
kind: 'projected',
|
||||
runId: null,
|
||||
definitionId: definition.id,
|
||||
title: definition.title,
|
||||
slug: definition.slug,
|
||||
seriesId: definition.series_id || null,
|
||||
seriesName: definition.series_name || null,
|
||||
seriesSlug: definition.series_slug || null,
|
||||
scheduledFor: occurrence.at,
|
||||
timezone: definition.timezone,
|
||||
scope: '',
|
||||
status: null,
|
||||
health: null,
|
||||
// Why this instant is not the wall clock the schedule names. Carried on the
|
||||
// projection as well as on the materialised run, so the calendar can explain
|
||||
// a DST-shifted time before it happens rather than after.
|
||||
adjusted: occurrence.adjusted,
|
||||
shiftMinutes: occurrence.shiftMinutes,
|
||||
})
|
||||
|
||||
/**
|
||||
* The calendar for a window.
|
||||
*
|
||||
* `{ ok, window, horizon, entries }` — entries ascending by instant, runs and
|
||||
* projections interleaved. `horizon` is the instant past which nothing is
|
||||
* materialised yet, so the UI can draw the line rather than infer it.
|
||||
*
|
||||
* **The instants are UTC and the placement is the client's.** A month grid has
|
||||
* one date axis and the viewer's own zone is what "this month" means to the
|
||||
* person reading it; each entry carries its own `timezone` so the time beside it
|
||||
* reads `20:00 Europe/Berlin` and nobody misreads a shard's local schedule as
|
||||
* their own. That is the split §E's "the timezone belongs to the event" implies:
|
||||
* the event owns the time, the reader owns the calendar.
|
||||
*/
|
||||
async function calendar({
|
||||
from,
|
||||
to,
|
||||
status = null,
|
||||
scope = null,
|
||||
seriesId = null,
|
||||
horizonDays = 14,
|
||||
now = new Date(),
|
||||
} = {}) {
|
||||
const start = from instanceof Date ? from : new Date(from)
|
||||
const end = to instanceof Date ? to : new Date(to)
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return { ok: false, status: 400, errors: ['from and to must be dates'] }
|
||||
}
|
||||
if (end <= start) {
|
||||
return { ok: false, status: 400, errors: ['to must be after from'] }
|
||||
}
|
||||
if (end - start > MAX_WINDOW_DAYS * recurrence.DAY_MS) {
|
||||
return { ok: false, status: 400, errors: [`the window may span at most ${MAX_WINDOW_DAYS} days`] }
|
||||
}
|
||||
|
||||
const runs = await runsDb.listInWindow({ from: start, to: end, status, scope, seriesId })
|
||||
const entries = runs.map(runEntry)
|
||||
|
||||
// Every instant a run already occupies, keyed by definition. Projections are
|
||||
// per definition at the empty scope, so the definition and the instant are the
|
||||
// whole key -- the same triple the unique index uses, with the scope fixed.
|
||||
const taken = new Set(
|
||||
runs
|
||||
.filter((r) => !r.scope)
|
||||
.map((r) => `${r.definition_id}@${new Date(r.scheduled_for).getTime()}`),
|
||||
)
|
||||
|
||||
// A status filter is a filter on RUNS. A projection has no status, so asking
|
||||
// for "everything that failed" must not answer with a forecast — it would be a
|
||||
// forecast that failed, which is not a thing.
|
||||
// The scope filter behaves the same way, and for the same reason: automatic
|
||||
// expansion is at the empty scope (org lead, 2026-09-02), so a request narrowed
|
||||
// to a named scope has no forecast to give.
|
||||
if (!status && !scope) {
|
||||
const definitions = await definitionsDb.findSchedulable()
|
||||
for (const definition of definitions) {
|
||||
if (seriesId && Number(definition.series_id) !== Number(seriesId)) continue
|
||||
const schedule = definition.version_spec?.schedule
|
||||
if (!schedule || schedule.kind === 'manual') continue
|
||||
let occurrences = []
|
||||
try {
|
||||
occurrences = recurrence.occurrencesBetween(
|
||||
schedule,
|
||||
definition.timezone || 'UTC',
|
||||
start,
|
||||
end,
|
||||
)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const occurrence of occurrences) {
|
||||
if (taken.has(`${definition.id}@${occurrence.at.getTime()}`)) continue
|
||||
entries.push(projectedEntry(definition, occurrence))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor))
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
window: { from: start, to: end },
|
||||
horizon: new Date(now.getTime() + horizonDays * recurrence.DAY_MS),
|
||||
entries: entries.slice(0, MAX_ENTRIES),
|
||||
truncated: entries.length > MAX_ENTRIES,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { calendar, MAX_WINDOW_DAYS, MAX_ENTRIES }
|
||||
213
server/src/model/events/eventDefinitions.db.js
Normal file
213
server/src/model/events/eventDefinitions.db.js
Normal file
@@ -0,0 +1,213 @@
|
||||
// ── event_definitions — SQL only ───────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D. The `.db.js` half of the pair: parameterised SQL and hydration,
|
||||
// no policy. Everything that decides whether a write is allowed lives in
|
||||
// `eventDefinitions.model.js`.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const hydrate = (row) =>
|
||||
row && {
|
||||
...row,
|
||||
spec: parseJson(row.spec, null),
|
||||
// TINYINT(1) arrives as 0/1. Every reader of this column asks a yes/no
|
||||
// question, and the public model's filters compare against a boolean.
|
||||
listed: Boolean(row.listed),
|
||||
}
|
||||
|
||||
// `current_version` is joined rather than stored: the list screen shows "v3" and
|
||||
// the column that would hold it is a denormalisation of a row this query already
|
||||
// has to reach for the publish date anyway.
|
||||
//
|
||||
// `current_version_verified_at` rides along for the same reason and answers the
|
||||
// same kind of question (Phase 6). A `ready` definition whose version has never
|
||||
// been dry-run will not start on its schedule (§K), and the one place that fact
|
||||
// is worth saying is the screen its author is already looking at -- the
|
||||
// alternative is finding out on the Friday it did not run.
|
||||
const SELECT_LIST = `
|
||||
SELECT d.*, s.name AS series_name, s.slug AS series_slug,
|
||||
v.version AS current_version,
|
||||
v.verified_at AS current_version_verified_at
|
||||
FROM event_definitions d
|
||||
LEFT JOIN event_series s ON s.id = d.series_id
|
||||
LEFT JOIN event_versions v ON v.id = d.current_version_id
|
||||
`
|
||||
|
||||
const list = async ({ state = null } = {}) => {
|
||||
const rows = state
|
||||
? await query(`${SELECT_LIST} WHERE d.state = ? ORDER BY d.updated_at DESC, d.id DESC`, [state])
|
||||
: await query(`${SELECT_LIST} ORDER BY d.updated_at DESC, d.id DESC`)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query(`${SELECT_LIST} WHERE d.id = ?`, [id])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
const getBySlug = async (slug) => {
|
||||
const [row] = await query(`${SELECT_LIST} WHERE d.slug = ?`, [slug])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/** Does any OTHER definition hold this slug? The uniqueness pre-check. */
|
||||
const slugTaken = async (slug, exceptId = null) => {
|
||||
const rows = exceptId
|
||||
? await query('SELECT id FROM event_definitions WHERE slug = ? AND id <> ?', [slug, exceptId])
|
||||
: await query('SELECT id FROM event_definitions WHERE slug = ?', [slug])
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
const insert = async (d) => {
|
||||
const result = await query(
|
||||
`INSERT INTO event_definitions
|
||||
(title, slug, summary, body, image_url, owner_module, series_id, series_order,
|
||||
concurrency_key, grace_seconds, timezone, listed, spec, created_by, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
d.title,
|
||||
d.slug,
|
||||
d.summary,
|
||||
d.body,
|
||||
d.image_url,
|
||||
d.owner_module,
|
||||
d.series_id,
|
||||
d.series_order,
|
||||
d.concurrency_key,
|
||||
d.grace_seconds,
|
||||
d.timezone,
|
||||
d.listed ? 1 : 0,
|
||||
JSON.stringify(d.spec),
|
||||
d.created_by,
|
||||
d.created_by,
|
||||
],
|
||||
)
|
||||
return result.insertId
|
||||
}
|
||||
|
||||
const update = (id, d) =>
|
||||
query(
|
||||
`UPDATE event_definitions
|
||||
SET title = ?, slug = ?, summary = ?, body = ?, image_url = ?, series_id = ?,
|
||||
series_order = ?, concurrency_key = ?, grace_seconds = ?, timezone = ?,
|
||||
listed = ?, spec = ?, updated_by = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
d.title,
|
||||
d.slug,
|
||||
d.summary,
|
||||
d.body,
|
||||
d.image_url,
|
||||
d.series_id,
|
||||
d.series_order,
|
||||
d.concurrency_key,
|
||||
d.grace_seconds,
|
||||
d.timezone,
|
||||
d.listed ? 1 : 0,
|
||||
JSON.stringify(d.spec),
|
||||
d.updated_by,
|
||||
id,
|
||||
],
|
||||
)
|
||||
|
||||
/**
|
||||
* Point a definition at the version it just published, and mark it `ready`.
|
||||
*
|
||||
* One statement, because the two halves are the same fact: `ready` means "a
|
||||
* version has been published and the schedule is live" (§E), so a state without
|
||||
* a `current_version_id` is a lie the scheduler would act on.
|
||||
*/
|
||||
const markReady = (id, versionId, userId) =>
|
||||
query(
|
||||
`UPDATE event_definitions
|
||||
SET state = 'ready', current_version_id = ?, updated_by = ?
|
||||
WHERE id = ?`,
|
||||
[versionId, userId, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* Every definition the runner should expand a recurrence for (Phase 4).
|
||||
*
|
||||
* `ready` is the whole gate, and it is deliberately the only one: EVENTS.md §E
|
||||
* defines `ready` as "a version has been published and the schedule is live", so
|
||||
* publishing IS the switch and archiving is how an operator turns a recurrence
|
||||
* off. A separate schedule-enabled flag would be a second answer to a question
|
||||
* `state` already answers, and the two would eventually disagree.
|
||||
*
|
||||
* The VERSION's spec is joined rather than the definition's working copy: the
|
||||
* draft is what an author is midway through editing, and a half-typed `weekly`
|
||||
* must never materialise anything. The pinned spec comes back with it, so the
|
||||
* whole expansion is one round trip.
|
||||
*
|
||||
* The series columns are here for the CALENDAR rather than the runner, which
|
||||
* ignores them: a projected occurrence has to be filterable and labellable by
|
||||
* its arc exactly as a materialised run is, and a second query to learn the name
|
||||
* of a row this one already reached would be two round trips for a join.
|
||||
*/
|
||||
const findSchedulable = async ({ listedOnly = false } = {}) => {
|
||||
const rows = await query(
|
||||
`SELECT d.id, d.title, d.slug, d.summary, d.image_url, d.timezone, d.grace_seconds,
|
||||
d.concurrency_key, d.current_version_id, d.series_id, v.spec AS version_spec,
|
||||
s.name AS series_name, s.slug AS series_slug
|
||||
FROM event_definitions d
|
||||
JOIN event_versions v ON v.id = d.current_version_id
|
||||
LEFT JOIN event_series s ON s.id = d.series_id
|
||||
WHERE d.state = 'ready'${listedOnly ? ' AND d.listed = 1' : ''}
|
||||
ORDER BY d.id`,
|
||||
)
|
||||
return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* One definition by slug, for the PUBLIC event page (Phase 14a).
|
||||
*
|
||||
* `ready` and `listed` are both in the WHERE rather than checked by the caller,
|
||||
* so an unlisted event answers exactly as a nonexistent one does — a 404 that
|
||||
* cannot be told from "no such slug". A caller that filtered afterwards would
|
||||
* be one forgotten early-return away from publishing a draft.
|
||||
*
|
||||
* An ARCHIVED definition is deliberately absent too. Archiving is what delete
|
||||
* means on the admin screen, and a page that kept answering afterwards would
|
||||
* make the only delete this feature has do nothing an operator could see.
|
||||
*/
|
||||
const getPublicBySlug = async (slug) => {
|
||||
const [row] = await query(
|
||||
`${SELECT_LIST} WHERE d.slug = ? AND d.state = 'ready' AND d.listed = 1`,
|
||||
[slug],
|
||||
)
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/** Every listed, ready definition in one series, in the arc's own order. */
|
||||
const listPublicBySeries = async (seriesId) => {
|
||||
const rows = await query(
|
||||
`${SELECT_LIST}
|
||||
WHERE d.series_id = ? AND d.state = 'ready' AND d.listed = 1
|
||||
ORDER BY d.series_order, d.id`,
|
||||
[seriesId],
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive. Never a hard delete while runs reference it (§ API surface) — and the
|
||||
* schema would refuse one anyway, because `event_runs.version_id` RESTRICTs.
|
||||
* Archiving is what "delete" means on this screen, and the row keeps its history.
|
||||
*/
|
||||
const archive = (id, userId) =>
|
||||
query("UPDATE event_definitions SET state = 'archived', updated_by = ? WHERE id = ?", [userId, id])
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
getById,
|
||||
getBySlug,
|
||||
getPublicBySlug,
|
||||
listPublicBySeries,
|
||||
slugTaken,
|
||||
findSchedulable,
|
||||
insert,
|
||||
update,
|
||||
markReady,
|
||||
archive,
|
||||
}
|
||||
413
server/src/model/events/eventDefinitions.model.js
Normal file
413
server/src/model/events/eventDefinitions.model.js
Normal file
@@ -0,0 +1,413 @@
|
||||
// ── Event definitions — the save path ──────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and "Versioning, and editing a live event". A definition is
|
||||
// operator-authored data, and this file is the boundary that decides whether a
|
||||
// version of it may exist. The authoring UI (Phase 3, then Phase 13) will
|
||||
// re-check some of this for the sake of a good inline error; that second copy is
|
||||
// expected to drift, so this one is the one that decides, and a definition
|
||||
// arriving by any other route gets the same answer.
|
||||
//
|
||||
// **The three rules with teeth, and each is a rule about time rather than about
|
||||
// shape:**
|
||||
//
|
||||
// 1. Publishing SNAPSHOTS. It copies the working spec into an immutable
|
||||
// `event_versions` row and points `current_version_id` at it. Editing
|
||||
// afterwards is free and does not touch the row a live run pinned.
|
||||
// 2. A dormant step blocks a PUBLISH and never a SAVE. An uninstalled module
|
||||
// must not make an author's work uneditable, and it must not let a version be
|
||||
// published that names a verb nobody can perform.
|
||||
// 3. Archiving is what "delete" means here. `event_runs.version_id` RESTRICTs, so
|
||||
// a hard delete of a definition that has ever run is refused by the database
|
||||
// anyway — and the row's history is the thing an audit reads.
|
||||
|
||||
const db = require('./eventDefinitions.db')
|
||||
const versionsDb = require('./eventVersions.db')
|
||||
const runsDb = require('./eventRuns.db')
|
||||
const logDb = require('./eventRunLog.db')
|
||||
const seriesDb = require('./eventSeries.db')
|
||||
const spec = require('../../events/spec')
|
||||
const authorize = require('../../events/authorize')
|
||||
const verifier = require('../../events/verify')
|
||||
const registries = require('../../modules/registries')
|
||||
const { slugify, uniqueSlug } = require('../teams/teamSlug')
|
||||
const { cleanBody } = require('../../utils/sanitizeHtml')
|
||||
|
||||
const MAX_TITLE = 200
|
||||
const MAX_SUMMARY = 500
|
||||
const MAX_URL = 500
|
||||
const MAX_CONCURRENCY_KEY = 190
|
||||
|
||||
// A day either side of §D's default. Below a minute the grace window cannot
|
||||
// survive a single slow boot; above a day a "missed" occurrence would start
|
||||
// silently the following afternoon, which is the exact behaviour §E forbids.
|
||||
const MIN_GRACE_SECONDS = 60
|
||||
const MAX_GRACE_SECONDS = 86_400
|
||||
|
||||
/**
|
||||
* IANA zone names, checked against the platform's own database rather than a
|
||||
* list. `Intl.DateTimeFormat` throws `RangeError` on an unknown zone, and Node
|
||||
* ships the full tzdata — so this is the same check Phase 4's occurrence
|
||||
* arithmetic will make, asked one screen earlier where an operator can fix it.
|
||||
*/
|
||||
function isTimezone(tz) {
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: tz })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
const trimOrNull = (v, max) => {
|
||||
if (v === undefined || v === null) return null
|
||||
const s = String(v).trim()
|
||||
return s === '' ? null : s.slice(0, max)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an incoming definition against the registries and the schema.
|
||||
*
|
||||
* `{ ok: true, definition }` with a normalised row ready for insert/update, or
|
||||
* `{ ok: false, errors }` listing every problem rather than the first.
|
||||
*
|
||||
* `existing` is the row being edited, or null on a create. It is what lets a
|
||||
* dormant step survive: the ids already in the saved spec widen what the spec
|
||||
* validator will accept, so an uninstall is never destructive after the fact.
|
||||
*/
|
||||
async function validate(input, { existing = null } = {}) {
|
||||
const errors = []
|
||||
const body = isPlainObject(input) ? input : {}
|
||||
|
||||
const title = trimOrNull(body.title, MAX_TITLE)
|
||||
if (!title) errors.push('title is required')
|
||||
|
||||
const summary = trimOrNull(body.summary, MAX_SUMMARY)
|
||||
const imageUrl = trimOrNull(body.imageUrl, MAX_URL)
|
||||
const concurrencyKey = trimOrNull(body.concurrencyKey, MAX_CONCURRENCY_KEY)
|
||||
|
||||
// The storyline. Sanitized on write, exactly as a wiki page and a forum post
|
||||
// are: it is author-supplied HTML that ends up on a public page.
|
||||
const storyline = body.body === undefined || body.body === null ? null : cleanBody(String(body.body))
|
||||
|
||||
const timezone = trimOrNull(body.timezone, 64) || existing?.timezone || 'UTC'
|
||||
if (!isTimezone(timezone)) errors.push(`timezone: "${timezone}" is not an IANA zone name`)
|
||||
|
||||
const graceRaw = body.graceSeconds === undefined ? (existing?.grace_seconds ?? 900) : body.graceSeconds
|
||||
const graceSeconds = Number(graceRaw)
|
||||
if (
|
||||
!Number.isInteger(graceSeconds) ||
|
||||
graceSeconds < MIN_GRACE_SECONDS ||
|
||||
graceSeconds > MAX_GRACE_SECONDS
|
||||
) {
|
||||
errors.push(`graceSeconds must be an integer ${MIN_GRACE_SECONDS}..${MAX_GRACE_SECONDS}`)
|
||||
}
|
||||
|
||||
let seriesId = null
|
||||
if (body.seriesId !== undefined && body.seriesId !== null && body.seriesId !== '') {
|
||||
seriesId = Number(body.seriesId)
|
||||
if (!Number.isInteger(seriesId) || seriesId < 1) {
|
||||
errors.push('seriesId must be an integer')
|
||||
seriesId = null
|
||||
} else if (!(await seriesDb.exists(seriesId))) {
|
||||
// Checked here as well as by the foreign key, because a 1452 reaching a
|
||||
// controller is a 500 and this is a 400 an author can act on.
|
||||
errors.push(`seriesId ${seriesId} does not exist`)
|
||||
seriesId = null
|
||||
}
|
||||
} else if (existing) {
|
||||
seriesId = existing.series_id
|
||||
}
|
||||
|
||||
const seriesOrderRaw = body.seriesOrder === undefined ? (existing?.series_order ?? 0) : body.seriesOrder
|
||||
const seriesOrder = Number(seriesOrderRaw)
|
||||
if (!Number.isInteger(seriesOrder)) errors.push('seriesOrder must be an integer')
|
||||
|
||||
// Whether this event is announced on the public calendar (Phase 14a). It is
|
||||
// NOT whether it may run: `state` answers that, and the two are separate
|
||||
// precisely because publishing is what makes a definition runnable — an
|
||||
// unlisted event still schedules, still runs and is still on the admin
|
||||
// calendar. A missing key means "leave it as it was", and a NEW definition
|
||||
// defaults to listed, which is the column's own default and the ordinary
|
||||
// case; unlisting is the deliberate act.
|
||||
const listed = body.listed === undefined ? (existing ? Boolean(existing.listed) : true) : Boolean(body.listed)
|
||||
|
||||
// ── the spec ──
|
||||
const rawSpec = body.spec === undefined ? existing?.spec ?? spec.emptySpec() : body.spec
|
||||
const known = existing?.spec ? spec.actionIdsIn(existing.spec) : []
|
||||
const checked = spec.validate(rawSpec, { knownActionIds: known })
|
||||
if (!checked.ok) errors.push(...checked.errors)
|
||||
|
||||
// ── the slug ──
|
||||
//
|
||||
// Derived from the title on create and FROZEN afterwards, like a Team's: the
|
||||
// public event page lives at it, and a retitle must not break a link somebody
|
||||
// posted in Discord. An author who genuinely needs a different address makes a
|
||||
// new definition.
|
||||
let slug = existing?.slug || null
|
||||
if (!slug) {
|
||||
const stem = slugify(title || '') || 'event'
|
||||
const taken = (await db.list()).map((d) => d.slug)
|
||||
slug = uniqueSlug(stem, taken, { fallback: 'event' })
|
||||
}
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
definition: {
|
||||
title,
|
||||
slug,
|
||||
summary,
|
||||
body: storyline,
|
||||
image_url: imageUrl,
|
||||
owner_module: existing?.owner_module ?? null,
|
||||
series_id: seriesId,
|
||||
series_order: seriesOrder,
|
||||
concurrency_key: concurrencyKey,
|
||||
grace_seconds: graceSeconds,
|
||||
timezone,
|
||||
listed,
|
||||
spec: checked.spec,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a draft. */
|
||||
async function create(input, userId, { role = null } = {}) {
|
||||
const result = await validate(input)
|
||||
if (!result.ok) return result
|
||||
const floor = checkRoleFloor(result.definition.spec, role)
|
||||
if (floor) return floor
|
||||
const id = await db.insert({ ...result.definition, created_by: userId })
|
||||
return { ok: true, id, definition: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a definition.
|
||||
*
|
||||
* An `archived` definition is not editable. That is the one state check here,
|
||||
* and it is a real one rather than a formality: archiving is how a definition is
|
||||
* retired, and a retired definition that can still be edited is a definition
|
||||
* somebody will edit and then wonder why it never runs.
|
||||
*/
|
||||
async function save(id, input, userId, { role = null } = {}) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (existing.state === 'archived') {
|
||||
return { ok: false, status: 409, errors: ['an archived definition cannot be edited'] }
|
||||
}
|
||||
const result = await validate(input, { existing })
|
||||
if (!result.ok) return result
|
||||
const floor = checkRoleFloor(result.definition.spec, role)
|
||||
if (floor) return floor
|
||||
await db.update(id, { ...result.definition, updated_by: userId })
|
||||
return { ok: true, id, definition: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish: snapshot the working spec into an immutable version and go `ready`.
|
||||
*
|
||||
* The spec is re-validated here against the registries as they stand RIGHT NOW,
|
||||
* not trusted from the save that wrote it. A module uninstalled between the two
|
||||
* is the whole reason: the save was legitimate, and publishing a version whose
|
||||
* steps name a verb nobody can perform would be a run that fails at dispatch
|
||||
* with the world half-changed.
|
||||
*/
|
||||
async function publish(id, userId) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (existing.state === 'archived') {
|
||||
return { ok: false, status: 409, errors: ['an archived definition cannot be published'] }
|
||||
}
|
||||
|
||||
const checked = spec.validate(existing.spec, {
|
||||
knownActionIds: spec.actionIdsIn(existing.spec),
|
||||
})
|
||||
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
|
||||
|
||||
const publishable = spec.publishable(checked.spec)
|
||||
if (!publishable.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
errors: [
|
||||
`cannot publish: no module registers ${publishable.dormant.join(', ')}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
if (!checked.spec.phases.some((p) => p.steps.length)) {
|
||||
// An empty event publishes cleanly and then does nothing, which looks like a
|
||||
// broken run rather than an empty one. Refusing costs an author one click and
|
||||
// saves an operator a diagnosis.
|
||||
return { ok: false, status: 400, errors: ['cannot publish: no phase has any steps'] }
|
||||
}
|
||||
|
||||
const version = await versionsDb.nextVersion(id)
|
||||
const versionId = await versionsDb.insert(id, version, checked.spec, userId)
|
||||
await db.markReady(id, versionId, userId)
|
||||
|
||||
// Occurrences already materialised ahead of their instant move to the new
|
||||
// version; ones that have begun do not (org lead, 2026-09-02). Logged per run
|
||||
// rather than only counted, because "which version did this run actually use"
|
||||
// is the first question an audit asks and the pin is no longer immutable while
|
||||
// a run is still `scheduled`.
|
||||
const pending = await runsDb.listScheduledFor(id)
|
||||
const stale = pending.filter((r) => Number(r.version_id) !== Number(versionId))
|
||||
const repinned = stale.length ? await runsDb.repinScheduled(id, versionId) : 0
|
||||
for (const run of stale) {
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'run.status',
|
||||
detail: {
|
||||
to: 'scheduled',
|
||||
repinned: true,
|
||||
fromVersionId: run.version_id,
|
||||
toVersionId: versionId,
|
||||
toVersion: version,
|
||||
by: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true, versionId, version, repinned, definition: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive.
|
||||
*
|
||||
* Refused while a run of this definition is still in flight — not because the
|
||||
* database would object (it would not; archiving is an UPDATE), but because the
|
||||
* screen the run is on reads its title and state from here, and retiring a
|
||||
* definition mid-run makes the console describe something that is no longer
|
||||
* supposed to exist. Cancel the run, then archive.
|
||||
*/
|
||||
async function archive(id, userId) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (existing.state === 'archived') return { ok: true, definition: existing }
|
||||
|
||||
const active = await runsDb.countActiveForDefinition(id)
|
||||
if (active > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
errors: [`cannot archive: ${active} run(s) of this definition are still in flight`],
|
||||
}
|
||||
}
|
||||
await db.archive(id, userId)
|
||||
return { ok: true, definition: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The role floor on a spec's steps (§K, Phase 6).
|
||||
*
|
||||
* §K's table reads "any step whose action is above `notify` — `admin` only", and
|
||||
* the line falls between `inspect` and `change` for the reason the default-off
|
||||
* rule does (org lead, 2026-09-03): an `inspect` action reads state and writes
|
||||
* nothing, and an editor who cannot author a step that WAITS has an authoring
|
||||
* role that cannot author.
|
||||
*
|
||||
* Checked at SAVE rather than only at publish, which is the difference between
|
||||
* telling an editor now and telling them after they have written twelve steps.
|
||||
* Publish re-checks anyway — it re-checks everything, against the registries as
|
||||
* they stand at that moment — because an action's risk class is a module's
|
||||
* declaration and a module can be upgraded between the two.
|
||||
*/
|
||||
function worldChangingSteps(specValue) {
|
||||
const out = []
|
||||
for (const phase of specValue?.phases || []) {
|
||||
for (const step of phase.steps || []) {
|
||||
const action = registries.eventAction(step.actionId)
|
||||
if (action && authorize.changesWorld(action)) out.push(action)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function checkRoleFloor(specValue, role) {
|
||||
if (!role || role === 'admin') return null
|
||||
const blocked = worldChangingSteps(specValue)
|
||||
if (!blocked.length) return null
|
||||
const names = [...new Set(blocked.map((a) => `"${a.label}"`))]
|
||||
// Agreement, because this sentence is read by the person it refuses. The list
|
||||
// is almost always one long -- an editor adds one world-changing step and is
|
||||
// stopped -- so `"Spawn creatures" change the world` is the case that shows,
|
||||
// and it reads as a bug in the sentence rather than a rule about the step.
|
||||
const one = names.length === 1
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
errors: [
|
||||
`${names.join(', ')} ${one ? 'changes' : 'change'} the world, so only an administrator may author a step that uses ${one ? 'it' : 'them'}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry-run a definition, and record the pass when there is a version to record it
|
||||
* on (Phase 6).
|
||||
*
|
||||
* The target follows the definition's state: a `ready` definition is verified
|
||||
* against the version that would actually run, a draft against the working spec
|
||||
* the author is still holding. See `events/verify.js` for why that is one act at
|
||||
* two moments rather than two rules.
|
||||
*/
|
||||
async function verify(id, user) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (existing.state === 'archived') {
|
||||
return { ok: false, status: 409, errors: ['an archived definition cannot be verified'] }
|
||||
}
|
||||
|
||||
const version =
|
||||
existing.state === 'ready' && existing.current_version_id
|
||||
? await versionsDb.getById(existing.current_version_id)
|
||||
: null
|
||||
const target = version?.spec || existing.spec
|
||||
if (!target?.phases?.length) {
|
||||
return { ok: false, status: 409, errors: ['this definition has no phases to verify'] }
|
||||
}
|
||||
|
||||
const report = await verifier.verifySpec(target, { user })
|
||||
|
||||
if (version && report.ok) {
|
||||
await versionsDb.markVerified(version.id, user?.id || null)
|
||||
// Written to every run already pinned to this version, because that is where
|
||||
// an operator asks the question: a scheduled occurrence that was being held
|
||||
// is now going to start, and the line saying why belongs on it.
|
||||
for (const run of await runsDb.listScheduledFor(id)) {
|
||||
if (Number(run.version_id) !== Number(version.id)) continue
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'version.verified',
|
||||
detail: { versionId: version.id, version: version.version, by: user?.id || null },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
report,
|
||||
// Which spec was verified, said plainly, because the two answer different
|
||||
// questions and a report that did not say would be read as the other one.
|
||||
target: version ? 'version' : 'draft',
|
||||
versionId: version?.id || null,
|
||||
version: version?.version || null,
|
||||
recorded: Boolean(version && report.ok),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
create,
|
||||
save,
|
||||
publish,
|
||||
archive,
|
||||
verify,
|
||||
checkRoleFloor,
|
||||
isTimezone,
|
||||
MIN_GRACE_SECONDS,
|
||||
MAX_GRACE_SECONDS,
|
||||
}
|
||||
23
server/src/model/events/eventJson.js
Normal file
23
server/src/model/events/eventJson.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// ── One JSON reader for the whole events model ─────────────────────────────
|
||||
//
|
||||
// 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 in this directory goes through this, so no caller has to know which it
|
||||
// got.
|
||||
//
|
||||
// Lifted from `engagementRules.db.js`, which learned it first, and hoisted into
|
||||
// its own file here rather than copied into six: six copies of a fallback is six
|
||||
// chances for one of them to fall back to `{}` where the reader expects `[]`.
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { parseJson }
|
||||
187
server/src/model/events/eventPhaseGates.db.js
Normal file
187
server/src/model/events/eventPhaseGates.db.js
Normal file
@@ -0,0 +1,187 @@
|
||||
// ── event_run_phase_gates — SQL only ───────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §E, and Phase 5 of EVENTS_PLAN.md. A phase used to advance on one
|
||||
// fact — every step terminal — and that fact lives in `event_run_steps`. An
|
||||
// advance CONDITION is a second fact, and it is the only one in this feature
|
||||
// that is not derivable from a row somebody already wrote: `{ on:
|
||||
// 'uo.champ.boss_up', count: 3 }` counts things that happen between one tick and
|
||||
// the next, and the runner is not running when they happen. A gate row is where
|
||||
// a firing is counted at the moment it fires.
|
||||
//
|
||||
// **Two writers, and they are not the same process leg.** The RUNNER opens a
|
||||
// gate (at phase entry) and closes an `after` one (when its deadline passes);
|
||||
// the EMIT PATH increments and closes an `on` one. Everything here is therefore
|
||||
// written as a single guarded statement rather than a read-then-write, which is
|
||||
// the same argument `event_run_budget`'s conditional increment makes one phase
|
||||
// early and the same one `runsDb.transition` makes for a status.
|
||||
//
|
||||
// **Nothing here throws at the emit path.** `observe` is called from inside a
|
||||
// game-event handler by way of `ctx.events.emit`, exactly as `engine.dispatch`
|
||||
// is, and a database problem of core's must not become a module's control flow.
|
||||
// The catch lives in `events/gates.js`; this file is the statements.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const hydrate = (row) =>
|
||||
row && {
|
||||
...row,
|
||||
conditions: parseJson(row.conditions, null),
|
||||
last_event: parseJson(row.last_event, null),
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a phase's gate. **INSERT IGNORE against `uq_evgate_phase`**, so a process
|
||||
* that died between entering a phase and getting here opens no second gate on
|
||||
* the next tick — the idempotence `materialisePhase` has, for the same reason.
|
||||
*
|
||||
* Answers whether a row was created, which is what lets the caller log
|
||||
* `phase.entered`'s gate detail exactly once.
|
||||
*/
|
||||
async function open({ runId, phase, kind, afterSeconds = null, triggerId = null, conditions = null, needed = 1, now = new Date() }) {
|
||||
// `due_at` is computed here, once, from the moment the phase was entered —
|
||||
// never re-derived on a later tick from a `now` that has moved. A deadline
|
||||
// recomputed every fifteen seconds is a deadline that never arrives.
|
||||
const dueAt = kind === 'after' ? new Date(now.getTime() + afterSeconds * 1000) : null
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO event_run_phase_gates
|
||||
(run_id, phase, kind, after_seconds, trigger_id, conditions, needed, entered_at, due_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
runId,
|
||||
phase,
|
||||
kind,
|
||||
afterSeconds,
|
||||
triggerId,
|
||||
conditions === null ? null : JSON.stringify(conditions),
|
||||
needed,
|
||||
now,
|
||||
dueAt,
|
||||
],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/** One run's gate for one phase, or null. */
|
||||
const forPhase = async (runId, phase) =>
|
||||
hydrate(
|
||||
(
|
||||
await query('SELECT * FROM event_run_phase_gates WHERE run_id = ? AND phase = ? LIMIT 1', [
|
||||
runId,
|
||||
phase,
|
||||
])
|
||||
)[0] || null,
|
||||
)
|
||||
|
||||
/** Every gate a run has ever opened, oldest first — what the run console reads. */
|
||||
const listForRun = async (runId) =>
|
||||
(
|
||||
await query('SELECT * FROM event_run_phase_gates WHERE run_id = ? ORDER BY entered_at, id', [
|
||||
runId,
|
||||
])
|
||||
).map(hydrate)
|
||||
|
||||
/**
|
||||
* Every OPEN gate waiting on one trigger, with the run's status and phase.
|
||||
*
|
||||
* This is the emit path's only query and the one index in this feature on a hot
|
||||
* path. The join is what keeps a gate belonging to a cancelled run from counting
|
||||
* for ever: a run that will never advance again must stop tallying, and its
|
||||
* row's `satisfied_at` is not what says so.
|
||||
*
|
||||
* **`paused` counts.** The world does not stop because an operator paused the
|
||||
* console, and discarding firings that arrived during a pause would make pause a
|
||||
* destructive control — the tally an operator came back to would be lower than
|
||||
* the one they left, with nothing recording the difference.
|
||||
*/
|
||||
const openForTrigger = async (triggerId) =>
|
||||
(
|
||||
await query(
|
||||
`SELECT g.* FROM event_run_phase_gates g
|
||||
JOIN event_runs r ON r.id = g.run_id
|
||||
WHERE g.trigger_id = ? AND g.satisfied_at IS NULL
|
||||
AND r.status IN ('running','paused')
|
||||
AND r.current_phase = g.phase
|
||||
LIMIT 200`,
|
||||
[triggerId],
|
||||
)
|
||||
).map(hydrate)
|
||||
|
||||
/**
|
||||
* Count one matching firing, and close the gate if that was the last one needed.
|
||||
*
|
||||
* **One statement, with the threshold inside it.** Two emits arriving together
|
||||
* each add one and exactly one of them crosses `needed`; a read-then-write would
|
||||
* let both see 2 of 3 and neither satisfy, or both satisfy and advance a phase
|
||||
* twice. `WHERE satisfied_at IS NULL` is what makes a late arrival a no-op
|
||||
* rather than a tally that keeps climbing after the phase moved on.
|
||||
*
|
||||
* Answers `{ counted, satisfied }` read back from the row, so the caller logs
|
||||
* the tally the database actually holds rather than the one it predicted.
|
||||
*/
|
||||
async function count(gateId, { lastEvent = null, now = new Date() } = {}) {
|
||||
// **THE INCREMENT MUST BE LAST, and this is not style.** MariaDB evaluates an
|
||||
// UPDATE's SET assignments LEFT TO RIGHT, each one seeing the values already
|
||||
// assigned by the ones before it — which is a documented departure from
|
||||
// standard SQL, and it is invisible in a stub. With `tally = tally + 1` first,
|
||||
// the CASE that follows reads the ALREADY-INCREMENTED tally, so `tally + 1 >=
|
||||
// needed` is really `new + 1 >= needed` and a gate needing two firings closes
|
||||
// on the first. Written this way, both CASEs see the old tally and say exactly
|
||||
// what they read as. `eventRunnerSql.test.js` is what catches a reorder, and
|
||||
// it is what caught this one.
|
||||
const result = await query(
|
||||
`UPDATE event_run_phase_gates
|
||||
SET satisfied_at = CASE WHEN tally + 1 >= needed THEN ? ELSE NULL END,
|
||||
satisfied_by = CASE WHEN tally + 1 >= needed THEN 'condition' ELSE NULL END,
|
||||
last_event = ?,
|
||||
last_event_at = ?,
|
||||
tally = tally + 1
|
||||
WHERE id = ? AND satisfied_at IS NULL`,
|
||||
[now, lastEvent === null ? null : JSON.stringify(lastEvent), now, gateId],
|
||||
)
|
||||
if (Number(result?.affectedRows || 0) !== 1) return { counted: false, satisfied: false }
|
||||
const row = await byId(gateId)
|
||||
return { counted: true, satisfied: Boolean(row?.satisfied_at), tally: row?.tally ?? null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a firing was seen and did NOT match.
|
||||
*
|
||||
* Only `last_event_at` and `last_event` move: the tally is what the phase is
|
||||
* waiting on, and a near miss is not progress. It is recorded at all because
|
||||
* "the boss did spawn, in the wrong region" and "no boss has spawned" are
|
||||
* different answers to the operator's question, and only this column can tell
|
||||
* them apart on a screen.
|
||||
*/
|
||||
async function noteNearMiss(gateId, { lastEvent = null, now = new Date() } = {}) {
|
||||
const result = await query(
|
||||
`UPDATE event_run_phase_gates
|
||||
SET last_event = ?, last_event_at = ?
|
||||
WHERE id = ? AND satisfied_at IS NULL`,
|
||||
[lastEvent === null ? null : JSON.stringify(lastEvent), now, gateId],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a gate for a reason that is not a matching firing: `'elapsed'` when an
|
||||
* `after` deadline passed, `'forced'` when a human pressed advance.
|
||||
*
|
||||
* Guarded on `satisfied_at IS NULL` like everything else here, so a force that
|
||||
* races the tick that would have opened the gate anyway loses harmlessly and the
|
||||
* log records whichever actually happened rather than both.
|
||||
*/
|
||||
async function satisfy(gateId, by, { userId = null, now = new Date() } = {}) {
|
||||
const result = await query(
|
||||
`UPDATE event_run_phase_gates
|
||||
SET satisfied_at = ?, satisfied_by = ?, forced_by = ?
|
||||
WHERE id = ? AND satisfied_at IS NULL`,
|
||||
[now, by, userId, gateId],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
const byId = async (id) =>
|
||||
hydrate((await query('SELECT * FROM event_run_phase_gates WHERE id = ? LIMIT 1', [id]))[0] || null)
|
||||
|
||||
module.exports = { open, forPhase, byId, listForRun, openForTrigger, count, noteNearMiss, satisfy }
|
||||
440
server/src/model/events/eventPublic.model.js
Normal file
440
server/src/model/events/eventPublic.model.js
Normal file
@@ -0,0 +1,440 @@
|
||||
// ── The public event surface ───────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md § API surface, and Phase 14a of EVENTS_PLAN.md: the calendar an
|
||||
// anonymous visitor reads, one event's page, and an arc.
|
||||
//
|
||||
// **This file is a projection, and the projection is the security boundary.**
|
||||
// Every other reader of these tables is staff, and every field they are shown is
|
||||
// one somebody with a role was allowed to see. What comes out of here is read by
|
||||
// nobody at all, so the rule is the opposite of the admin shapes': nothing is
|
||||
// spread, and a field reaches a public entry because a line below put it there.
|
||||
// The day somebody adds a column to `event_runs` — a claim token, an operator's
|
||||
// note, a last error — a `{ ...run }` anywhere here would publish it, silently,
|
||||
// in the release after the one anybody reviewed.
|
||||
//
|
||||
// Three things are absent from every shape below, and each is a decision:
|
||||
//
|
||||
// • **The spec.** Phases, steps, actions and their params are the plan for
|
||||
// changing a live world. A visitor is told what is happening and when, and
|
||||
// the LABEL of the phase while it is happening; the steps are the operator's.
|
||||
// • **Health, cleanup, claims and errors.** A degraded run is a fact about the
|
||||
// deployment's plumbing. "The event is running" is the fact about the event.
|
||||
// • **`member_key`.** It is the game's own identifier for a character, it is
|
||||
// module-opaque, and core cannot say what it discloses — so it stays unsent
|
||||
// even on a results table where every other column is published.
|
||||
//
|
||||
// **What makes something public is `listed` AND `ready` AND not a rehearsal**,
|
||||
// and all three live in SQL (`eventDefinitions.db.getPublicBySlug`, and
|
||||
// `publicOnly` on `eventRuns.db.listInWindow`). Filtering in JavaScript after
|
||||
// the read would work exactly as well, right up until the first caller that
|
||||
// forgot to.
|
||||
|
||||
const definitionsDb = require('./eventDefinitions.db')
|
||||
const runsDb = require('./eventRuns.db')
|
||||
const seriesDb = require('./eventSeries.db')
|
||||
const versionsDb = require('./eventVersions.db')
|
||||
const participantsDb = require('./eventRunParticipants.db')
|
||||
const calendarModel = require('./eventCalendar.model')
|
||||
const recurrence = require('../../events/recurrence')
|
||||
|
||||
// The public calendar's window when a caller names neither end: a few days BACK
|
||||
// through a month out. A visitor arriving at /site/events wants "what is on", and
|
||||
// a client that had to compute a window before it could ask anything would make
|
||||
// every deep link carry two ISO instants.
|
||||
//
|
||||
// **The backward tail is not padding — it is the "recent" in §I's "upcoming, live
|
||||
// and recent".** The default used to start at `now`, so an event that finished an
|
||||
// hour ago was already gone and a visitor had nowhere to find the results of the
|
||||
// thing they had just attended. The LIVE half is answered by `listInWindow`'s
|
||||
// overlap test rather than by this number, so the tail only has to be long enough
|
||||
// to be a "recently" a reader would recognise.
|
||||
const DEFAULT_WINDOW_DAYS = 31
|
||||
const DEFAULT_RECENT_DAYS = 7
|
||||
|
||||
// How many past occurrences an event page carries. It shows what is next and
|
||||
// what happened recently; the whole history of a three-year-old weekly event is
|
||||
// a different screen and nobody has asked for one.
|
||||
const PAST_RUNS = 10
|
||||
const RESULTS_LIMIT = 100
|
||||
|
||||
// The status words a visitor is told. `paused` maps to `live` deliberately: an
|
||||
// operator holding a run for two minutes while they deal with something is not a
|
||||
// state a public page should render, and a page that said "paused" would invite
|
||||
// a question whose answer is internal.
|
||||
const PUBLIC_STATUS = {
|
||||
scheduled: 'scheduled',
|
||||
starting: 'live',
|
||||
running: 'live',
|
||||
paused: 'live',
|
||||
ending: 'live',
|
||||
completed: 'completed',
|
||||
cancelled: 'cancelled',
|
||||
failed: 'cancelled',
|
||||
missed: 'cancelled',
|
||||
}
|
||||
|
||||
/**
|
||||
* The public status word for a run.
|
||||
*
|
||||
* **`failed` and `missed` are published as `cancelled`**, which is the mapping
|
||||
* worth defending. To a visitor the three are one event: it was on the calendar
|
||||
* and it did not happen. The difference between them is entirely about the
|
||||
* deployment — `failed` names broken machinery, `missed` names a process that
|
||||
* was down when the schedule came round — so publishing either word would tell a
|
||||
* stranger something true about the server and nothing about the event.
|
||||
*/
|
||||
const publicStatus = (status) => PUBLIC_STATUS[status] || 'scheduled'
|
||||
|
||||
/** Is this a run a visitor should be shown as happening now? */
|
||||
const isLive = (status) => publicStatus(status) === 'live'
|
||||
|
||||
/**
|
||||
* The label of the phase a run is in, resolved from the PINNED version's spec.
|
||||
*
|
||||
* A phase id is a slug an author typed and the label is what they meant it to
|
||||
* read as, so a page rendering the id would show `phase-2` to the public. A
|
||||
* phase the spec does not name answers null and the page shows nothing, which is
|
||||
* the right answer for a version edited since: the run pinned the old spec and
|
||||
* the old spec is what it is executing.
|
||||
*/
|
||||
function phaseLabel(spec, phaseId) {
|
||||
if (!phaseId || !spec || !Array.isArray(spec.phases)) return null
|
||||
const phase = spec.phases.find((p) => p && p.id === phaseId)
|
||||
return (phase && (phase.label || phase.id)) || null
|
||||
}
|
||||
|
||||
/** One calendar entry, from a materialised run. */
|
||||
const publicRunEntry = (run) => ({
|
||||
kind: 'run',
|
||||
title: run.definition_title,
|
||||
slug: run.definition_slug,
|
||||
seriesName: run.series_name || null,
|
||||
seriesSlug: run.series_slug || null,
|
||||
scheduledFor: run.scheduled_for,
|
||||
timezone: run.timezone,
|
||||
status: publicStatus(run.status),
|
||||
live: isLive(run.status),
|
||||
})
|
||||
|
||||
/**
|
||||
* One calendar entry, from a projection.
|
||||
*
|
||||
* A projection is arithmetic past the materialisation horizon (§I), and the
|
||||
* public entry keeps the distinction for the visitor's version of the operator's
|
||||
* reason: a forecast three weeks out is a plan rather than a booking, and a page
|
||||
* drawing the two identically would promise something nothing has committed to.
|
||||
* `adjusted` rides along because a DST-shifted occurrence is worth explaining
|
||||
* before it happens rather than after.
|
||||
*/
|
||||
const publicProjectedEntry = (definition, occurrence) => ({
|
||||
kind: 'projected',
|
||||
title: definition.title,
|
||||
slug: definition.slug,
|
||||
seriesName: definition.series_name || null,
|
||||
seriesSlug: definition.series_slug || null,
|
||||
scheduledFor: occurrence.at,
|
||||
timezone: definition.timezone,
|
||||
status: 'scheduled',
|
||||
live: false,
|
||||
adjusted: occurrence.adjusted,
|
||||
shiftMinutes: occurrence.shiftMinutes,
|
||||
})
|
||||
|
||||
/**
|
||||
* The public calendar for a window.
|
||||
*
|
||||
* **The run half is read here rather than borrowed from `eventCalendar.model`**,
|
||||
* and the reason is the file header's: that model answers with `status`,
|
||||
* `health`, `version` and `waitingSteps` on every entry, so reusing it would
|
||||
* mean building the public answer by DELETING fields from an operator's, which
|
||||
* is the direction that fails silently. The arithmetic IS shared —
|
||||
* `occurrencesBetween` is the same function the runner calls, so a forecast
|
||||
* still cannot disagree with what later appears — and so are the window bound
|
||||
* and the entry cap, which are a defence against an expensive query on the one
|
||||
* surface that has no login in front of it.
|
||||
*/
|
||||
async function calendar({ from, to, seriesId = null, now = new Date() } = {}) {
|
||||
// The default `to` is measured from NOW, not from `start` — otherwise the
|
||||
// backward tail would silently push the horizon a week further out and a caller
|
||||
// naming only `from` would get a different span than one naming neither.
|
||||
const start = from
|
||||
? new Date(from)
|
||||
: new Date(new Date(now).getTime() - DEFAULT_RECENT_DAYS * recurrence.DAY_MS)
|
||||
const end = to
|
||||
? new Date(to)
|
||||
: new Date(new Date(now).getTime() + DEFAULT_WINDOW_DAYS * recurrence.DAY_MS)
|
||||
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return { ok: false, status: 400, errors: ['from and to must be dates'] }
|
||||
}
|
||||
if (end <= start) {
|
||||
return { ok: false, status: 400, errors: ['to must be after from'] }
|
||||
}
|
||||
if (end - start > calendarModel.MAX_WINDOW_DAYS * recurrence.DAY_MS) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
errors: [`the window may span at most ${calendarModel.MAX_WINDOW_DAYS} days`],
|
||||
}
|
||||
}
|
||||
|
||||
const runs = await runsDb.listInWindow({ from: start, to: end, seriesId, publicOnly: true })
|
||||
const entries = runs.map(publicRunEntry)
|
||||
|
||||
// Every instant a run already occupies, so the fortnight inside the horizon is
|
||||
// not drawn twice — and so a CANCELLED occurrence is not re-forecast as though
|
||||
// it were still coming. The same key the admin calendar uses, for the same
|
||||
// reason: projections are per definition at the empty scope.
|
||||
const taken = new Set(
|
||||
runs
|
||||
.filter((r) => !r.scope)
|
||||
.map((r) => `${r.definition_id}@${new Date(r.scheduled_for).getTime()}`),
|
||||
)
|
||||
|
||||
const definitions = await definitionsDb.findSchedulable({ listedOnly: true })
|
||||
for (const definition of definitions) {
|
||||
if (seriesId && Number(definition.series_id) !== Number(seriesId)) continue
|
||||
const schedule = definition.version_spec?.schedule
|
||||
if (!schedule || schedule.kind === 'manual') continue
|
||||
let occurrences = []
|
||||
try {
|
||||
// **Forecast from `now`, never from `start`.** The default window now
|
||||
// reaches a week backwards so that "recent" has somewhere to live, and a
|
||||
// projection into that tail would advertise an occurrence that did not
|
||||
// happen — a run that WAS created is a real row and arrives above, and one
|
||||
// that was not is a slot the runner has already passed. A forecast is about
|
||||
// the future; the tail is about the past. Only the materialised half fills
|
||||
// it.
|
||||
const forecastFrom = start > now ? start : new Date(now)
|
||||
if (forecastFrom < end) {
|
||||
occurrences = recurrence.occurrencesBetween(
|
||||
schedule,
|
||||
definition.timezone || 'UTC',
|
||||
forecastFrom,
|
||||
end,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// A version whose schedule the recurrence engine will not read is one the
|
||||
// runner will not expand either. The calendar then shows that definition's
|
||||
// materialised runs and no forecast, rather than failing the whole page.
|
||||
continue
|
||||
}
|
||||
for (const occurrence of occurrences) {
|
||||
if (taken.has(`${definition.id}@${occurrence.at.getTime()}`)) continue
|
||||
entries.push(publicProjectedEntry(definition, occurrence))
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor))
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
window: { from: start, to: end },
|
||||
entries: entries.slice(0, calendarModel.MAX_ENTRIES),
|
||||
truncated: entries.length > calendarModel.MAX_ENTRIES,
|
||||
}
|
||||
}
|
||||
|
||||
/** One participant, as a results table publishes them. */
|
||||
const publicParticipant = (p) => ({
|
||||
// NOT `memberKey` — see the file header. A display name is whatever the module
|
||||
// chose to put in `meta`, because core has no name for a character and must
|
||||
// not invent one from the key.
|
||||
name: (p.meta && (p.meta.name || p.meta.displayName)) || null,
|
||||
score: p.score,
|
||||
rank: p.rank_at,
|
||||
meta: p.meta || null,
|
||||
})
|
||||
|
||||
/** One occurrence, as an event page lists it. */
|
||||
const publicOccurrence = (run, spec) => ({
|
||||
runId: run.id,
|
||||
scheduledFor: run.scheduled_for,
|
||||
timezone: run.timezone,
|
||||
startedAt: run.started_at,
|
||||
endedAt: run.ended_at,
|
||||
status: publicStatus(run.status),
|
||||
live: isLive(run.status),
|
||||
scope: run.scope || null,
|
||||
phase: isLive(run.status) ? phaseLabel(spec, run.current_phase) : null,
|
||||
resultsPublishedAt: run.results_published_at || null,
|
||||
})
|
||||
|
||||
/**
|
||||
* One event's public page: the storyline, its arc, its occurrences, and a
|
||||
* results table when there is one to show.
|
||||
*
|
||||
* **`runId` selects WHICH occurrence the results are about, and it is optional
|
||||
* for a reason that exists only because of the announcements.** The page lives
|
||||
* at the definition's slug, so a weekly event has one address and a visitor
|
||||
* arriving at it should be shown what is next. But an `event.run.completed` mail
|
||||
* is about ONE occurrence, and a link in it that opened next Friday's would
|
||||
* answer a different question from the one the reader clicked. So the trigger's
|
||||
* `eventUrl` carries `?run=`, and this is what resolves it.
|
||||
*
|
||||
* **A `runId` that does not belong to this definition is ignored rather than
|
||||
* refused.** It names some other event's run, or none; the honest answer to
|
||||
* "show me this event" is still this event, and a 404 for the whole page would
|
||||
* turn a stale link in a months-old mail into a dead end rather than a page
|
||||
* about the thing the mail was about.
|
||||
*/
|
||||
async function event(slug, { runId = null } = {}) {
|
||||
const definition = await definitionsDb.getPublicBySlug(String(slug || ''))
|
||||
if (!definition) return { ok: false, status: 404, errors: ['Not found'] }
|
||||
|
||||
const series = definition.series_id ? await seriesDb.getById(definition.series_id) : null
|
||||
const runs = await runsDb.listPublicForDefinition(definition.id, PAST_RUNS + 20)
|
||||
|
||||
const now = Date.now()
|
||||
const live = runs.filter((r) => isLive(r.status))
|
||||
|
||||
// **Split on the instant, not on the status**, and the difference is visible
|
||||
// in both directions. A `missed` run is in the past whatever its status says,
|
||||
// and so is a `scheduled` one whose moment went by while the runner had not
|
||||
// reached it — but a run an operator CANCELLED next Friday is still next
|
||||
// Friday, and filing it under "previously" tells a visitor it already
|
||||
// happened, which is the one thing that is certainly untrue about it. That a
|
||||
// cancelled occurrence still appears under what is coming is the point:
|
||||
// "next Friday is off" is exactly what somebody checking the calendar came to
|
||||
// find out.
|
||||
const upcoming = runs
|
||||
.filter((r) => !live.includes(r) && new Date(r.scheduled_for).getTime() >= now)
|
||||
.sort((a, b) => new Date(a.scheduled_for) - new Date(b.scheduled_for))
|
||||
const past = runs.filter((r) => !live.includes(r) && !upcoming.includes(r)).slice(0, PAST_RUNS)
|
||||
|
||||
// `next` is narrower than `upcoming[0]`, deliberately: the headline answers
|
||||
// "when is the next one", and a cancelled occurrence is not one. An event
|
||||
// whose only future occurrence has been called off has no `next` and says so,
|
||||
// while the cancellation itself is still listed below.
|
||||
const next = upcoming.find((r) => r.status === 'scheduled') || null
|
||||
|
||||
// Which occurrence the results table is about. An explicit `run` wins; then a
|
||||
// live one, because that is what the visitor is looking at; then the most
|
||||
// recent that actually published results, because a page with a table on it is
|
||||
// more use than one with an empty heading.
|
||||
const named = runId ? runs.find((r) => String(r.id) === String(runId)) : null
|
||||
const resultsRun = named || live[0] || past.find((r) => r.results_published_at) || null
|
||||
|
||||
let participants = []
|
||||
if (resultsRun && resultsRun.results_published_at) {
|
||||
participants = (await participantsDb.listForRun(resultsRun.id, RESULTS_LIMIT)).map(
|
||||
publicParticipant,
|
||||
)
|
||||
}
|
||||
|
||||
// Phase labels come from the version the run is EXECUTING rather than from the
|
||||
// definition's working draft, which an author may be halfway through editing.
|
||||
// One extra read, and only when there is a run to label at all.
|
||||
const specRun = named || live[0] || null
|
||||
const spec = specRun ? (await versionsDb.getById(specRun.version_id))?.spec || null : null
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
event: {
|
||||
title: definition.title,
|
||||
slug: definition.slug,
|
||||
summary: definition.summary,
|
||||
body: definition.body,
|
||||
imageUrl: definition.image_url,
|
||||
timezone: definition.timezone,
|
||||
series: series ? { name: series.name, slug: series.slug } : null,
|
||||
live: live.length > 0,
|
||||
current: live[0] ? publicOccurrence(live[0], spec) : null,
|
||||
next: next ? publicOccurrence(next, spec) : null,
|
||||
upcoming: upcoming.map((r) => publicOccurrence(r, spec)),
|
||||
past: past.map((r) => publicOccurrence(r, spec)),
|
||||
results:
|
||||
resultsRun && resultsRun.results_published_at
|
||||
? {
|
||||
runId: resultsRun.id,
|
||||
scheduledFor: resultsRun.scheduled_for,
|
||||
publishedAt: resultsRun.results_published_at,
|
||||
participants,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One arc: the series, and the listed events in it in the order an editor
|
||||
* dragged them into.
|
||||
*
|
||||
* **A series with no listed events is a 404 rather than an empty page.** The arc
|
||||
* is a label on its definitions and nothing else, so a page for an empty one
|
||||
* would publish the single fact that an operator has named something they have
|
||||
* not announced.
|
||||
*/
|
||||
async function series(slug) {
|
||||
const row = await seriesDb.getBySlug(String(slug || ''))
|
||||
if (!row) return { ok: false, status: 404, errors: ['Not found'] }
|
||||
|
||||
const definitions = await definitionsDb.listPublicBySeries(row.id)
|
||||
if (!definitions.length) return { ok: false, status: 404, errors: ['Not found'] }
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
series: {
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
description: row.description,
|
||||
events: definitions.map((d) => ({
|
||||
title: d.title,
|
||||
slug: d.slug,
|
||||
summary: d.summary,
|
||||
imageUrl: d.image_url,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One account's participation history.
|
||||
*
|
||||
* Self-scoped by the caller's own id and nothing else. There is no route on
|
||||
* which one account reads another's, and deliberately no id parameter that could
|
||||
* later grow into one.
|
||||
*/
|
||||
async function history(userId, { limit = 50, before = null } = {}) {
|
||||
const rows = await participantsDb.listForUser(userId, { limit, before })
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
entries: rows.map((r) => ({
|
||||
id: r.id,
|
||||
runId: r.run_id,
|
||||
title: r.definition_title,
|
||||
slug: r.definition_slug,
|
||||
seriesName: r.series_name || null,
|
||||
seriesSlug: r.series_slug || null,
|
||||
scheduledFor: r.scheduled_for,
|
||||
startedAt: r.started_at,
|
||||
endedAt: r.ended_at,
|
||||
timezone: r.timezone,
|
||||
status: publicStatus(r.status),
|
||||
joinedAt: r.joined_at,
|
||||
score: r.score,
|
||||
// Null until `core.results.publish` ran. The screen says so rather than
|
||||
// inventing a position nobody computed.
|
||||
rank: r.rank_at,
|
||||
resultsPublishedAt: r.results_published_at || null,
|
||||
meta: r.meta || null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
calendar,
|
||||
event,
|
||||
series,
|
||||
history,
|
||||
publicStatus,
|
||||
phaseLabel,
|
||||
DEFAULT_WINDOW_DAYS,
|
||||
DEFAULT_RECENT_DAYS,
|
||||
PAST_RUNS,
|
||||
}
|
||||
134
server/src/model/events/eventRunBudget.db.js
Normal file
134
server/src/model/events/eventRunBudget.db.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// ── event_run_budget — SQL only ────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D/§E, and Phase 6 of EVENTS_PLAN.md. What one run has spent of one
|
||||
// dimension, and the most it may.
|
||||
//
|
||||
// **The whole file exists for one statement.** `spend()` is the conditional
|
||||
// increment §E names as the answer to "two steps spending one cap":
|
||||
//
|
||||
// UPDATE … SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND consumed + ? <= cap
|
||||
//
|
||||
// A read-then-write would let two steps drawing on `uo.creatures` in the same
|
||||
// tick each see 28 of 30 and each spend 5. The cap in the WHERE means the second
|
||||
// one changes no rows, and `affectedRows === 0` *is* the refusal — no transaction,
|
||||
// no lock, and no second opinion about the arithmetic. Same shape as the outbox
|
||||
// claim, `runsDb.transition` and Phase 5's gate increment, and the same argument.
|
||||
//
|
||||
// **The SET list here has one assignment for the reason Phase 5's had three.**
|
||||
// MariaDB evaluates an UPDATE's SET assignments left to right, each seeing what
|
||||
// the ones before it assigned — which is how Phase 5's gate closed a firing early
|
||||
// — so nothing in this statement may read `consumed` after it has been written.
|
||||
// The guard is in the WHERE, where it reads the pre-update row, and it must stay
|
||||
// there.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* Seed a run's budget rows.
|
||||
*
|
||||
* **INSERT IGNORE against `uq_evbud_dim`**, so a tick that overruns into the next
|
||||
* one cannot double-seed and cannot reset a cap a run has already spent against —
|
||||
* the idempotence `materialisePhase` and `gates.open` both have, for the same
|
||||
* reason.
|
||||
*
|
||||
* The caps are COPIED here rather than read live at dispatch. A run pins its
|
||||
* version and is reproducible in every other respect; a cap read live would be
|
||||
* the one input to a run's behaviour an admin could change underneath it while
|
||||
* nobody was watching, and the console's meter would answer "what is allowed now"
|
||||
* when the question afterwards is "what was this run allowed".
|
||||
*/
|
||||
async function seed(runId, dimensions) {
|
||||
const rows = Object.entries(dimensions || {})
|
||||
if (!rows.length) return 0
|
||||
const values = rows.map(() => '(?, ?, 0, ?, ?)').join(', ')
|
||||
const params = rows.flatMap(([dimension, d]) => [runId, dimension, d.cap, d.from || null])
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO event_run_budget (run_id, dimension, consumed, cap, effective_from)
|
||||
VALUES ${values}`,
|
||||
params,
|
||||
)
|
||||
return result.affectedRows || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend `amount` of one dimension, or refuse.
|
||||
*
|
||||
* Answers `true` when the row moved and `false` when it did not — and `false` has
|
||||
* exactly two causes, both of which mean the same thing to the caller: the spend
|
||||
* would breach the cap, or there is no row for this dimension at all. The second
|
||||
* is not a silent pass: a run whose version names an action that costs a
|
||||
* dimension always has that dimension seeded — **uncapped ones included, as a row
|
||||
* with a NULL cap** — so a missing row means the step is spending something its
|
||||
* own version never declared, and refusing that is the fail-closed direction.
|
||||
*
|
||||
* A zero or negative amount is not a spend and never touches the database. An
|
||||
* action whose `cost()` answers `0` for its params is telling core it consumes
|
||||
* nothing, and pricing that as a query would put one round trip per step behind a
|
||||
* fact the caller already has.
|
||||
*/
|
||||
async function spend(runId, dimension, amount) {
|
||||
if (!(amount > 0)) return true
|
||||
const result = await query(
|
||||
`UPDATE event_run_budget
|
||||
SET consumed = consumed + ?
|
||||
WHERE run_id = ? AND dimension = ? AND (cap IS NULL OR consumed + ? <= cap)`,
|
||||
[amount, runId, dimension, amount],
|
||||
)
|
||||
return (result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Give `amount` back.
|
||||
*
|
||||
* Called on exactly one path: a step that spent several dimensions and was then
|
||||
* refused on a later one. The spends are separate statements — they must be, the
|
||||
* atomicity that matters is per dimension — so a step costing 5 creatures and 2
|
||||
* bosses can take the creatures and be refused the bosses, and a step that did
|
||||
* not run must not have spent anything. `GREATEST(consumed - ?, 0)` because the
|
||||
* floor is worth more than a refund that is exactly right: a negative `consumed`
|
||||
* would make the cap arithmetic lie in the permissive direction forever after.
|
||||
*/
|
||||
async function refund(runId, dimension, amount) {
|
||||
if (!(amount > 0)) return
|
||||
await query(
|
||||
`UPDATE event_run_budget
|
||||
SET consumed = GREATEST(consumed - ?, 0)
|
||||
WHERE run_id = ? AND dimension = ?`,
|
||||
[amount, runId, dimension],
|
||||
)
|
||||
}
|
||||
|
||||
/** Every dimension of one run, for the console's meter. */
|
||||
async function forRun(runId) {
|
||||
return query(
|
||||
`SELECT dimension, consumed, cap, effective_from
|
||||
FROM event_run_budget WHERE run_id = ? ORDER BY dimension`,
|
||||
[runId],
|
||||
)
|
||||
}
|
||||
|
||||
/** The dimensions of several runs at once, keyed by run id — the run LIST's read. */
|
||||
async function forRuns(runIds) {
|
||||
const ids = [...new Set(runIds || [])].filter(Boolean)
|
||||
if (!ids.length) return new Map()
|
||||
const rows = await query(
|
||||
`SELECT run_id, dimension, consumed, cap, effective_from
|
||||
FROM event_run_budget
|
||||
WHERE run_id IN (${ids.map(() => '?').join(',')})
|
||||
ORDER BY run_id, dimension`,
|
||||
ids,
|
||||
)
|
||||
const out = new Map()
|
||||
for (const r of rows) {
|
||||
if (!out.has(r.run_id)) out.set(r.run_id, [])
|
||||
out.get(r.run_id).push({
|
||||
dimension: r.dimension,
|
||||
consumed: r.consumed,
|
||||
cap: r.cap,
|
||||
effective_from: r.effective_from,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { seed, spend, refund, forRun, forRuns }
|
||||
463
server/src/model/events/eventRunControls.model.js
Normal file
463
server/src/model/events/eventRunControls.model.js
Normal file
@@ -0,0 +1,463 @@
|
||||
// ── The live run controls ──────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §I ("live controls that are honest"), §K and §L. Six of them: pause,
|
||||
// resume and cancel act on a run; confirm, skip and retry act on one step. They
|
||||
// arrive in Phase 3 because Phase 2 is what gave them something to act on — a
|
||||
// run that announces, waits and completes on its own is exactly the run that
|
||||
// needs no control, and a run that paused on a failed world write is the one
|
||||
// that does.
|
||||
//
|
||||
// **`advance` is the seventh, and it arrived in Phase 5 rather than Phase 3
|
||||
// because that is when it started meaning something.** A phase used to advance
|
||||
// when its steps went terminal and on nothing else, so "force it anyway" named
|
||||
// no state an operator could be in; a phase with a gate can wait for a boss that
|
||||
// will never spawn, and then it names exactly one. It is the other half of the
|
||||
// diagnosis panel: a screen that explains why a phase has not started, beside a
|
||||
// control that does something about it.
|
||||
//
|
||||
// **`cleanup` is the eighth, and Phase 8 is what gave it a ledger to work over.**
|
||||
// It re-runs the teardown across every resource a run has not given back, and it
|
||||
// is `admin` where the other seven are `admin` + `moderator`: it is not incident
|
||||
// response, it is asking core to write to the world again. Its partner is
|
||||
// cancel's new `cleanup: false`, which is §L's "cancelling WITHOUT cleanup is a
|
||||
// separate, logged, admin-only action" — deliberately the flag that has to be
|
||||
// asked for, because the safe default is to give back what the run took.
|
||||
//
|
||||
// **Every control is guarded on the status it may act from, and the guard is a
|
||||
// WHERE clause rather than a read-then-write.** A run console rendered thirty
|
||||
// seconds ago describes a run that has since moved — the runner ticks every
|
||||
// fifteen — so a control that checked in JavaScript and then wrote would race
|
||||
// the tick it exists to interrupt. `transition()` and the four step statements
|
||||
// are all compare-and-set, and a `false` from one of them is reported as a 409
|
||||
// naming the status the run is actually in.
|
||||
//
|
||||
// **Who may press them is `admin` + `moderator` (§K, §N2), and it is the widest
|
||||
// gate in this feature on purpose.** Starting a run commits the deployment to
|
||||
// everything the definition contains, unattended — that wants the narrowest gate
|
||||
// there is. Stopping one is incident response at 2am, and it wants the widest.
|
||||
|
||||
const runsDb = require('./eventRuns.db')
|
||||
const stepsDb = require('./eventRunSteps.db')
|
||||
const logDb = require('./eventRunLog.db')
|
||||
const announce = require('../../events/announce')
|
||||
const gatesDb = require('./eventPhaseGates.db')
|
||||
const resourcesDb = require('./eventRunResources.db')
|
||||
const gates = require('../../events/gates')
|
||||
|
||||
const MAX_REASON = 500
|
||||
|
||||
const clean = (raw) => {
|
||||
const text = typeof raw === 'string' ? raw.trim() : ''
|
||||
return text ? text.slice(0, MAX_REASON) : null
|
||||
}
|
||||
|
||||
const conflict = (message) => ({ ok: false, status: 409, errors: [message] })
|
||||
|
||||
/** The run, or a 404 shaped the way every other model here shapes one. */
|
||||
async function loadRun(runId) {
|
||||
const run = await runsDb.getById(runId)
|
||||
return run || null
|
||||
}
|
||||
|
||||
/**
|
||||
* A step of THIS run, or null.
|
||||
*
|
||||
* Scoped to the run rather than fetched by id alone: the step id arrives from a
|
||||
* URL under a run id, and a control that acted on a step belonging to a
|
||||
* different run would be a real one — the console's step ids are not secret and
|
||||
* the two paths would otherwise never be compared.
|
||||
*/
|
||||
async function loadStep(runId, stepId) {
|
||||
const step = await stepsDb.getById(stepId)
|
||||
if (!step || Number(step.run_id) !== Number(runId)) return null
|
||||
return step
|
||||
}
|
||||
|
||||
// ── Run-level ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pause a run in flight.
|
||||
*
|
||||
* `starting` and `running` only — §K's "live control of a run **in flight**". A
|
||||
* `scheduled` run has not begun, and the thing to do with an occurrence that
|
||||
* should not happen is cancel it: pausing one would leave a run that is neither
|
||||
* going to start nor visibly abandoned, and resuming it after its grace window
|
||||
* had passed would produce a `missed` from a button labelled resume.
|
||||
*
|
||||
* The claim is cleared with the transition. A tick may be working the run at
|
||||
* this exact moment; it will find its guarded writes returning zero rows and
|
||||
* hand back a lease it no longer holds, both of which are no-ops. What it will
|
||||
* NOT do is dispatch the rest of its batch — `advanceRun` re-reads the status
|
||||
* between steps precisely so this control means what it says.
|
||||
*/
|
||||
async function pause(runId, { reason } = {}, userId = null) {
|
||||
const run = await loadRun(runId)
|
||||
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
|
||||
if (run.status === 'paused') return conflict('this run is already paused')
|
||||
|
||||
const note = clean(reason)
|
||||
if (!(await runsDb.transition(run.id, ['starting', 'running'], 'paused', { clearClaim: true }))) {
|
||||
return conflict(`a ${run.status} run cannot be paused`)
|
||||
}
|
||||
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'run.status',
|
||||
phase: run.current_phase,
|
||||
detail: { from: run.status, to: 'paused', control: 'pause', by: userId, reason: note },
|
||||
})
|
||||
return { ok: true, run: await runsDb.getById(run.id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a paused run.
|
||||
*
|
||||
* Where it goes back to is derived rather than remembered: `current_phase` is
|
||||
* set by the transition into `running` and by nothing else, so a paused run that
|
||||
* has one was running and a paused run that has none never got past `starting`.
|
||||
* Both statuses are in `findDue`, so the next tick picks the run up either way,
|
||||
* and there is no fourth column recording what a run was paused *from* — a
|
||||
* column that could disagree with the run's own history.
|
||||
*
|
||||
* **`last_error` is cleared and `health` is not.** The error is what the pause
|
||||
* was about and an operator has just dealt with it; leaving it on the banner
|
||||
* would have a healthy run permanently accused of a failure that is in the log
|
||||
* where it belongs. Health is a different claim — that this run has already had
|
||||
* trouble — and it stays true no matter who pressed resume.
|
||||
*/
|
||||
async function resume(runId, options = {}, userId = null) {
|
||||
const run = await loadRun(runId)
|
||||
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
|
||||
if (run.status !== 'paused') return conflict(`a ${run.status} run is not paused`)
|
||||
|
||||
const to = run.current_phase ? 'running' : 'starting'
|
||||
if (!(await runsDb.transition(run.id, 'paused', to, { error: null }))) {
|
||||
return conflict('this run stopped being paused')
|
||||
}
|
||||
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'run.status',
|
||||
phase: run.current_phase,
|
||||
detail: { from: 'paused', to, control: 'resume', by: userId },
|
||||
})
|
||||
return { ok: true, run: await runsDb.getById(run.id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a run.
|
||||
*
|
||||
* Legal from every non-terminal status including `scheduled`, because "this
|
||||
* event is not happening" is a decision an operator makes before it starts as
|
||||
* often as during it.
|
||||
*
|
||||
* `cancelOpen` then closes out the steps that will never run — the pending ones
|
||||
* and any parked cue. A step with a LIVE lease is left exactly where it is:
|
||||
* something is dispatching it, nothing can recall a command already sent (§L),
|
||||
* and a second writer on that row would race the process that owns it. It
|
||||
* finishes into a cancelled run, which is honest.
|
||||
*/
|
||||
async function cancel(runId, { reason, cleanup = true } = {}, userId = null, { isAdmin = true } = {}) {
|
||||
const run = await loadRun(runId)
|
||||
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
|
||||
if (runsDb.TERMINAL.includes(run.status)) return conflict(`this run is already ${run.status}`)
|
||||
|
||||
// §L: cancelling WITHOUT cleanup is a separate, logged, ADMIN-only action. The
|
||||
// route itself is `admin` + `moderator`, so the narrower gate cannot live in
|
||||
// middleware — which of the two you have to be depends on what is in the body,
|
||||
// exactly as the authoring role floor does (§K).
|
||||
if (cleanup === false && !isAdmin) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
errors: ['leaving a run\'s world changes in place is an administrator\'s decision'],
|
||||
}
|
||||
}
|
||||
|
||||
const note = clean(reason)
|
||||
const from = ['scheduled', 'starting', 'running', 'paused', 'ending']
|
||||
if (!(await runsDb.transition(run.id, from, 'cancelled', { error: note || 'cancelled by staff' }))) {
|
||||
return conflict('this run is no longer cancellable')
|
||||
}
|
||||
|
||||
const closed = await stepsDb.cancelOpen(run.id)
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'run.status',
|
||||
phase: run.current_phase,
|
||||
detail: {
|
||||
from: run.status,
|
||||
to: 'cancelled',
|
||||
control: 'cancel',
|
||||
by: userId,
|
||||
reason: note,
|
||||
cancelledSteps: closed,
|
||||
cleanup: cleanup !== false,
|
||||
},
|
||||
})
|
||||
|
||||
// **After the guarded transition, so exactly one caller announces** (Phase
|
||||
// 10). Two moderators pressing cancel in the same second both reach the log
|
||||
// write; only one of them wins `transition`, and the loser has already
|
||||
// returned a 409 above.
|
||||
//
|
||||
// The operator's `reason`, not the run's `last_error` — `cancel` takes a
|
||||
// sentence a human typed for other humans, and the diagnostic string that
|
||||
// ends up in `last_error` would read as gibberish in a mail.
|
||||
await announce.runCancelled(run, note)
|
||||
|
||||
// **The teardown is not done here, and the request does not wait for it.**
|
||||
// Cleanup is one leg of the runner's tick over terminal runs (§L), which is
|
||||
// what makes it survive a process that dies halfway through it — and a cancel
|
||||
// pressed at two in the morning must answer at once rather than after a dozen
|
||||
// round trips to a shard that may be the reason it is being cancelled. The run
|
||||
// is terminal the moment this returns, so the very next tick picks its ledger
|
||||
// up.
|
||||
//
|
||||
// `cleanup: false` is the operator saying leave it. The resources stay
|
||||
// unresolved and the run carries `incomplete`, which is the truthful value: the
|
||||
// world changes are still up, they are listed on the console, and the log line
|
||||
// above records who decided that.
|
||||
let cleanupStatus = run.cleanup_status
|
||||
if (cleanup === false && (await resourcesDb.unresolvedCount(run.id)) > 0) {
|
||||
// `incomplete` is what takes the run out of the cleanup leg's scan, and it is
|
||||
// the truthful value: the world changes are still up, they are listed on the
|
||||
// console, and the log line above records who decided that.
|
||||
//
|
||||
// **The first draft spent every row's `revert_attempts` instead**, to stop the
|
||||
// sweep by the same mechanism a failed retry does. It worked and it made the
|
||||
// console lie: the run page rendered "3 attempts" beside resources nothing had
|
||||
// ever tried, which reads as "core tried three times and could not". Found by
|
||||
// opening the page. A counter that means two things is a counter a screen
|
||||
// cannot render.
|
||||
await runsDb.setCleanupStatus(run.id, 'incomplete')
|
||||
cleanupStatus = 'incomplete'
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
run: await runsDb.getById(run.id),
|
||||
cancelledSteps: closed,
|
||||
cleanup: cleanup !== false,
|
||||
cleanupStatus,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run cleanup over everything a run has not given back.
|
||||
*
|
||||
* The manual retry §L promises, and the only thing that clears
|
||||
* `revert_attempts`. That licence is the same one a human's step retry has, and
|
||||
* it is deliberately not extended to the automatic sweep: Engagement Phase 14's
|
||||
* defect was exactly a sweep that reset every stale row, which made the attempt
|
||||
* ceiling unreachable and left the row cycling for ever.
|
||||
*
|
||||
* Legal on a TERMINAL run only. A run still in flight has a ledger that is still
|
||||
* growing, and reverting a resource the next step is about to use would be core
|
||||
* undoing an event while it is happening.
|
||||
*/
|
||||
async function cleanupRun(runId, userId = null) {
|
||||
const run = await loadRun(runId)
|
||||
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
|
||||
if (!runsDb.TERMINAL.includes(run.status)) {
|
||||
return conflict(`this run is still ${run.status}; cancel it before cleaning up after it`)
|
||||
}
|
||||
if (run.cleanup_status === 'not_required') {
|
||||
return conflict('this run recorded no resources, so there is nothing to give back')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line global-require
|
||||
const summary = await require('../../events/cleanup').cleanupRun(run, {
|
||||
resetAttempts: true,
|
||||
actor: userId,
|
||||
})
|
||||
return { ok: true, run: await runsDb.getById(run.id), summary }
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a phase forward: open its gate without the condition that would have.
|
||||
*
|
||||
* **It is legal only when the phase is actually waiting on a gate**, and the
|
||||
* three refusals are the whole design. A run that is not `running` is not
|
||||
* waiting on anything (409 naming what it is). A phase with no gate advances on
|
||||
* its steps and always has, so forcing it would be a control that duplicated the
|
||||
* runner rather than overriding it. And a phase whose steps have not all gone
|
||||
* terminal is not being held by its gate — it is being held by a step, and the
|
||||
* step-level skip is the honest control for that, one step at a time. A force
|
||||
* that swept past pending steps would be a cancel of half a phase under a button
|
||||
* labelled advance.
|
||||
*
|
||||
* **It satisfies the gate and stops.** The next tick advances the run, exactly
|
||||
* as it does after `resume` — the phase transition, the next phase's
|
||||
* materialisation, its own gate and the log lines are one sequence in
|
||||
* `advanceRun`, and a second copy of it here would be a second opinion about
|
||||
* what a phase boundary is. The response says which phase was released, so the
|
||||
* console can say so before the tick lands.
|
||||
*/
|
||||
async function advancePhase(runId, { reason } = {}, userId = null) {
|
||||
const run = await loadRun(runId)
|
||||
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
|
||||
if (run.status !== 'running') return conflict(`a ${run.status} run has no phase to advance`)
|
||||
if (!run.current_phase) return conflict('this run has not entered a phase yet')
|
||||
|
||||
const gate = await gatesDb.forPhase(run.id, run.current_phase)
|
||||
if (!gate) return conflict(`phase "${run.current_phase}" has no advance condition; skip its steps instead`)
|
||||
if (gate.satisfied_at) return conflict(`phase "${run.current_phase}" is already past its advance condition`)
|
||||
|
||||
const openStep = await stepsDb.nextOpenStep(run.id, run.current_phase)
|
||||
if (openStep) {
|
||||
return conflict(
|
||||
`phase "${run.current_phase}" is waiting on step ${openStep.seq} (${openStep.action_id}), not on its advance condition`,
|
||||
)
|
||||
}
|
||||
|
||||
const note = clean(reason)
|
||||
if (!(await gatesDb.satisfy(gate.id, 'forced', { userId }))) {
|
||||
return conflict('this phase stopped waiting on its advance condition')
|
||||
}
|
||||
|
||||
const described = gates.describe(gate)
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'phase.advanced',
|
||||
phase: run.current_phase,
|
||||
detail: {
|
||||
because: 'forced',
|
||||
control: 'advance',
|
||||
by: userId,
|
||||
reason: note,
|
||||
waitedSeconds: described.elapsedSeconds,
|
||||
...(gate.kind === 'on'
|
||||
? { trigger: gate.trigger_id, seen: gate.tally, needed: gate.needed }
|
||||
: { after: gate.after_seconds }),
|
||||
},
|
||||
})
|
||||
return { ok: true, run: await runsDb.getById(run.id), phase: run.current_phase }
|
||||
}
|
||||
|
||||
// ── Step-level ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Confirm a parked step — the GM cue's other half.
|
||||
*
|
||||
* `core.cue` posts an instruction and parks: the step stays `running` with a
|
||||
* NULL lease, genuinely in flight with nothing holding it, so no sweep takes it
|
||||
* back and a cue posted on Friday is still waiting on Monday. This is what ends
|
||||
* it, and it is the control that makes the whole system useful before any module
|
||||
* automates anything — a GM does the target-driven part in-client and says so
|
||||
* here.
|
||||
*
|
||||
* The outcome is `done`, not `skipped`: a person saying they did the thing is
|
||||
* the step having succeeded. The note is what they did, and it is kept.
|
||||
*/
|
||||
async function confirmStep(runId, stepId, { note } = {}, userId = null) {
|
||||
const run = await loadRun(runId)
|
||||
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
|
||||
const step = await loadStep(runId, stepId)
|
||||
if (!step) return { ok: false, status: 404, errors: ['no such step on this run'] }
|
||||
|
||||
const text = clean(note)
|
||||
if (!(await stepsDb.confirmParked(step.id, text))) {
|
||||
return conflict(`this step is ${step.status} and is not waiting on anyone`)
|
||||
}
|
||||
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
kind: 'step.status',
|
||||
phase: step.phase,
|
||||
detail: { to: 'done', action: step.action_id, control: 'confirm', by: userId, note: text },
|
||||
})
|
||||
return { ok: true, step: await stepsDb.getById(step.id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip a step: one that has not started, or a parked cue nobody is going to do.
|
||||
*
|
||||
* This is what the `skipped` status was reserved for (§L) — which is also why
|
||||
* the three `on_failure` dispositions all write `failed` instead. A status
|
||||
* meaning both "a human decided against this" and "this was attempted three
|
||||
* times and never worked" would make the console's summary line unreadable.
|
||||
*
|
||||
* A `failed` step is not skippable and does not need to be: `nextOpenStep`
|
||||
* already passes over one, so resuming a run carries the phase past it.
|
||||
*/
|
||||
async function skipStep(runId, stepId, { reason } = {}, userId = null) {
|
||||
const run = await loadRun(runId)
|
||||
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
|
||||
if (runsDb.TERMINAL.includes(run.status)) return conflict(`this run is ${run.status}`)
|
||||
const step = await loadStep(runId, stepId)
|
||||
if (!step) return { ok: false, status: 404, errors: ['no such step on this run'] }
|
||||
|
||||
const note = clean(reason)
|
||||
if (!(await stepsDb.skipByHuman(step.id, note))) {
|
||||
return conflict(`a ${step.status} step cannot be skipped`)
|
||||
}
|
||||
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
kind: 'step.status',
|
||||
phase: step.phase,
|
||||
detail: { to: 'skipped', action: step.action_id, control: 'skip', by: userId, reason: note },
|
||||
})
|
||||
return { ok: true, step: await stepsDb.getById(step.id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-queue the failed step a run is stopped at, and resume the run — one action.
|
||||
*
|
||||
* **The two halves are one control because there is no state in which you would
|
||||
* want half of it.** Retry is legal only from `paused`, and a paused run is
|
||||
* paused *at* this step; re-queueing without resuming would leave the run in
|
||||
* precisely the state it was already in, with a button the operator now has to
|
||||
* find. Splitting them would read as honesty and behave as a trap.
|
||||
*
|
||||
* Two guards, and the second is the one worth explaining. The step must be the
|
||||
* furthest one its phase has reached — `lastStartedSeq` — because a `failed`
|
||||
* step under an `on_failure` of `skip` is one the run has already moved PAST.
|
||||
* `nextOpenStep` selects `pending` and `running` only, so the runner steps over
|
||||
* a failed row; re-queueing an earlier one puts a `pending` step behind the
|
||||
* cursor, where it sits for ever.
|
||||
*/
|
||||
async function retryStep(runId, stepId, options = {}, userId = null) {
|
||||
const run = await loadRun(runId)
|
||||
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
|
||||
if (run.status !== 'paused') {
|
||||
return conflict(`a step can only be retried while its run is paused; this run is ${run.status}`)
|
||||
}
|
||||
const step = await loadStep(runId, stepId)
|
||||
if (!step) return { ok: false, status: 404, errors: ['no such step on this run'] }
|
||||
if (step.status !== 'failed') return conflict(`a ${step.status} step cannot be retried`)
|
||||
if (step.phase !== run.current_phase) {
|
||||
return conflict('this step belongs to a phase the run has already left')
|
||||
}
|
||||
|
||||
const furthest = await stepsDb.lastStartedSeq(run.id, step.phase)
|
||||
if (furthest === null || Number(furthest) !== Number(step.seq)) {
|
||||
return conflict('the run is not stopped at this step; only the step a phase is stopped at can be retried')
|
||||
}
|
||||
|
||||
if (!(await stepsDb.requeue(step.id))) return conflict('this step is no longer failed')
|
||||
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
kind: 'step.status',
|
||||
phase: step.phase,
|
||||
detail: { to: 'pending', action: step.action_id, control: 'retry', by: userId, attemptsReset: step.attempts },
|
||||
})
|
||||
|
||||
const resumed = await resume(runId, {}, userId)
|
||||
return {
|
||||
ok: true,
|
||||
step: await stepsDb.getById(step.id),
|
||||
// A resume that did not take is reported rather than swallowed: the step IS
|
||||
// re-queued either way, and an operator told "retried" about a run that is
|
||||
// still paused would be told something false.
|
||||
resumed: Boolean(resumed.ok),
|
||||
run: resumed.run || (await runsDb.getById(run.id)),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { pause, resume, cancel, cleanupRun, advancePhase, confirmStep, skipStep, retryStep }
|
||||
144
server/src/model/events/eventRunLog.db.js
Normal file
144
server/src/model/events/eventRunLog.db.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// ── event_run_log — SQL only ───────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md § Observability. "Why didn't phase 3 start?" must be a query, and
|
||||
// `activity_log.detail` is TEXT and unqueryable, which is why this table exists
|
||||
// beside the audit log rather than instead of it. Both are written: the audit of
|
||||
// WHO published WHAT goes to `activity_log`, the diagnosis goes here.
|
||||
//
|
||||
// **`kind` is a closed set enforced here rather than an ENUM in the DDL.** The
|
||||
// set grows with almost every later phase — conditions in Phase 5, cap draws in
|
||||
// Phase 6, ledger movements in Phase 8 — and an ENUM change is a table alter
|
||||
// this project has no migration system for. A constant in a file is the same
|
||||
// guarantee with a cheaper hinge.
|
||||
|
||||
const log = require('../../utils/logger')('events')
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
// Phase 1's kinds. Later phases append; nothing here is ever renamed, because a
|
||||
// stored row would then name a kind no reader knows.
|
||||
const KINDS = [
|
||||
'run.created', // an occurrence was materialised
|
||||
'run.status', // a status transition, with from/to
|
||||
'phase.entered', // a phase's steps were materialised
|
||||
'step.status', // a step transition, with the module's answer
|
||||
'note', // a human action taken from the admin surface
|
||||
// Phase 2's, all five of them answers to a question an operator asks out
|
||||
// loud. `run.blocked` in particular is the whole reason this table exists
|
||||
// rather than a server log line: "it did not start because run 37 holds
|
||||
// invasion:Yew" is a fact with two run ids in it, and it has to be
|
||||
// queryable from the run that did NOT start.
|
||||
'run.blocked', // an occurrence held off: another run has its concurrency key
|
||||
'run.health', // a health change, which is not a status change
|
||||
'step.retry', // a step failed transiently and will be attempted again
|
||||
'step.parked', // a step is waiting on a human and nothing is holding it
|
||||
'phase.completed', // every step of a phase reached a terminal status
|
||||
// Phase 5's four. `condition.evaluated` is written for BOTH outcomes (§
|
||||
// Observability), and the non-matching one is the more valuable of the two on
|
||||
// the night: "the boss did spawn, in Britain" and "no boss has spawned" are
|
||||
// different answers to the same question and look identical without it.
|
||||
'phase.gate', // a phase opened an advance gate, with what it waits for
|
||||
'condition.evaluated', // a firing was tested against a gate, matched or not
|
||||
'phase.advanced', // a gate opened: on a firing, on its deadline, or forced
|
||||
// Phase 6's three. `step.refused` is the one worth naming separately from
|
||||
// `step.status`: a refusal is not a failure, and an operator reading a run that
|
||||
// stopped needs to see at a glance that nothing is broken -- the deployment
|
||||
// simply does not permit what the author asked for.
|
||||
'run.budget', // the caps this run was seeded with, and which switch set each
|
||||
'step.refused', // a step was not permitted: disabled, or over a cap
|
||||
'version.verified', // a dry run passed against a version, unlocking scheduled starts
|
||||
// Phase 8's six, and every one of them is an answer to "what did this event
|
||||
// leave behind". `resource.recorded` is written at the ANSWER rather than at
|
||||
// the placeholder, because a placeholder is a promise and the operator's
|
||||
// question is about the world.
|
||||
'resource.recorded', // a step reported what it created or borrowed, and it is ledgered
|
||||
'resource.orphaned', // a module reports a ledgered resource is no longer in force
|
||||
'cleanup.reverted', // a group of resources came back
|
||||
'cleanup.failed', // a group did not, with the reason and how it was left
|
||||
'cleanup.swept', // one pass over a run's ledger, and what it found
|
||||
'cleanup.retry', // a human cleared the attempt counter and asked again
|
||||
// Phase 10's four: the integrations. `announcement.emitted` is a line about
|
||||
// what the run SAID happened, not about who was told -- the engagement engine
|
||||
// owns that decision and logs its own, and a run log that claimed to know how
|
||||
// many mails went out would be reporting a decision it does not make.
|
||||
'participants.recorded', // a step reported who took part, and they are recorded
|
||||
'results.published', // the results table was ranked and stamped
|
||||
'announcement.emitted', // a lifecycle trigger fired, with its id and ceiling
|
||||
'announcement.enqueued', // a post was linked to this run and queued on the legs
|
||||
// Phase 15's one, and it is the only kind whose payload core does not compose.
|
||||
// A module may answer a success envelope with a `detail` object; it is bounded
|
||||
// and sanitised at the dispatcher and written here verbatim beside the action
|
||||
// id. Nothing reads a key out of it — it exists because a module knows things
|
||||
// about its own verb that core cannot compute and had no other way to say.
|
||||
'step.detail', // a module's own account of what a successful step did
|
||||
]
|
||||
|
||||
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }
|
||||
|
||||
const listForRun = async (runId, { limit = 500 } = {}) => {
|
||||
const n = Math.min(Math.max(Number(limit) || 500, 1), 2000)
|
||||
return (
|
||||
await query(`SELECT * FROM event_run_log WHERE run_id = ? ORDER BY at DESC, id DESC LIMIT ${n}`, [
|
||||
runId,
|
||||
])
|
||||
).map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one line. **Never throws.**
|
||||
*
|
||||
* The diagnostic log is what an operator reads when something has already gone
|
||||
* wrong, so a failure to write it must not become a second failure on top of the
|
||||
* first — a runner that aborted a run because it could not record why would be
|
||||
* the worst possible reading of "observability". The same posture
|
||||
* `uoLinkClient.js` takes: answer, do not throw.
|
||||
*/
|
||||
async function write({ runId, stepId = null, kind, phase = null, detail = null }) {
|
||||
if (!KINDS.includes(kind)) {
|
||||
// A programming error, not an operational one, and it is louder than a
|
||||
// silent drop for exactly that reason.
|
||||
log.warn('event run log: unknown kind', { kind, runId })
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await query(
|
||||
'INSERT INTO event_run_log (run_id, step_id, kind, phase, detail) VALUES (?, ?, ?, ?, ?)',
|
||||
[runId, stepId, kind, phase, detail === null ? null : JSON.stringify(detail)],
|
||||
)
|
||||
return true
|
||||
} catch (err) {
|
||||
log.error('event run log write failed', { runId, kind, message: err.message })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete log lines belonging to runs that are both TERMINAL and older than
|
||||
* `before`, a bounded number at a time.
|
||||
*
|
||||
* The schema comment beside `idx_evlog_at` parked this sweep here, and it is the
|
||||
* rule Engagement Phase 14 arrived at applied to a second high-cardinality table:
|
||||
* **only terminal rows are eligible.** A run still in flight keeps every line it
|
||||
* has, however old — the log's whole job is answering "why didn't phase 3 start?"
|
||||
* about a run that is, right now, not starting phase 3, and a horizon that could
|
||||
* reach a live run would delete the answer while the question was still open.
|
||||
*
|
||||
* `LIMIT` makes one call a bounded amount of work rather than a table-sized
|
||||
* transaction; the timer runs again and takes the next slice. The join is on the
|
||||
* run's terminal status rather than on a precomputed id list so that a run which
|
||||
* reached a terminal state between the two would not be missed.
|
||||
*/
|
||||
const pruneTerminal = async (before, limit = 5000) => {
|
||||
const n = Math.min(Math.max(Number(limit) || 5000, 1), 50_000)
|
||||
const result = await query(
|
||||
`DELETE l FROM event_run_log l
|
||||
JOIN event_runs r ON r.id = l.run_id
|
||||
WHERE r.status IN ('completed','cancelled','failed','missed')
|
||||
AND COALESCE(r.ended_at, r.updated_at) < ?
|
||||
LIMIT ${n}`,
|
||||
[before],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { KINDS, listForRun, write, pruneTerminal }
|
||||
165
server/src/model/events/eventRunParticipants.db.js
Normal file
165
server/src/model/events/eventRunParticipants.db.js
Normal file
@@ -0,0 +1,165 @@
|
||||
// ── event_run_participants — SQL only ──────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §J, and Phase 10 of EVENTS_PLAN.md. The eleventh and last of
|
||||
// §D's core tables: who took part in a run, and how well.
|
||||
//
|
||||
// **Core writes this table and never sources it.** A `member_key` is
|
||||
// module-opaque, exactly as a resource's `ref` is — core cannot map a character
|
||||
// name onto a user row and must not try, because that mapping is one game's
|
||||
// (`shard_links`, for module-uo) and would be that game compiled into core. A
|
||||
// module that knows both halves reports both; core stores what it is told.
|
||||
//
|
||||
// **Every write is an upsert on `(run_id, member_key)`.** A module's collect step
|
||||
// can be retried — that is what `EVENT_STEP_MAX_ATTEMPTS` means — and a retried
|
||||
// collect that duplicated its rows would double a leaderboard. It is the same
|
||||
// argument `materialisePhase`'s `INSERT IGNORE` makes about steps, one table
|
||||
// along, with the difference that a re-report may carry a BETTER score and must
|
||||
// win rather than be ignored.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const COLUMNS = `id, run_id, member_key, user_id, score, rank_at, joined_at, meta,
|
||||
created_at, updated_at`
|
||||
|
||||
// `score` is `DECIMAL(18,4)` and the pool sets `decimalAsNumber`, so it already
|
||||
// arrives as a JS number; the coercion is belt to that braces and costs nothing.
|
||||
// `meta` is hydrated for the reason a resource's payload is: opaque to core, but
|
||||
// every caller wants the object rather than the string the driver returns.
|
||||
const hydrate = (row) =>
|
||||
row && { ...row, score: Number(row.score), meta: parseJson(row.meta, null) }
|
||||
|
||||
/**
|
||||
* Record one participant, or update the one already recorded.
|
||||
*
|
||||
* **`joined_at` is written on INSERT and never on UPDATE**, and that asymmetry is
|
||||
* the point of the column: it is when this participant first appeared, and a
|
||||
* second report — a later collect, a corrected score — must not rewrite it. The
|
||||
* same applies to `rank_at`, which is not touched here at all: ranking is
|
||||
* `core.results.publish`'s job and a re-report between two publications must not
|
||||
* silently invent a rank nobody computed.
|
||||
*
|
||||
* `user_id` DOES move on a re-report, deliberately: a player who linked their
|
||||
* website account between two collects should stop being anonymous, and the
|
||||
* module is the only thing that can know they did.
|
||||
*
|
||||
* **It answers nothing, and the reason is a trap worth naming.** The obvious
|
||||
* return is "was this new", read off `affectedRows` — 1 for an insert, 2 for an
|
||||
* update. That is true only without `CLIENT_FOUND_ROWS`, and this connector
|
||||
* sends it: with it, a re-report whose values are identical also answers 1, so
|
||||
* the flag would report every idempotent retry as a fresh participant. The
|
||||
* caller wants "how many were reported" anyway, which it already knows from the
|
||||
* length of its own list.
|
||||
*/
|
||||
async function record({ runId, memberKey, userId = null, score = 0, meta = null, joinedAt = null }) {
|
||||
await query(
|
||||
`INSERT INTO event_run_participants (run_id, member_key, user_id, score, meta, joined_at)
|
||||
VALUES (?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))
|
||||
ON DUPLICATE KEY UPDATE
|
||||
user_id = VALUES(user_id),
|
||||
score = VALUES(score),
|
||||
meta = VALUES(meta)`,
|
||||
[runId, memberKey, userId, score, meta === null ? null : JSON.stringify(meta), joinedAt],
|
||||
)
|
||||
}
|
||||
|
||||
/** One run's participants, best first. The results table, and the console's. */
|
||||
async function listForRun(runId, limit = 500) {
|
||||
const rows = await query(
|
||||
`SELECT ${COLUMNS} FROM event_run_participants
|
||||
WHERE run_id = ?
|
||||
ORDER BY score DESC, joined_at ASC, id ASC
|
||||
LIMIT ?`,
|
||||
[runId, limit],
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* One account's participation history, most recent event first (Phase 14a).
|
||||
*
|
||||
* **Joined all the way out to the definition, and the join is the access
|
||||
* control.** A rehearsal is excluded by §D's own rule, and an unlisted
|
||||
* definition is excluded because unlisting is what an operator does to an event
|
||||
* they are not announcing — a history that named it would announce it to
|
||||
* everyone who attended, which is everyone who could tell anybody.
|
||||
*
|
||||
* `member_key` is NOT selected. It is the game's identifier for a character and
|
||||
* the caller is a player reading their own page; the run, the date, the score
|
||||
* and the rank are what a history is, and the key adds a module-opaque string
|
||||
* nothing on the page can render.
|
||||
*
|
||||
* `rank_at` is null until results are published, and that is a real state the
|
||||
* screen shows rather than an error — a run whose participants are collected
|
||||
* and unranked is exactly what Phase 10 made visible on the admin side.
|
||||
*/
|
||||
async function listForUser(userId, { limit = 50, before = null } = {}) {
|
||||
const n = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||
const args = [userId]
|
||||
// A keyset cursor on the participation row rather than an offset: the list
|
||||
// gains a row every time the reader attends something, and an offset page two
|
||||
// would skip whatever arrived in between.
|
||||
const cursor = before ? ' AND p.id < ?' : ''
|
||||
if (before) args.push(before)
|
||||
const rows = await query(
|
||||
`SELECT p.id, p.run_id, p.score, p.rank_at, p.joined_at, p.meta,
|
||||
r.scheduled_for, r.started_at, r.ended_at, r.status, r.scope,
|
||||
r.timezone, r.results_published_at,
|
||||
d.title AS definition_title, d.slug AS definition_slug,
|
||||
s.name AS series_name, s.slug AS series_slug
|
||||
FROM event_run_participants p
|
||||
JOIN event_runs r ON r.id = p.run_id
|
||||
JOIN event_definitions d ON d.id = r.definition_id
|
||||
LEFT JOIN event_series s ON s.id = d.series_id
|
||||
WHERE p.user_id = ?${cursor}
|
||||
AND r.rehearsal = 0
|
||||
AND d.listed = 1
|
||||
ORDER BY p.id DESC
|
||||
LIMIT ${n}`,
|
||||
args,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** How many the run has. Its own query because the trigger payload needs only this. */
|
||||
async function countForRun(runId) {
|
||||
const rows = await query('SELECT COUNT(*) AS n FROM event_run_participants WHERE run_id = ?', [runId])
|
||||
return Number(rows[0]?.n || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Number every participant of one run by score, best first.
|
||||
*
|
||||
* **One statement, and it has to be one.** The obvious form — `SET @rk := 0`
|
||||
* followed by an `UPDATE … SET rank_at = (@rk := @rk + 1) ORDER BY …` — is
|
||||
* wrong here in a way that would have passed every test that did not run twice
|
||||
* concurrently: `query()` takes a connection from the pool per call and releases
|
||||
* it, so the session variable is set on one connection and read on whichever the
|
||||
* second call happens to get. A window function needs no session state at all.
|
||||
*
|
||||
* **The ordering is total.** `score DESC` alone leaves ties in whatever order the
|
||||
* engine felt like, so two publications of the same run would hand out different
|
||||
* ranks to the same two people; `joined_at` then `id` breaks every tie the same
|
||||
* way every time, which is what makes re-publishing idempotent rather than a
|
||||
* reshuffle.
|
||||
*
|
||||
* Ties share nothing — two people on the same score get consecutive ranks rather
|
||||
* than a dense or competition ranking. That is a presentation decision belonging
|
||||
* to whatever renders the table; what this owes is a stable number.
|
||||
*/
|
||||
async function rankRun(runId) {
|
||||
const result = await query(
|
||||
`UPDATE event_run_participants p
|
||||
JOIN (SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC, joined_at ASC, id ASC) AS rk
|
||||
FROM event_run_participants
|
||||
WHERE run_id = ?) r ON r.id = p.id
|
||||
SET p.rank_at = r.rk`,
|
||||
[runId],
|
||||
)
|
||||
// The connector sends CLIENT_FOUND_ROWS, so this counts rows MATCHED rather
|
||||
// than rows changed — which is the number wanted here. Re-publishing a run
|
||||
// whose ranks are already correct answers "12 ranked", not "0".
|
||||
return Number(result.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { record, listForRun, listForUser, countForRun, rankRun }
|
||||
389
server/src/model/events/eventRunResources.db.js
Normal file
389
server/src/model/events/eventRunResources.db.js
Normal file
@@ -0,0 +1,389 @@
|
||||
// ── event_run_resources — SQL only ─────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §L ("The ledger's two rules"), and Phase 8 of EVENTS_PLAN.md.
|
||||
// Everything one run created or leased, and what became of it.
|
||||
//
|
||||
// **Rule 1 lives in `reserve()`.** A resource is recorded BEFORE it is
|
||||
// confirmed, so the placeholder this writes is the row that exists while the
|
||||
// dispatch is in flight — and the row that SURVIVES when the acknowledgement is
|
||||
// lost. Recording on the answer instead would make every object whose ack went
|
||||
// missing invisible to cleanup for ever.
|
||||
//
|
||||
// **Rule 2 lives in the status column and in `failRevert()`.** A revert that
|
||||
// never succeeds leaves its row unreverted, with the error on it, and the run
|
||||
// completes with `cleanup_status = 'incomplete'` rather than being held open.
|
||||
// Loud and sticky.
|
||||
//
|
||||
// **The unique key is enforced by the database, not by a read.** `reserve()`
|
||||
// answers `{ ok: false, code: 'held' }` on a duplicate key rather than checking
|
||||
// first and then inserting — two runs entering the same tick would both pass the
|
||||
// check. It is the argument `event_run_budget.spend()` makes about the cap and
|
||||
// `runsDb.transition` makes about a status, in the third place it applies.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
// The one `kind` core owns. A module's kinds are opaque and stored verbatim; this
|
||||
// one is core's own, and `registries` refuses a module resource that claims it.
|
||||
const STEP_KIND = '@step'
|
||||
|
||||
// The statuses that mean "core still believes this resource is this run's". They
|
||||
// are exactly the ones the `live_marker` generated column keeps non-NULL, so the
|
||||
// unique target key holds while a row is in one of them and releases when it
|
||||
// leaves. Duplicated here as a JavaScript list because the sweeps read by it too,
|
||||
// and a second copy that can drift is better than a query that cannot express it.
|
||||
const HELD = ['pending', 'confirmed', 'reverting']
|
||||
|
||||
// Every status that still wants a human or a retry: `HELD` plus the two that mean
|
||||
// "we let go, and not cleanly". This is what "unreverted" means everywhere in
|
||||
// this feature — the console's list, `cleanup_status`, and the manual retry.
|
||||
const UNRESOLVED = [...HELD, 'orphaned', 'drifted']
|
||||
|
||||
const COLUMNS = `id, run_id, step_id, owner_module, kind, ref, payload, lease_until,
|
||||
status, revert_attempts, last_error, member_key, created_at, updated_at`
|
||||
|
||||
// `payload` is opaque to core and stored verbatim, but it comes back as a string
|
||||
// from the driver and every caller wants the object — the cleanup sweep reads a
|
||||
// lease's baseline out of it, and the console renders it. Hydrated here for the
|
||||
// same reason a step's params are: one place rather than at each read.
|
||||
const hydrate = (row) => row && { ...row, payload: parseJson(row.payload, null) }
|
||||
|
||||
/**
|
||||
* Record a resource that does not exist yet.
|
||||
*
|
||||
* Answers `{ ok: true, id }`, or `{ ok: false, code: 'held', holder }` when the
|
||||
* target is already someone's — which is the lease conflict, surfaced as a
|
||||
* refusal rather than a failure because nothing is wrong with the system: another
|
||||
* run has the thing.
|
||||
*
|
||||
* **`ER_DUP_ENTRY` is the check.** The holder is looked up only to name it in the
|
||||
* refusal, and only after the insert has already lost the race.
|
||||
*/
|
||||
async function reserve({ runId, stepId = null, owner, kind, ref, payload = null, leaseUntil = null, memberKey = null }) {
|
||||
try {
|
||||
const result = await query(
|
||||
`INSERT INTO event_run_resources
|
||||
(run_id, step_id, owner_module, kind, ref, payload, lease_until, member_key, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')`,
|
||||
[runId, stepId, owner, kind, ref, payload === null ? null : JSON.stringify(payload), leaseUntil, memberKey],
|
||||
)
|
||||
return { ok: true, id: Number(result.insertId) }
|
||||
} catch (err) {
|
||||
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
|
||||
const [holder] = await query(
|
||||
`SELECT run_id, status FROM event_run_resources
|
||||
WHERE owner_module = ? AND kind = ? AND ref = ? AND status IN (?, ?, ?)
|
||||
LIMIT 1`,
|
||||
[owner, kind, ref, ...HELD],
|
||||
)
|
||||
return { ok: false, code: 'held', holder: holder || null }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a reserved row to `confirmed`, optionally attaching what the module
|
||||
* finally said about it.
|
||||
*
|
||||
* Guarded on `pending` so a late answer cannot un-revert a row cleanup has
|
||||
* already dealt with — the same reason every other write in this feature is a
|
||||
* compare-and-set rather than a read-then-write.
|
||||
*/
|
||||
async function confirm(id, { payload, leaseUntil, memberKey } = {}) {
|
||||
const sets = ["status = 'confirmed'"]
|
||||
const params = []
|
||||
if (payload !== undefined) {
|
||||
sets.push('payload = ?')
|
||||
params.push(payload === null ? null : JSON.stringify(payload))
|
||||
}
|
||||
if (leaseUntil !== undefined) {
|
||||
sets.push('lease_until = ?')
|
||||
params.push(leaseUntil)
|
||||
}
|
||||
if (memberKey !== undefined) {
|
||||
sets.push('member_key = ?')
|
||||
params.push(memberKey)
|
||||
}
|
||||
const result = await query(
|
||||
`UPDATE event_run_resources SET ${sets.join(', ')} WHERE id = ? AND status = 'pending'`,
|
||||
[...params, id],
|
||||
)
|
||||
return (result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a step placeholder once the module has named what it actually made.
|
||||
*
|
||||
* The placeholder's whole job is over at this point: the real rows exist, so the
|
||||
* `@step` row must stop being one of the things cleanup will try to revert.
|
||||
* `reverted` is the honest terminal state for it — there is nothing left to undo
|
||||
* that the rows it stood in for do not now cover — and it releases the
|
||||
* idempotency key for a later run, which matters because keys are per step and a
|
||||
* re-materialised step reuses its own.
|
||||
*/
|
||||
async function resolvePlaceholder(id) {
|
||||
const result = await query(
|
||||
`UPDATE event_run_resources
|
||||
SET status = 'reverted', last_error = NULL
|
||||
WHERE id = ? AND kind = ? AND status IN ('pending', 'confirmed')`,
|
||||
[id, STEP_KIND],
|
||||
)
|
||||
return (result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* One row by its target, live or not — how a caller that lost the insert race
|
||||
* finds the row it meant to write. Newest first, so a target that has been held
|
||||
* and released several times answers with the current holder.
|
||||
*/
|
||||
async function findByTarget(owner, kind, ref) {
|
||||
const [row] = await query(
|
||||
`SELECT ${COLUMNS} FROM event_run_resources
|
||||
WHERE owner_module = ? AND kind = ? AND ref = ?
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
[owner, kind, ref],
|
||||
)
|
||||
return hydrate(row) || null
|
||||
}
|
||||
|
||||
/** One run's whole ledger, oldest first — the console's read. */
|
||||
async function forRun(runId) {
|
||||
const rows = await query(
|
||||
`SELECT ${COLUMNS} FROM event_run_resources WHERE run_id = ? ORDER BY id`,
|
||||
[runId],
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** The rows of one run that still want something: the cleanup sweep's input. */
|
||||
async function unresolvedForRun(runId, { maxAttempts = null } = {}) {
|
||||
const params = [runId, ...UNRESOLVED]
|
||||
const attemptClause = maxAttempts === null ? '' : ' AND revert_attempts < ?'
|
||||
if (maxAttempts !== null) params.push(maxAttempts)
|
||||
const rows = await query(
|
||||
`SELECT ${COLUMNS} FROM event_run_resources
|
||||
WHERE run_id = ? AND status IN (?, ?, ?, ?, ?)${attemptClause}
|
||||
ORDER BY id`,
|
||||
params,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** How many of one run's rows are still unresolved — what `cleanup_status` is derived from. */
|
||||
async function unresolvedCount(runId) {
|
||||
const [row] = await query(
|
||||
`SELECT COUNT(*) AS n FROM event_run_resources
|
||||
WHERE run_id = ? AND status IN (?, ?, ?, ?, ?)`,
|
||||
[runId, ...UNRESOLVED],
|
||||
)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
/** Unresolved counts for several runs at once, keyed by run id — the run LIST's read. */
|
||||
async function unresolvedCounts(runIds) {
|
||||
const ids = [...new Set(runIds || [])].filter(Boolean)
|
||||
if (!ids.length) return new Map()
|
||||
const rows = await query(
|
||||
`SELECT run_id, COUNT(*) AS n FROM event_run_resources
|
||||
WHERE run_id IN (${ids.map(() => '?').join(',')}) AND status IN (?, ?, ?, ?, ?)
|
||||
GROUP BY run_id`,
|
||||
[...ids, ...UNRESOLVED],
|
||||
)
|
||||
return new Map(rows.map((r) => [r.run_id, Number(r.n)]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim one row for a revert: `pending | confirmed | orphaned | drifted → reverting`,
|
||||
* and `reverting` again once the claim on it has gone stale.
|
||||
*
|
||||
* The compare-and-set that keeps the cleanup leg and the manual cleanup route off
|
||||
* each other's rows. A row another pass is mid-revert on is left alone, exactly as
|
||||
* a step with a live claim is.
|
||||
*
|
||||
* **"Exactly as a step" has to include the expiry, and it did not until the Phase
|
||||
* 16 acceptance walk.** A step's claim carries `claim_expires_at`, so a step whose
|
||||
* process died is reclaimed once the lease lapses — that reclaim is the whole
|
||||
* reason §E's CAS survives §N4's single instance. A `reverting` row had no such
|
||||
* bound and nothing released it, so a process killed mid-teardown stranded the row
|
||||
* for good: the sweep skipped it every 15s forever, `cleanup_status` never left
|
||||
* `pending`, and `POST …/cleanup` — the recourse §I names — answered 200 and did
|
||||
* nothing, because it claims through this same function. Observed with a lease,
|
||||
* which then blocked the NEXT run of the same event from taking the value.
|
||||
*
|
||||
* The stale test is `updated_at`, not a new column: the row is stamped exactly
|
||||
* when it enters `reverting` and is not written again until the revert resolves,
|
||||
* so for a `reverting` row `updated_at` IS "when this claim was taken". The bound
|
||||
* is the run lease's, for the run lease's reason — it has to outlast a whole
|
||||
* tick's work on one run, and every revert in a sweep is bounded by its action's
|
||||
* own `budgetMs` long before this.
|
||||
*
|
||||
* `revert_attempts` is deliberately NOT incremented by reclaiming. A stale claim
|
||||
* is a process that died, not an attempt that failed, and counting it would burn
|
||||
* the retry budget on crashes — Engagement Phase 14's rule, one table over.
|
||||
*
|
||||
* **`updated_at` is re-stamped explicitly, and that is what keeps this a CAS.**
|
||||
* This connector sends `CLIENT_FOUND_ROWS`, so `affectedRows` counts rows MATCHED
|
||||
* rather than changed. For the four fresh statuses that is harmless — the winner
|
||||
* moves the row to `reverting` and the loser's `status IN (…)` no longer matches.
|
||||
* A stale `reverting` row has no such natural change: without re-stamping, the
|
||||
* row would still satisfy `status = 'reverting' AND updated_at < …` and a second
|
||||
* claimer would match it too. Writing the column is what makes the second one
|
||||
* miss.
|
||||
*/
|
||||
const REVERT_CLAIM_TTL_MS = Number(process.env.EVENT_REVERT_CLAIM_TTL_MS) || 15 * 60 * 1000
|
||||
|
||||
async function claimRevert(id) {
|
||||
const result = await query(
|
||||
`UPDATE event_run_resources
|
||||
SET status = 'reverting', updated_at = NOW()
|
||||
WHERE id = ?
|
||||
AND (status IN ('pending', 'confirmed', 'orphaned', 'drifted')
|
||||
OR (status = 'reverting'
|
||||
AND updated_at < (NOW() - INTERVAL ? MICROSECOND)))`,
|
||||
[id, REVERT_CLAIM_TTL_MS * 1000],
|
||||
)
|
||||
return (result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/** The revert worked. `reverted` is terminal and releases the target. */
|
||||
async function markReverted(id) {
|
||||
await query(
|
||||
`UPDATE event_run_resources SET status = 'reverted', last_error = NULL WHERE id = ?`,
|
||||
[id],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The revert did not work, and the row goes back to being unresolved.
|
||||
*
|
||||
* `revert_attempts` is incremented here and NOWHERE else, and it is never reset by
|
||||
* a sweep — Engagement Phase 14's rule, whose defect was a reclaim that returned
|
||||
* every stale row to its start state and made the attempt ceiling unreachable, so
|
||||
* the row cycled for ever and was never eligible for any retention sweep. The one
|
||||
* thing that may reset it is a human pressing cleanup, which is the same licence
|
||||
* a human's step retry has.
|
||||
*
|
||||
* `restoreTo` is where the row lands: `drifted` when the module says somebody else
|
||||
* moved the value, `orphaned` when it says the thing is gone, and `confirmed`
|
||||
* otherwise — still ours, still out there, try again.
|
||||
*/
|
||||
async function failRevert(id, error, restoreTo = 'confirmed') {
|
||||
await query(
|
||||
`UPDATE event_run_resources
|
||||
SET status = ?, revert_attempts = revert_attempts + 1, last_error = ?
|
||||
WHERE id = ?`,
|
||||
[restoreTo, String(error || 'the revert did not answer').slice(0, 500), id],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A human is trying again: clear the attempt counter on one run's unresolved rows.
|
||||
*
|
||||
* Only ever called from the cleanup route with an actor behind it. The automatic
|
||||
* leg must never do this (see `failRevert`).
|
||||
*/
|
||||
async function resetAttempts(runId) {
|
||||
const result = await query(
|
||||
`UPDATE event_run_resources
|
||||
SET revert_attempts = 0
|
||||
WHERE run_id = ? AND status IN (?, ?, ?, ?, ?)`,
|
||||
[runId, ...UNRESOLVED],
|
||||
)
|
||||
return result.affectedRows || 0
|
||||
}
|
||||
|
||||
/** Every live row one module owns, for the reconcile sweep. */
|
||||
async function liveForModule(owner, { limit = 500 } = {}) {
|
||||
const rows = await query(
|
||||
`SELECT ${COLUMNS} FROM event_run_resources
|
||||
WHERE owner_module = ? AND status IN ('pending', 'confirmed')
|
||||
ORDER BY id LIMIT ?`,
|
||||
[owner, Number(limit)],
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** Every module that currently owns a live row — who the reconcile sweep asks. */
|
||||
async function modulesWithLiveRows() {
|
||||
const rows = await query(
|
||||
`SELECT DISTINCT owner_module FROM event_run_resources
|
||||
WHERE status IN ('pending', 'confirmed')`,
|
||||
)
|
||||
return rows.map((r) => r.owner_module)
|
||||
}
|
||||
|
||||
/**
|
||||
* The game no longer has it. Never reached by a revert — a revert that finds
|
||||
* nothing there is a SUCCESS (§L, and it is what a Rust wipe needs) — only by
|
||||
* reconcile, which is a different question: nobody asked for this to go.
|
||||
*/
|
||||
async function markOrphaned(id, detail = null) {
|
||||
await query(
|
||||
`UPDATE event_run_resources
|
||||
SET status = 'orphaned', last_error = ?
|
||||
WHERE id = ? AND status IN ('pending', 'confirmed', 'reverting')`,
|
||||
[detail === null ? null : String(detail).slice(0, 500), id],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal runs that still owe the world something — the cleanup leg's scan.
|
||||
*
|
||||
* **Both halves of the WHERE were live-walk findings, and they are opposite
|
||||
* mistakes.**
|
||||
*
|
||||
* `cleanup_status = 'pending'` alone missed a run whose only resource was a
|
||||
* LEASE: `core.lease` reserves its own row and never goes through the ledger's
|
||||
* `markRunDirty`, so the flag stayed `not_required` and the lease was never given
|
||||
* back at all. Hence `not_required` is in the list — a terminal run with an
|
||||
* unresolved row has something to do whatever any summary column says, and
|
||||
* treating that combination as work is the fail-safe direction.
|
||||
*
|
||||
* And the run status filter alone made `MAX_REVERT_ATTEMPTS` mean ONE attempt,
|
||||
* because the first failing sweep set `incomplete` and nothing looked at the run
|
||||
* again. That is fixed in `cleanupRun`, which now only writes `incomplete` once
|
||||
* there is nothing left it will try — so `incomplete` genuinely means "finished
|
||||
* with, and not finished", which is exactly what excludes both a run whose
|
||||
* retries are spent and a run an admin cancelled without cleanup.
|
||||
*
|
||||
* The attempt bound is in the join for a different reason: without it a run whose
|
||||
* rows are all spent would be selected, worked over and found to have nothing to
|
||||
* do on every tick for the rest of its life.
|
||||
*/
|
||||
async function runsNeedingCleanup(limit = 25, maxAttempts = 3) {
|
||||
return query(
|
||||
`SELECT DISTINCT r.id, r.status, r.cleanup_status, r.version_id, r.definition_id, r.scope
|
||||
FROM event_runs r
|
||||
JOIN event_run_resources res ON res.run_id = r.id
|
||||
WHERE r.status IN ('completed', 'cancelled', 'failed', 'missed')
|
||||
AND r.cleanup_status IN ('pending', 'not_required')
|
||||
AND res.status IN (?, ?, ?, ?, ?)
|
||||
AND res.revert_attempts < ?
|
||||
ORDER BY r.id
|
||||
LIMIT ?`,
|
||||
[...UNRESOLVED, Number(maxAttempts), Number(limit)],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STEP_KIND,
|
||||
HELD,
|
||||
UNRESOLVED,
|
||||
reserve,
|
||||
confirm,
|
||||
resolvePlaceholder,
|
||||
findByTarget,
|
||||
forRun,
|
||||
unresolvedForRun,
|
||||
unresolvedCount,
|
||||
unresolvedCounts,
|
||||
claimRevert,
|
||||
markReverted,
|
||||
failRevert,
|
||||
resetAttempts,
|
||||
liveForModule,
|
||||
modulesWithLiveRows,
|
||||
markOrphaned,
|
||||
runsNeedingCleanup,
|
||||
}
|
||||
421
server/src/model/events/eventRunSteps.db.js
Normal file
421
server/src/model/events/eventRunSteps.db.js
Normal file
@@ -0,0 +1,421 @@
|
||||
// ── event_run_steps — SQL only ─────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §E. Phase 1 materialises a run's steps and reads them back
|
||||
// for the run console. **Draining them is Phase 2's**: the CAS claim, the lease,
|
||||
// the attempt counter and the classification of a module's answer are the
|
||||
// runner, and none of them is stubbed here.
|
||||
//
|
||||
// The one runtime property Phase 1 does have to get right is the idempotency key
|
||||
// (§E). Core mints it ONCE, at materialisation, and it does NOT vary by attempt —
|
||||
// a retry re-sends the same key so the game side can recognise the repeat. That
|
||||
// makes it a property of the INSERT below rather than of the dispatch, which is
|
||||
// the only reason it can be stable at all.
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const hydrate = (row) => row && { ...row, params: parseJson(row.params, {}) }
|
||||
|
||||
/**
|
||||
* `sha256(runId | stepId)`, truncated to 40 hex — the shape `shardEvents.dedupeKey`
|
||||
* already uses, so the two dedupe keys on this codebase read alike.
|
||||
*
|
||||
* The step id is not known until the row exists, so materialisation inserts with
|
||||
* a provisional key and stamps the real one immediately afterwards. That is one
|
||||
* extra statement per step and it buys the property the whole retry story rests
|
||||
* on: the key is a function of identity, never of attempt or of clock.
|
||||
*/
|
||||
const idempotencyKey = (runId, stepId) =>
|
||||
crypto.createHash('sha256').update(`${runId}|${stepId}`).digest('hex').slice(0, 40)
|
||||
|
||||
const listForRun = async (runId) =>
|
||||
(
|
||||
await query(
|
||||
'SELECT * FROM event_run_steps WHERE run_id = ? ORDER BY phase, seq, id',
|
||||
[runId],
|
||||
)
|
||||
).map(hydrate)
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query('SELECT * FROM event_run_steps WHERE id = ?', [id])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialise one phase's steps.
|
||||
*
|
||||
* `INSERT IGNORE` against `UNIQUE (run_id, phase, seq)`, so a tick that overran
|
||||
* into the next one cannot double-materialise a phase — the same argument the
|
||||
* occurrence key makes one table up, at the other end of the run.
|
||||
*
|
||||
* Returns the rows as they now stand, created or pre-existing, so a caller that
|
||||
* lost the race still gets the step ids.
|
||||
*/
|
||||
const materialisePhase = async (runId, phase, steps) => {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i]
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO event_run_steps
|
||||
(run_id, phase, seq, action_id, params, action_version, on_failure, idempotency_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '')`,
|
||||
[
|
||||
runId,
|
||||
phase,
|
||||
i,
|
||||
step.actionId,
|
||||
JSON.stringify(step.params || {}),
|
||||
step.actionVersion || 1,
|
||||
step.onFailure || 'pause',
|
||||
],
|
||||
)
|
||||
if (Number(result?.affectedRows || 0) === 1) {
|
||||
// Stamped in a second statement because the key is a function of the row's
|
||||
// own id. Scoped by the empty key so a re-run of this loop over an existing
|
||||
// phase can never overwrite a key a dispatch has already sent.
|
||||
await query(
|
||||
"UPDATE event_run_steps SET idempotency_key = ? WHERE id = ? AND idempotency_key = ''",
|
||||
[idempotencyKey(runId, result.insertId), result.insertId],
|
||||
)
|
||||
}
|
||||
}
|
||||
return listForRun(runId)
|
||||
}
|
||||
|
||||
/** The run console's summary line: how many steps sit in each status. */
|
||||
const statusCounts = async (runId) => {
|
||||
const rows = await query(
|
||||
'SELECT status, COUNT(*) AS n FROM event_run_steps WHERE run_id = ? GROUP BY status',
|
||||
[runId],
|
||||
)
|
||||
return Object.fromEntries(rows.map((r) => [r.status, Number(r.n)]))
|
||||
}
|
||||
|
||||
// ── Phase 2: draining a step ───────────────────────────────────────────────
|
||||
//
|
||||
// The CAS claim, the lease, the attempt counter and the terminal writes. Phase 1
|
||||
// left all of it out rather than stubbing it, and this is where it lands.
|
||||
//
|
||||
// **Two rules govern everything below, and both were paid for once already.**
|
||||
//
|
||||
// 1. `attempts` is incremented by the CLAIM and by nothing else, and no recovery
|
||||
// path ever resets it. Engagement Phase 14's defect was a stale-row sweep that
|
||||
// returned rows to their start state: the attempt ceiling became unreachable,
|
||||
// so the row cycled forever, never reached a terminal status, and was
|
||||
// therefore never eligible for any retention sweep.
|
||||
// 2. A PARKED step is `running` with a NULL lease, and the reclaim only ever
|
||||
// touches a lease that is non-NULL and expired (the org lead's answer,
|
||||
// 2026-09-02). That is what lets a GM cue wait for a human overnight without a
|
||||
// sweep re-dispatching the instruction every fifteen minutes.
|
||||
|
||||
// A run's steps in authored order, for the phase the run is currently in.
|
||||
const listForPhase = async (runId, phase) =>
|
||||
(
|
||||
await query(
|
||||
'SELECT * FROM event_run_steps WHERE run_id = ? AND phase = ? ORDER BY seq, id',
|
||||
[runId, phase],
|
||||
)
|
||||
).map(hydrate)
|
||||
|
||||
/**
|
||||
* The next step of a phase that the runner may work on, or null.
|
||||
*
|
||||
* **Steps within a phase are strictly serial.** This returns the lowest-`seq`
|
||||
* step that is not terminal, and the runner does nothing with step N+1 until N
|
||||
* has finished — which is the only reading under which `core.wait` means anything
|
||||
* at all, and the only one under which a cue can gate what follows it.
|
||||
*
|
||||
* A parked or running step is returned too, so the caller can see that the phase
|
||||
* is occupied rather than concluding it is finished.
|
||||
*/
|
||||
const nextOpenStep = async (runId, phase) => {
|
||||
const [row] = await query(
|
||||
`SELECT * FROM event_run_steps
|
||||
WHERE run_id = ? AND phase = ?
|
||||
AND status IN ('pending','running')
|
||||
ORDER BY seq, id LIMIT 1`,
|
||||
[runId, phase],
|
||||
)
|
||||
return hydrate(row) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Take ownership of one pending step: the CAS `pending -> running`, plus a lease.
|
||||
*
|
||||
* `due_at` is honoured here rather than in the caller's filter so that the whole
|
||||
* decision — is it mine, is it due — is one statement the database arbitrates. A
|
||||
* NULL `due_at` is due now, which is what materialisation writes for every step
|
||||
* that is not sitting behind a `core.wait`.
|
||||
*/
|
||||
async function claim(id, owner, leaseUntil, now) {
|
||||
const result = await query(
|
||||
`UPDATE event_run_steps
|
||||
SET status = 'running', attempts = attempts + 1, claimed_by = ?, claim_expires_at = ?,
|
||||
started_at = COALESCE(started_at, NOW())
|
||||
WHERE id = ? AND status = 'pending' AND (due_at IS NULL OR due_at <= ?)`,
|
||||
[owner, leaseUntil, id, now],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Park a claimed step: it stays `running`, and its lease goes NULL.
|
||||
*
|
||||
* This is the whole mechanism behind `core.cue`. The step is genuinely in flight
|
||||
* — an instruction has been posted and nothing else in the phase may proceed —
|
||||
* but no process is holding it, so the reclaim must not take it back. A NULL
|
||||
* lease says exactly that, and `reclaimStale` below is written to agree.
|
||||
*/
|
||||
const park = (id, note) =>
|
||||
query(
|
||||
`UPDATE event_run_steps SET claim_expires_at = NULL, last_error = ?
|
||||
WHERE id = ? AND status = 'running'`,
|
||||
[note ? String(note).slice(0, 500) : null, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* Release a claimed step back to `pending` for a later attempt.
|
||||
*
|
||||
* `attempts` is untouched — it was already incremented by the claim, which is the
|
||||
* only place that may. Backoff is flat rather than exponential for the reason the
|
||||
* outbox's is: `due_at` is also the event's own clock, and a doubling backoff
|
||||
* pushes a step arbitrarily far past the moment the event was about.
|
||||
*/
|
||||
const reschedule = (id, dueAt, error) =>
|
||||
query(
|
||||
`UPDATE event_run_steps
|
||||
SET status = 'pending', due_at = ?, claimed_by = NULL, claim_expires_at = NULL, last_error = ?
|
||||
WHERE id = ? AND status = 'running'`,
|
||||
[dueAt, error ? String(error).slice(0, 500) : null, id],
|
||||
)
|
||||
|
||||
/** A terminal outcome for one step: done, failed, skipped, refused or cancelled. */
|
||||
const finish = (id, status, error) =>
|
||||
query(
|
||||
`UPDATE event_run_steps
|
||||
SET status = ?, last_error = ?, finished_at = NOW(),
|
||||
claimed_by = NULL, claim_expires_at = NULL
|
||||
WHERE id = ? AND status = 'running'`,
|
||||
[status, error ? String(error).slice(0, 500) : null, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* Delay the next not-yet-started step of a phase — what `core.wait` actually does.
|
||||
*
|
||||
* The wait step itself completes normally; the pause is the NEXT step's `due_at`,
|
||||
* owned by the runner. A `perform()` that slept would hold its claim for the
|
||||
* duration and turn a five-minute pause into a five-minute lease, which is the
|
||||
* one shape this must not have.
|
||||
*
|
||||
* Guarded on `status = 'pending'` and on the current `due_at` being sooner, so a
|
||||
* re-dispatch of a wait whose ack was lost cannot push the following step further
|
||||
* out a second time.
|
||||
*/
|
||||
const holdNext = async (runId, phase, afterSeq, dueAt) => {
|
||||
const result = await query(
|
||||
`UPDATE event_run_steps
|
||||
SET due_at = ?
|
||||
WHERE run_id = ? AND phase = ? AND seq > ? AND status = 'pending'
|
||||
AND (due_at IS NULL OR due_at < ?)
|
||||
ORDER BY seq LIMIT 1`,
|
||||
[dueAt, runId, phase, afterSeq, dueAt],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover steps whose claim outlived the process that took it.
|
||||
*
|
||||
* **`attempts` is not reset and the lease being NULL is not staleness.** The
|
||||
* first is Engagement Phase 14's rule; the second is what makes a parked cue
|
||||
* survive. A step that has already burned its attempts leaves `running` as
|
||||
* `failed` rather than being handed back, and in that order — a reclaim that ran
|
||||
* first would return it to `pending` and it would be retried forever.
|
||||
*/
|
||||
const reclaimStale = async (now, maxAttempts = 0) => {
|
||||
let failed = 0
|
||||
if (Number(maxAttempts) > 0) {
|
||||
const gaveUp = await query(
|
||||
`UPDATE event_run_steps
|
||||
SET status = 'failed', last_error = 'gave up after repeated interruptions',
|
||||
finished_at = NOW(), claimed_by = NULL, claim_expires_at = NULL
|
||||
WHERE status = 'running'
|
||||
AND claim_expires_at IS NOT NULL AND claim_expires_at < ?
|
||||
AND attempts >= ?`,
|
||||
[now, Math.floor(maxAttempts)],
|
||||
)
|
||||
failed = Number(gaveUp?.affectedRows || 0)
|
||||
}
|
||||
const reclaimed = await query(
|
||||
`UPDATE event_run_steps
|
||||
SET status = 'pending', claimed_by = NULL, claim_expires_at = NULL
|
||||
WHERE status = 'running' AND claim_expires_at IS NOT NULL AND claim_expires_at < ?`,
|
||||
[now],
|
||||
)
|
||||
return { failed, reclaimed: Number(reclaimed?.affectedRows || 0) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel every step of a run that has not started (Phase 3's cancel, and the
|
||||
* abort_run disposition).
|
||||
*
|
||||
* A `running` step is deliberately left alone, parked or not: nothing can recall
|
||||
* a command already sent, and a second writer on that row would race the process
|
||||
* that owns it (§L).
|
||||
*/
|
||||
const cancelPending = async (runId) => {
|
||||
const result = await query(
|
||||
`UPDATE event_run_steps
|
||||
SET status = 'cancelled', finished_at = NOW()
|
||||
WHERE run_id = ? AND status = 'pending'`,
|
||||
[runId],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
// ── Phase 3: the controls a human works ────────────────────────────────────
|
||||
//
|
||||
// Four statements, and every one of them is guarded on the status it is allowed
|
||||
// to act from rather than trusting the button that was pressed. The run console
|
||||
// decides what to OFFER; these decide what may happen, and they disagree on
|
||||
// purpose — a console rendered thirty seconds ago is a console describing a run
|
||||
// that has since moved.
|
||||
//
|
||||
// **A parked step is `running` with a NULL lease**, and that pair is the whole
|
||||
// vocabulary these need. `park()` above is the only thing that produces it, so
|
||||
// `status = 'running' AND claim_expires_at IS NULL` names a cue waiting on a
|
||||
// human and cannot name a step some process is mid-dispatch on. Confirm and skip
|
||||
// are both written against it, which is what makes them safe to expose to a
|
||||
// moderator: neither can touch a step the runner is holding.
|
||||
|
||||
/**
|
||||
* The highest `seq` of a step in this phase that is not still `pending` — the
|
||||
* furthest the phase has got — or null if none of it has been attempted.
|
||||
*
|
||||
* It exists for the retry control, and the definition is chosen to agree with
|
||||
* the runner's own cursor rather than to look tidy. Steps within a phase are
|
||||
* strictly serial, so the last step that is not pending is the last one the
|
||||
* runner worked on; if the run is `paused` that step is what it paused at.
|
||||
*
|
||||
* **The near miss worth recording: "the lowest step that is not settled" is the
|
||||
* wrong rule**, and it looks right. `nextOpenStep` selects `pending` and
|
||||
* `running` only, so a `failed` step is one the runner has already stepped OVER
|
||||
* — which is exactly what an `on_failure` of `skip` produces. Under that rule a
|
||||
* phase whose second step failed-and-skipped and whose fifth then failed-and-
|
||||
* paused would offer retry on the second, re-queueing a row behind the runner's
|
||||
* cursor where it would sit pending for ever.
|
||||
*/
|
||||
const lastStartedSeq = async (runId, phase) => {
|
||||
const [row] = await query(
|
||||
`SELECT MAX(seq) AS seq FROM event_run_steps
|
||||
WHERE run_id = ? AND phase = ? AND status <> 'pending'`,
|
||||
[runId, phase],
|
||||
)
|
||||
return row?.seq === null || row?.seq === undefined ? null : Number(row.seq)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a parked step: the GM cue's confirm.
|
||||
*
|
||||
* `done` rather than `skipped` — a human saying they did the thing is the step
|
||||
* having succeeded, and it is the only outcome under which the instruction was
|
||||
* actually carried out. The note is kept in `last_error` for the same reason the
|
||||
* park's is: it is the column the console already renders beside the step, and a
|
||||
* second one for prose would be a column two writers disagree about.
|
||||
*/
|
||||
const confirmParked = async (id, note) => {
|
||||
const result = await query(
|
||||
`UPDATE event_run_steps
|
||||
SET status = 'done', finished_at = NOW(), claimed_by = NULL,
|
||||
last_error = ?
|
||||
WHERE id = ? AND status = 'running' AND claim_expires_at IS NULL`,
|
||||
[note ? String(note).slice(0, 500) : null, id],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip a step a human has decided not to run: `pending`, or a parked cue.
|
||||
*
|
||||
* This is what `skipped` was reserved for (§L). A `running` step with a live
|
||||
* lease is excluded — nothing can recall a command already sent — and a `failed`
|
||||
* one is excluded because it is already terminal and the run's own resume is
|
||||
* what carries the phase past it.
|
||||
*/
|
||||
const skipByHuman = async (id, reason) => {
|
||||
const result = await query(
|
||||
`UPDATE event_run_steps
|
||||
SET status = 'skipped', finished_at = NOW(), claimed_by = NULL,
|
||||
last_error = ?
|
||||
WHERE id = ?
|
||||
AND (status = 'pending' OR (status = 'running' AND claim_expires_at IS NULL))`,
|
||||
[reason ? String(reason).slice(0, 500) : null, id],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a failed step back in the queue for another attempt.
|
||||
*
|
||||
* **`attempts` goes back to zero, and that is not the rule Engagement Phase 14
|
||||
* arrived at being broken.** That rule is about SWEEPS: an automatic path that
|
||||
* reset a counter made the ceiling unreachable and the row immortal. This is a
|
||||
* named person deciding, once, that the thing which failed three times will work
|
||||
* now — `EVENT_STEP_MAX_ATTEMPTS` bounds what the runner does unattended, and a
|
||||
* human is the thing it is unattended from. The decision is in the run log with
|
||||
* the actor on it.
|
||||
*/
|
||||
const requeue = async (id) => {
|
||||
const result = await query(
|
||||
`UPDATE event_run_steps
|
||||
SET status = 'pending', attempts = 0, due_at = NULL, last_error = NULL,
|
||||
claimed_by = NULL, claim_expires_at = NULL, finished_at = NULL
|
||||
WHERE id = ? AND status = 'failed'`,
|
||||
[id],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Close out every step a cancelled run will never run: pending, and parked.
|
||||
*
|
||||
* Wider than `cancelPending` by exactly one case, and deliberately so. §L leaves
|
||||
* a `running` step alone because nothing can recall a sent command — but a
|
||||
* parked cue is not a sent command, it is an instruction nobody is holding, and
|
||||
* leaving it `running` after the run was cancelled would leave the console
|
||||
* claiming a cancelled event is still waiting for someone. The live lease is
|
||||
* what distinguishes them, and it is in the WHERE clause.
|
||||
*/
|
||||
const cancelOpen = async (runId) => {
|
||||
const result = await query(
|
||||
`UPDATE event_run_steps
|
||||
SET status = 'cancelled', finished_at = NOW(), claimed_by = NULL
|
||||
WHERE run_id = ?
|
||||
AND (status = 'pending' OR (status = 'running' AND claim_expires_at IS NULL))`,
|
||||
[runId],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listForRun,
|
||||
listForPhase,
|
||||
getById,
|
||||
materialisePhase,
|
||||
statusCounts,
|
||||
idempotencyKey,
|
||||
nextOpenStep,
|
||||
claim,
|
||||
park,
|
||||
reschedule,
|
||||
finish,
|
||||
holdNext,
|
||||
reclaimStale,
|
||||
cancelPending,
|
||||
lastStartedSeq,
|
||||
confirmParked,
|
||||
skipByHuman,
|
||||
requeue,
|
||||
cancelOpen,
|
||||
}
|
||||
603
server/src/model/events/eventRuns.db.js
Normal file
603
server/src/model/events/eventRuns.db.js
Normal file
@@ -0,0 +1,603 @@
|
||||
// ── event_runs — SQL only ──────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §E. Phase 1 writes exactly one kind of row — a `scheduled`
|
||||
// occurrence — and reads them back for the admin surface. **The claim, the CAS
|
||||
// transitions and the lease reclaim are Phase 2's** and are deliberately not
|
||||
// stubbed here: a half-written claim is worse than no claim, because it reads as
|
||||
// protection.
|
||||
//
|
||||
// What Phase 1 does own is the INSERT, and it owns the important half of it:
|
||||
// materialisation is `INSERT IGNORE` against `UNIQUE (definition_id, scope,
|
||||
// scheduled_for)`, so a second attempt at one occurrence writes nothing and
|
||||
// answers honestly rather than raising a duplicate-key error a caller has to
|
||||
// interpret.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), rehearsal: Boolean(row.rehearsal) }
|
||||
|
||||
// The statuses a run never leaves. A transition INTO one of these stamps
|
||||
// `ended_at` and drops the claim, and only rows in one of them are eligible for
|
||||
// the log retention sweep -- Engagement Phase 14's rule, which is a bound at all
|
||||
// only if every path a run can take reaches one of them.
|
||||
const TERMINAL = ['completed', 'cancelled', 'failed', 'missed']
|
||||
|
||||
// §E's health values, worst last. Health is a HIGH-WATER MARK in this system —
|
||||
// nothing has ever cleared `degraded`, because a run whose announcement landed
|
||||
// on the second attempt did have trouble and that stays true for the rest of its
|
||||
// life — and `setHealth` enforces that rather than leaving it to every caller to
|
||||
// remember. `FIELD()` gives the same order inside the WHERE clause, 1-indexed,
|
||||
// which is what makes the guard one statement rather than a read and a write.
|
||||
const HEALTH_ORDER = ['ok', 'degraded', 'stalled']
|
||||
const HEALTH_RANK = Object.fromEntries(HEALTH_ORDER.map((h, i) => [h, i + 1]))
|
||||
const HEALTH_SQL_ORDER = HEALTH_ORDER.map((h) => `'${h}'`).join(', ')
|
||||
|
||||
// `waiting_steps` is the count of PARKED steps: `running` with a NULL lease, the
|
||||
// pair `park()` alone produces, which means a cue waiting on a human. It is a
|
||||
// correlated subquery on an admin list bounded at 500 rows rather than a column,
|
||||
// because it is derived from the steps and a column would be a second writer's
|
||||
// opinion of them. It earns its cost on the list screen: a cue nobody notices is
|
||||
// a run that never advances, and the run itself looks perfectly healthy until
|
||||
// somebody opens it.
|
||||
const SELECT_LIST = `
|
||||
SELECT r.*, d.title AS definition_title, d.slug AS definition_slug, v.version AS version_number,
|
||||
(SELECT COUNT(*) FROM event_run_steps s
|
||||
WHERE s.run_id = r.id AND s.status = 'running' AND s.claim_expires_at IS NULL) AS waiting_steps
|
||||
FROM event_runs r
|
||||
JOIN event_definitions d ON d.id = r.definition_id
|
||||
JOIN event_versions v ON v.id = r.version_id
|
||||
`
|
||||
|
||||
/**
|
||||
* The admin run list. Newest occurrence first, across every definition.
|
||||
*
|
||||
* `limit` is interpolated after an integer coercion rather than bound, because
|
||||
* MariaDB will not take a placeholder in LIMIT on a prepared statement. It never
|
||||
* reaches SQL as anything but a number.
|
||||
*/
|
||||
const list = async ({ definitionId = null, status = null, limit = 100 } = {}) => {
|
||||
const where = []
|
||||
const args = []
|
||||
if (definitionId) {
|
||||
where.push('r.definition_id = ?')
|
||||
args.push(definitionId)
|
||||
}
|
||||
if (status) {
|
||||
where.push('r.status = ?')
|
||||
args.push(status)
|
||||
}
|
||||
const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''
|
||||
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
|
||||
const rows = await query(
|
||||
`${SELECT_LIST} ${clause} ORDER BY r.scheduled_for DESC, r.id DESC LIMIT ${n}`,
|
||||
args,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query(`${SELECT_LIST} WHERE r.id = ?`, [id])
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialise one occurrence. Answers the row id, or `null` when one already
|
||||
* existed — which is not an error and is the ordinary answer under a tick that
|
||||
* overran into the next one.
|
||||
*/
|
||||
const materialise = async (run) => {
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO event_runs
|
||||
(definition_id, version_id, scope, scheduled_for, timezone, concurrency_key,
|
||||
params, rehearsal, started_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
run.definition_id,
|
||||
run.version_id,
|
||||
run.scope || '',
|
||||
run.scheduled_for,
|
||||
run.timezone || 'UTC',
|
||||
run.concurrency_key,
|
||||
run.params === null || run.params === undefined ? null : JSON.stringify(run.params),
|
||||
run.rehearsal ? 1 : 0,
|
||||
run.started_by,
|
||||
],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1 ? result.insertId : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every run whose OCCUPIED INTERVAL overlaps a window — the calendar's real half.
|
||||
*
|
||||
* Ascending, unlike the admin run list: a calendar is read forwards. The join
|
||||
* reaches the series so a month can be filtered to one arc without a second
|
||||
* round trip, and `d.timezone` is NOT what comes back — `r.timezone` is, because
|
||||
* a run records the zone it was COMPUTED in and a definition's zone can be
|
||||
* edited afterwards.
|
||||
*
|
||||
* **A run OVERLAPS the window; it does not merely START in it.** This asked
|
||||
* `scheduled_for >= from` alone until the Phase 16 acceptance walk, and a run is
|
||||
* not an instant — it is an interval, and a multi-phase event's whole point is
|
||||
* that the interval is long. A run that began before `from` and has not ended is
|
||||
* happening DURING the window and belongs in it. With the instant test, the
|
||||
* public calendar answered `entries: []` while that same event's own page said
|
||||
* `live: true`, so the site disagreed with itself about whether something was on
|
||||
* — and `EVENTS.md` §I promises this route serves "upcoming, **live** and
|
||||
* recent". The admin calendar had the same hole for the same reason: a run that
|
||||
* started last Sunday and is still going was missing from "this week".
|
||||
*
|
||||
* A finished run needs no clause: it is `recent` only if its instant is in the
|
||||
* window, which is what the window's own `from` decides (see
|
||||
* `eventPublic.model.calendar`, which backdates its default `from` so that
|
||||
* "recent" has somewhere to live).
|
||||
*/
|
||||
const LIVE_STATUSES = ['starting', 'running', 'paused', 'ending']
|
||||
|
||||
const listInWindow = async ({
|
||||
from,
|
||||
to,
|
||||
status = null,
|
||||
scope = null,
|
||||
seriesId = null,
|
||||
limit = 500,
|
||||
publicOnly = false,
|
||||
} = {}) => {
|
||||
const where = [
|
||||
`((r.scheduled_for >= ? AND r.scheduled_for < ?)
|
||||
OR (r.scheduled_for < ? AND r.status IN (${LIVE_STATUSES.map(() => '?').join(',')})))`,
|
||||
]
|
||||
const args = [from, to, to, ...LIVE_STATUSES]
|
||||
if (status) {
|
||||
where.push('r.status = ?')
|
||||
args.push(status)
|
||||
}
|
||||
// The public calendar's two exclusions, in SQL rather than in the model that
|
||||
// maps the rows. A rehearsal "is excluded from the public calendar and from
|
||||
// participation history" by §D's own column comment, and an unlisted
|
||||
// definition is one an operator chose not to announce. Both belong in the
|
||||
// query because a filter applied after the read is a filter somebody can
|
||||
// forget in the next caller.
|
||||
if (publicOnly) {
|
||||
where.push('r.rehearsal = 0', 'd.listed = 1', "d.state <> 'archived'")
|
||||
}
|
||||
if (scope !== null && scope !== undefined) {
|
||||
where.push('r.scope = ?')
|
||||
args.push(scope)
|
||||
}
|
||||
if (seriesId) {
|
||||
where.push('d.series_id = ?')
|
||||
args.push(seriesId)
|
||||
}
|
||||
const n = Math.min(Math.max(Number(limit) || 500, 1), 1000)
|
||||
const rows = await query(
|
||||
`SELECT r.*, d.title AS definition_title, d.slug AS definition_slug,
|
||||
d.series_id AS series_id, se.name AS series_name, se.slug AS series_slug,
|
||||
v.version AS version_number,
|
||||
(SELECT COUNT(*) FROM event_run_steps s
|
||||
WHERE s.run_id = r.id AND s.status = 'running' AND s.claim_expires_at IS NULL) AS waiting_steps
|
||||
FROM event_runs r
|
||||
JOIN event_definitions d ON d.id = r.definition_id
|
||||
JOIN event_versions v ON v.id = r.version_id
|
||||
LEFT JOIN event_series se ON se.id = d.series_id
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY r.scheduled_for, r.id
|
||||
LIMIT ${n}`,
|
||||
args,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Point every not-yet-started occurrence of a definition at a new version.
|
||||
*
|
||||
* Publishing calls this, and the guard is the whole statement: `status =
|
||||
* 'scheduled'` and `started_at IS NULL`. A run that has begun keeps the version
|
||||
* it pinned, for ever, because that pin is what makes it explicable afterwards
|
||||
* -- and a run that has NOT begun has nothing to explain yet.
|
||||
*
|
||||
* **Why re-pinning is the right answer and doing nothing is not** (org lead,
|
||||
* 2026-09-02): occurrences are materialised a fortnight ahead, so on the day an
|
||||
* editor fixes a typo there are already fourteen days of rows carrying the old
|
||||
* spec. Left alone, the fix reaches none of them, and the operator's only
|
||||
* recourse -- cancelling each one -- is worse: a cancelled row still holds its
|
||||
* slot in `uq_evrun_occurrence`, so the occurrence does not come back on the new
|
||||
* version, it disappears.
|
||||
*
|
||||
* Answers how many were moved, so publish can say so rather than leaving it to
|
||||
* be noticed.
|
||||
*/
|
||||
const repinScheduled = async (definitionId, versionId) => {
|
||||
const result = await query(
|
||||
`UPDATE event_runs
|
||||
SET version_id = ?
|
||||
WHERE definition_id = ?
|
||||
AND status = 'scheduled'
|
||||
AND started_at IS NULL
|
||||
AND version_id <> ?`,
|
||||
[versionId, definitionId, versionId],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
/** The scheduled, not-yet-started occurrences a re-pin would move. */
|
||||
const listScheduledFor = async (definitionId) =>
|
||||
(
|
||||
await query(
|
||||
`SELECT id, version_id, scheduled_for FROM event_runs
|
||||
WHERE definition_id = ? AND status = 'scheduled' AND started_at IS NULL
|
||||
ORDER BY scheduled_for`,
|
||||
[definitionId],
|
||||
)
|
||||
).map(hydrate)
|
||||
|
||||
/** The occurrence the unique key names, whether or not this call created it. */
|
||||
const findOccurrence = async (definitionId, scope, scheduledFor) => {
|
||||
const [row] = await query(
|
||||
`${SELECT_LIST} WHERE r.definition_id = ? AND r.scope = ? AND r.scheduled_for = ?`,
|
||||
[definitionId, scope || '', scheduledFor],
|
||||
)
|
||||
return hydrate(row)
|
||||
}
|
||||
|
||||
/** Is anything of this definition not yet terminal? The archive pre-check. */
|
||||
const countActiveForDefinition = async (definitionId) => {
|
||||
const [row] = await query(
|
||||
`SELECT COUNT(*) AS n FROM event_runs
|
||||
WHERE definition_id = ?
|
||||
AND status IN ('scheduled','starting','running','paused','ending')`,
|
||||
[definitionId],
|
||||
)
|
||||
return Number(row?.n || 0)
|
||||
}
|
||||
|
||||
// ── Phase 2: the claim, the transitions and the reclaim ────────────────────
|
||||
//
|
||||
// Everything below is the runner's, and none of it existed in Phase 1 for a
|
||||
// stated reason: a half-written claim is worse than no claim, because it reads
|
||||
// as protection. It is written here now, in full.
|
||||
//
|
||||
// **The division of labour with the unique index has not changed.** The index one
|
||||
// section up is what makes "one run per occurrence per scope" TRUE; the CAS below
|
||||
// decides only WHO advances an occurrence that already exists. Neither substitutes
|
||||
// for the other, and this deployment being single-instance (§N4) changes the test
|
||||
// rather than the design — the same two protections are what keep a tick that
|
||||
// overran into the next one from advancing a run twice.
|
||||
|
||||
/**
|
||||
* Runs the runner should look at this tick: due, and not yet terminal.
|
||||
*
|
||||
* It selects rather than claims — `claimStart` and `claimTick` below are one row
|
||||
* at a time — so two sweepers see the same candidates and then disagree,
|
||||
* harmlessly, about which of them owns each. `idx_evrun_due (status,
|
||||
* scheduled_for)` is this query.
|
||||
*
|
||||
* `paused` is absent from the status list on purpose. A paused run is waiting on
|
||||
* a human and must not be advanced by a tick; the only thing that moves it is
|
||||
* Phase 3's resume control.
|
||||
*/
|
||||
const findDue = async (now, limit = 50) => {
|
||||
const n = Math.min(Math.max(Number(limit) || 50, 1), 500)
|
||||
return (
|
||||
await query(
|
||||
`SELECT * FROM event_runs
|
||||
WHERE status IN ('scheduled','starting','running','ending')
|
||||
AND scheduled_for <= ?
|
||||
ORDER BY scheduled_for, id
|
||||
LIMIT ${n}`,
|
||||
[now],
|
||||
)
|
||||
).map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Take ownership of a run that has not started: the CAS `scheduled -> starting`.
|
||||
*
|
||||
* Verbatim the outbox claim the org lead settled over `SELECT ... FOR UPDATE
|
||||
* SKIP LOCKED` — the instance the server reports `affectedRows = 1` to owns the
|
||||
* row, every other sweeper gets 0 and moves on. No transaction to hold open and
|
||||
* no MariaDB version floor.
|
||||
*/
|
||||
async function claimStart(id, owner, leaseUntil) {
|
||||
const result = await query(
|
||||
`UPDATE event_runs
|
||||
SET status = 'starting', claimed_by = ?, claim_expires_at = ?,
|
||||
started_at = COALESCE(started_at, NOW())
|
||||
WHERE id = ? AND status = 'scheduled'`,
|
||||
[owner, leaseUntil, id],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a lease on a run already in flight, so one tick works on it at a time.
|
||||
*
|
||||
* Unlike `claimStart` this does not change `status` — the run is already
|
||||
* `starting`, `running` or `ending`, and what is being claimed is the right to
|
||||
* advance it.
|
||||
*
|
||||
* **A live lease is not re-enterable, not even by the process that took it**, and
|
||||
* that is the whole point rather than an oversight. `setInterval` fires the next
|
||||
* tick whether or not the last one has returned, so an owner-matches escape
|
||||
* clause here would let one process advance one run twice at once — which is
|
||||
* precisely the overrun the plan says the CAS is meant to protect against. A run
|
||||
* this process still holds is a run this process is still working on; the tick
|
||||
* skips it, and `releaseClaim` below is what ends that in the ordinary case.
|
||||
*/
|
||||
async function claimTick(id, owner, leaseUntil, now) {
|
||||
const result = await query(
|
||||
`UPDATE event_runs
|
||||
SET claimed_by = ?, claim_expires_at = ?
|
||||
WHERE id = ?
|
||||
AND status IN ('starting','running','ending')
|
||||
AND (claim_expires_at IS NULL OR claim_expires_at < ?)`,
|
||||
[owner, leaseUntil, id, now],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a still-in-flight run back, so the next tick can pick it up at once.
|
||||
*
|
||||
* A run left parked on a GM cue, or waiting out a `core.wait`, is not finished
|
||||
* and must not carry a lease: without this the run would be unadvanceable until
|
||||
* the lease expired, which would turn every wait into `max(wait, leaseMs)`.
|
||||
* Scoped to `claimed_by = ?` so a process can only release its own claim.
|
||||
*/
|
||||
async function releaseClaim(id, owner) {
|
||||
const result = await query(
|
||||
'UPDATE event_runs SET claimed_by = NULL, claim_expires_at = NULL WHERE id = ? AND claimed_by = ?',
|
||||
[id, owner],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* A guarded status transition: `from -> to`, and only from `from`.
|
||||
*
|
||||
* Every move the runner makes goes through here rather than through a bare
|
||||
* UPDATE, so "did this transition actually happen" is answerable at each call
|
||||
* site. A `false` is not an error — it is another worker, or this run having been
|
||||
* cancelled from the admin surface between the read and the write, which is a
|
||||
* race Phase 3's live controls make ordinary.
|
||||
*/
|
||||
async function transition(id, from, to, { phase, error, clearClaim = false } = {}) {
|
||||
const sets = ['status = ?']
|
||||
const args = [to]
|
||||
if (phase !== undefined) {
|
||||
sets.push('current_phase = ?')
|
||||
args.push(phase)
|
||||
}
|
||||
if (error !== undefined) {
|
||||
sets.push('last_error = ?')
|
||||
args.push(error === null ? null : String(error).slice(0, 500))
|
||||
}
|
||||
if (TERMINAL.includes(to)) sets.push('ended_at = COALESCE(ended_at, NOW())')
|
||||
if (clearClaim || TERMINAL.includes(to)) sets.push('claimed_by = NULL', 'claim_expires_at = NULL')
|
||||
|
||||
const froms = Array.isArray(from) ? from : [from]
|
||||
const result = await query(
|
||||
`UPDATE event_runs SET ${sets.join(', ')}
|
||||
WHERE id = ? AND status IN (${froms.map(() => '?').join(',')})`,
|
||||
[...args, id, ...froms],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Just this run's status, for a caller that must not act on a stale read.
|
||||
*
|
||||
* The runner drains a bounded batch of steps from one run inside a single tick,
|
||||
* and Phase 3 put a pause and a cancel button in a human's hand — so between two
|
||||
* steps of that batch the run may have stopped. A loop that only re-checked at
|
||||
* the top of the tick would answer a pause by dispatching another two dozen
|
||||
* steps, which is not a pause. One column, by primary key.
|
||||
*/
|
||||
const statusOf = async (id) => {
|
||||
const [row] = await query('SELECT status FROM event_runs WHERE id = ?', [id])
|
||||
return row?.status || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Set health without touching status (§E).
|
||||
*
|
||||
* The two columns are separate because a run can be genuinely running and
|
||||
* degraded at once — announcements landing, world writes parked — and one column
|
||||
* cannot say both. Guarded on the current value so a tick that re-observes the
|
||||
* same degradation does not restamp `updated_at`.
|
||||
*/
|
||||
async function setHealth(id, health) {
|
||||
// **Escalation only, and this is the guard rather than a convention.** Health
|
||||
// has always been a high-water mark here — `degraded` is never cleared,
|
||||
// because a run whose announcement landed on the second attempt DID have
|
||||
// trouble and that stays true — and Phase 5 gave the column a second writer
|
||||
// for `stalled`. Without a rank, a step that retried after a stall would
|
||||
// quietly demote `stalled` to `degraded` and a run that waited ninety minutes
|
||||
// on a boss that never came would end its life claiming it merely wobbled.
|
||||
const rank = HEALTH_RANK[health]
|
||||
if (!rank) return false
|
||||
const result = await query(
|
||||
`UPDATE event_runs SET health = ?
|
||||
WHERE id = ? AND FIELD(health, ${HEALTH_SQL_ORDER}) < ?`,
|
||||
[health, id, rank],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Set `cleanup_status`, optionally guarded on where it is now (Phase 8).
|
||||
*
|
||||
* Four values and three writers, which is why the guard is a parameter rather
|
||||
* than baked in. The ledger stamps `pending` the first time a run records
|
||||
* anything, and it must do so only over `not_required` — a run already marked
|
||||
* `complete` must not be walked back to `pending` by a late resource, and a
|
||||
* `incomplete` one must not be silently tidied. The cleanup sweep sets `complete`
|
||||
* or `incomplete` from what it found, unguarded, because the sweep IS the
|
||||
* authority on that. A human's cleanup route re-opens `pending` deliberately, and
|
||||
* says so in the log with the actor.
|
||||
*
|
||||
* **`pending` on a run that is still running is not a bug and reads correctly**:
|
||||
* there is something to clean up and it has not happened yet. The alternative -
|
||||
* a fifth value meaning "there will be something later" - is a state nothing
|
||||
* would ever branch on.
|
||||
*/
|
||||
async function setCleanupStatus(id, to, from = null) {
|
||||
const guard = from === null ? '' : ` AND cleanup_status IN (${from.map(() => '?').join(',')})`
|
||||
const result = await query(
|
||||
`UPDATE event_runs SET cleanup_status = ? WHERE id = ?${guard}`,
|
||||
from === null ? [to, id] : [to, id, ...from],
|
||||
)
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp this run's results table as published (EVENTS.md §J, Phase 10).
|
||||
*
|
||||
* **Unguarded, and re-stampable.** `core.results.publish` is an ordinary step
|
||||
* that an author may place more than once — before an announcement and again
|
||||
* after a late correction — and each publication is a real one whose moment is
|
||||
* worth recording. Guarding it on `IS NULL` would make the second silently do
|
||||
* nothing while the ranking beside it did move, which is the worst of both.
|
||||
*/
|
||||
async function markResultsPublished(id, at = new Date()) {
|
||||
const result = await query('UPDATE event_runs SET results_published_at = ? WHERE id = ?', [at, id])
|
||||
return Number(result?.affectedRows || 0) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs whose start instant passed more than their own grace window ago (§E, §L).
|
||||
*
|
||||
* The window is per definition, so the comparison is against `grace_seconds` on
|
||||
* the joined row rather than against a constant here: an event whose announcement
|
||||
* gives a fifteen-minute window and one that must start on the second are the
|
||||
* same query with different data.
|
||||
*
|
||||
* Only `scheduled` runs qualify. A run that reached `starting` has begun, and
|
||||
* "began and then stalled" is a different fact from "never began" — conflating
|
||||
* them would let `missed` describe a run that had already announced itself.
|
||||
*/
|
||||
const findMissed = async (now, limit = 100) => {
|
||||
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
|
||||
return (
|
||||
await query(
|
||||
`SELECT r.* FROM event_runs r
|
||||
JOIN event_definitions d ON d.id = r.definition_id
|
||||
WHERE r.status = 'scheduled'
|
||||
AND r.scheduled_for + INTERVAL d.grace_seconds SECOND < ?
|
||||
ORDER BY r.scheduled_for
|
||||
LIMIT ${n}`,
|
||||
[now],
|
||||
)
|
||||
).map(hydrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Is another run holding this concurrency key?
|
||||
*
|
||||
* The org lead's answer for a held key (2026-09-02) is to leave the run
|
||||
* `scheduled` and let the grace window decide, so this is a READ rather than a
|
||||
* claim: the caller holds off, logs which run holds the key, and tries again next
|
||||
* tick. `idx_evrun_concurrency (concurrency_key, status)` is this query, and a
|
||||
* NULL key is skipped by that index — which is right, because a definition with
|
||||
* no key never contends.
|
||||
*/
|
||||
const concurrencyHolder = async (key, exceptRunId) => {
|
||||
if (!key) return null
|
||||
const [row] = await query(
|
||||
`SELECT id, status, definition_id FROM event_runs
|
||||
WHERE concurrency_key = ?
|
||||
AND id <> ?
|
||||
AND status IN ('starting','running','paused','ending')
|
||||
ORDER BY id LIMIT 1`,
|
||||
[key, exceptRunId],
|
||||
)
|
||||
return row || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover runs whose claim outlived the process that took it.
|
||||
*
|
||||
* **It does not change status and it touches no counter.** All it releases is the
|
||||
* lease; the run stays exactly where it was and the next tick picks it up through
|
||||
* `findDue`. This is Engagement Phase 14's lesson applied one table over: a sweep
|
||||
* that returned a stale row to its start state made the attempt ceiling
|
||||
* unreachable, so the row cycled forever, never terminal, and therefore never
|
||||
* eligible for any retention sweep.
|
||||
*/
|
||||
const reclaimStale = async (now) => {
|
||||
const result = await query(
|
||||
`UPDATE event_runs SET claimed_by = NULL, claim_expires_at = NULL
|
||||
WHERE status IN ('starting','running','ending')
|
||||
AND claim_expires_at IS NOT NULL
|
||||
AND claim_expires_at < ?`,
|
||||
[now],
|
||||
)
|
||||
return Number(result?.affectedRows || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* One definition's public occurrences, newest first (Phase 14a).
|
||||
*
|
||||
* Rehearsals are excluded here rather than by the caller, for `listInWindow`'s
|
||||
* reason. The definition's own `listed`/`state` are NOT re-checked: the only
|
||||
* caller has already resolved the definition through `getPublicBySlug`, and a
|
||||
* second copy of that rule is a second thing to keep in step with the first.
|
||||
*
|
||||
* `scheduled` runs come back too — an upcoming occurrence is exactly what a
|
||||
* visitor came to the page for — and the caller splits past from future on the
|
||||
* instant rather than on the status, because a `missed` run is in the past
|
||||
* whatever its status says.
|
||||
*/
|
||||
const listPublicForDefinition = async (definitionId, limit = 50) => {
|
||||
const n = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||
const rows = await query(
|
||||
`SELECT r.*, v.version AS version_number
|
||||
FROM event_runs r
|
||||
JOIN event_versions v ON v.id = r.version_id
|
||||
WHERE r.definition_id = ? AND r.rehearsal = 0
|
||||
ORDER BY r.scheduled_for DESC, r.id DESC
|
||||
LIMIT ${n}`,
|
||||
[definitionId],
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** Terminal runs that ended before `before` — what the log retention sweep walks. */
|
||||
const terminalBefore = async (before, limit = 500) => {
|
||||
const n = Math.min(Math.max(Number(limit) || 500, 1), 5000)
|
||||
return (
|
||||
await query(
|
||||
`SELECT id FROM event_runs
|
||||
WHERE status IN (${TERMINAL.map(() => '?').join(',')})
|
||||
AND COALESCE(ended_at, updated_at) < ?
|
||||
ORDER BY id LIMIT ${n}`,
|
||||
[...TERMINAL, before],
|
||||
)
|
||||
).map((r) => Number(r.id))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
list,
|
||||
getById,
|
||||
materialise,
|
||||
listInWindow,
|
||||
listPublicForDefinition,
|
||||
repinScheduled,
|
||||
listScheduledFor,
|
||||
findOccurrence,
|
||||
countActiveForDefinition,
|
||||
findDue,
|
||||
findMissed,
|
||||
claimStart,
|
||||
claimTick,
|
||||
releaseClaim,
|
||||
statusOf,
|
||||
transition,
|
||||
setHealth,
|
||||
setCleanupStatus,
|
||||
markResultsPublished,
|
||||
concurrencyHolder,
|
||||
reclaimStale,
|
||||
terminalBefore,
|
||||
TERMINAL,
|
||||
}
|
||||
288
server/src/model/events/eventRuns.model.js
Normal file
288
server/src/model/events/eventRuns.model.js
Normal file
@@ -0,0 +1,288 @@
|
||||
// ── Event runs — creating an occurrence ────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §E. Phase 1 creates a run row and materialises the first phase's
|
||||
// steps. **It does not start anything**: there is no runner until Phase 2, so a
|
||||
// row created here sits at `scheduled` indefinitely. That is the correct
|
||||
// behaviour for this phase and it has to be VISIBLE as such rather than looking
|
||||
// broken, which is why `create()` answers with the row and the admin surface
|
||||
// renders the status verbatim.
|
||||
//
|
||||
// Two properties this file owns, both of which are the reason it exists before
|
||||
// the runner rather than with it:
|
||||
//
|
||||
// - **Materialisation is `INSERT IGNORE` against the occurrence key.** Two
|
||||
// attempts at one occurrence produce one row and an honest answer, not a
|
||||
// duplicate-key error a caller has to interpret. The unique index — not a
|
||||
// claim — is what makes "one run per occurrence per scope" true (§E).
|
||||
// - **The idempotency key is minted with the step row and never varies by
|
||||
// attempt.** It is a function of identity, so it can only be stable if it is
|
||||
// stamped where the identity is created.
|
||||
|
||||
const db = require('./eventRuns.db')
|
||||
const stepsDb = require('./eventRunSteps.db')
|
||||
const logDb = require('./eventRunLog.db')
|
||||
const gatesDb = require('./eventPhaseGates.db')
|
||||
const gates = require('../../events/gates')
|
||||
const definitionsDb = require('./eventDefinitions.db')
|
||||
const versionsDb = require('./eventVersions.db')
|
||||
const settingsDb = require('./eventActionSettings.db')
|
||||
const budgetDb = require('./eventRunBudget.db')
|
||||
const resourcesDb = require('./eventRunResources.db')
|
||||
const participantsDb = require('./eventRunParticipants.db')
|
||||
const authorize = require('../../events/authorize')
|
||||
|
||||
const MAX_SCOPE = 190
|
||||
|
||||
/**
|
||||
* Render a definition's `concurrency_key` template against a run's params.
|
||||
*
|
||||
* `invasion:{region}` with `{ region: 'Yew' }` becomes `invasion:Yew` (§E). A
|
||||
* placeholder with no matching param is left standing rather than replaced with
|
||||
* an empty string: `invasion:` would collide with every other unrendered key on
|
||||
* the deployment, which is the opposite of what a concurrency key is for, and a
|
||||
* literal `invasion:{region}` in the column is a visible mistake.
|
||||
*/
|
||||
function renderConcurrencyKey(template, params) {
|
||||
if (!template) return null
|
||||
return String(template)
|
||||
.replace(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, (whole, name) => {
|
||||
const value = params && params[name]
|
||||
return value === undefined || value === null || value === '' ? whole : String(value)
|
||||
})
|
||||
.slice(0, MAX_SCOPE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one occurrence of a definition and materialise its first phase.
|
||||
*
|
||||
* `scheduledFor` defaults to now — "start now" is an occurrence whose instant is
|
||||
* the present, not a separate concept, which is what keeps the runner's one
|
||||
* materialise/advance path honest now that Phase 4 has put recurrence on top.
|
||||
*
|
||||
* **Phase 4's expansion calls this, rather than a second insert path beside it.**
|
||||
* That is deliberate: every check here — the definition is still `ready`, the
|
||||
* version still has phases, the concurrency key renders, the first phase's steps
|
||||
* are materialised with their idempotency keys — is one a scheduled occurrence
|
||||
* needs at least as much as a hand-started one, because there is nobody watching
|
||||
* when it happens. The `INSERT IGNORE` answering `created: false` is what makes
|
||||
* it safe to call on every tick for every occurrence inside the horizon.
|
||||
*/
|
||||
async function create(
|
||||
definitionId,
|
||||
{ scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual' } = {},
|
||||
userId,
|
||||
) {
|
||||
const definition = await definitionsDb.getById(definitionId)
|
||||
if (!definition) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (definition.state !== 'ready') {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
errors: [`a ${definition.state} definition has no published version to run`],
|
||||
}
|
||||
}
|
||||
if (!definition.current_version_id) {
|
||||
return { ok: false, status: 409, errors: ['this definition has no published version'] }
|
||||
}
|
||||
|
||||
const version = await versionsDb.getById(definition.current_version_id)
|
||||
if (!version?.spec?.phases?.length) {
|
||||
return { ok: false, status: 409, errors: ['the published version has no phases'] }
|
||||
}
|
||||
|
||||
// §K's last bound, enforced for SCHEDULED starts only (org lead, 2026-09-03):
|
||||
// *"a scheduled definition that has never been verified is the case worth
|
||||
// refusing to start"*. An admin pressing start is watching, and that human IS
|
||||
// the review the gate exists to require — so the gate falls on the path where
|
||||
// nobody is. A version is immutable, so a dry run that passed against it stays
|
||||
// true, which is what makes the pass a property of the version rather than
|
||||
// something re-earned every occurrence.
|
||||
if (source === 'schedule' && !version.verified_at) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
code: 'unverified',
|
||||
errors: ['this version has not been verified, so it will not start unattended'],
|
||||
}
|
||||
}
|
||||
|
||||
const scopeValue = String(scope || '').slice(0, MAX_SCOPE)
|
||||
const when = scheduledFor ? new Date(scheduledFor) : new Date()
|
||||
if (Number.isNaN(when.getTime())) {
|
||||
return { ok: false, status: 400, errors: ['scheduledFor is not a date'] }
|
||||
}
|
||||
|
||||
const runId = await db.materialise({
|
||||
definition_id: definitionId,
|
||||
version_id: version.id,
|
||||
scope: scopeValue,
|
||||
// Stored as UTC. The definition's zone is what an occurrence is COMPUTED in
|
||||
// (Phase 4); what is stored is the instant.
|
||||
scheduled_for: when,
|
||||
timezone: definition.timezone,
|
||||
concurrency_key: renderConcurrencyKey(definition.concurrency_key, params),
|
||||
params,
|
||||
rehearsal,
|
||||
started_by: userId,
|
||||
})
|
||||
|
||||
if (runId === null) {
|
||||
// The occurrence already existed. Not an error — it is what the unique index
|
||||
// is for — so the existing row is the answer.
|
||||
const existing = await db.findOccurrence(definitionId, scopeValue, when)
|
||||
return { ok: true, created: false, run: existing }
|
||||
}
|
||||
|
||||
await logDb.write({
|
||||
runId,
|
||||
kind: 'run.created',
|
||||
detail: {
|
||||
definitionId,
|
||||
versionId: version.id,
|
||||
version: version.version,
|
||||
scope: scopeValue,
|
||||
rehearsal: Boolean(rehearsal),
|
||||
// 'manual' is an admin pressing start; 'schedule' is the runner expanding
|
||||
// a recurrence (Phase 4). Both produce the same row, and the log is the
|
||||
// only place the difference is recorded — `started_by` is NULL for both a
|
||||
// scheduled occurrence and one started by a since-deleted account.
|
||||
source,
|
||||
by: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// The run's budget, seeded from EVERY phase's steps rather than from the first
|
||||
// one's (Phase 6). The version is pinned and immutable, so all of its steps are
|
||||
// knowable now — and a budget that grew as phases were entered would let a
|
||||
// phase-1 step spend a cap that a phase-3 step was going to need, which is the
|
||||
// opposite of a per-run bound. The caps are copied here, so an admin moving a
|
||||
// switch tomorrow does not change what a run already in flight is allowed.
|
||||
const allSteps = version.spec.phases.flatMap((p) =>
|
||||
(p.steps || []).map((s) => ({ actionId: s.actionId, params: s.params || {} })),
|
||||
)
|
||||
const settingsByAction = await settingsDb.byIds(allSteps.map((s) => s.actionId))
|
||||
const budget = authorize.effectiveCaps(allSteps, settingsByAction)
|
||||
if (Object.keys(budget).length) {
|
||||
await budgetDb.seed(runId, budget)
|
||||
await logDb.write({
|
||||
runId,
|
||||
kind: 'run.budget',
|
||||
detail: {
|
||||
dimensions: Object.entries(budget).map(([dimension, d]) => ({
|
||||
dimension,
|
||||
cap: d.cap,
|
||||
from: d.from,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The first phase's steps, materialised at creation rather than at start.
|
||||
// Phase 2 materialises each LATER phase as the run enters it; doing the first
|
||||
// one here is what makes a Phase 1 run row inspectable — an operator can see
|
||||
// the steps that would run, with their params and their idempotency keys,
|
||||
// before there is anything to run them.
|
||||
const first = version.spec.phases[0]
|
||||
await stepsDb.materialisePhase(runId, first.key, first.steps || [])
|
||||
await logDb.write({ runId, kind: 'phase.entered', phase: first.key, detail: { steps: (first.steps || []).length } })
|
||||
|
||||
return { ok: true, created: true, run: await db.getById(runId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A run, its steps, its status counts and its phase gates — the run console.
|
||||
*
|
||||
* The gates arrive already DESCRIBED rather than as rows (Phase 5): the panel's
|
||||
* whole value is that it reads the way the condition builder reads, and those
|
||||
* words come from `engagement/conditions.js`'s own operator labels. Rendering
|
||||
* them in the browser would be a second implementation of a grammar the server
|
||||
* owns, and the first clause the two spelled differently would meet its operator
|
||||
* at two in the morning.
|
||||
*
|
||||
* Every gate the run has opened is returned, not only the current phase's. A
|
||||
* completed phase's gate answers "how long did phase 2 actually wait, and what
|
||||
* released it" — which is the same question as the live one, asked afterwards.
|
||||
*/
|
||||
async function detail(runId) {
|
||||
const run = await db.getById(runId)
|
||||
if (!run) return null
|
||||
const [steps, counts, gateRows, budget, resources, attendees] = await Promise.all([
|
||||
stepsDb.listForRun(runId),
|
||||
stepsDb.statusCounts(runId),
|
||||
gatesDb.listForRun(runId),
|
||||
budgetDb.forRun(runId),
|
||||
resourcesDb.forRun(runId),
|
||||
participantsDb.listForRun(runId),
|
||||
])
|
||||
const now = new Date()
|
||||
return {
|
||||
run,
|
||||
steps,
|
||||
counts,
|
||||
gates: gateRows.map((g) => gates.describe(g, now)),
|
||||
// The meter, as rows rather than as a sentence: a cap is two numbers and a
|
||||
// name, and unlike a gate it needs no grammar rendered to be read. `cap:
|
||||
// null` is uncapped and the client says so — a dimension the run counts but
|
||||
// nothing bounds.
|
||||
budget: budget.map((b) => ({
|
||||
dimension: b.dimension,
|
||||
consumed: b.consumed,
|
||||
cap: b.cap,
|
||||
from: b.effective_from,
|
||||
})),
|
||||
// What this run changed in the world, and what became of it (Phase 8). The
|
||||
// WHOLE ledger, reverted rows included, because "what did last night's
|
||||
// invasion actually spawn, and did all of it come back" is the question this
|
||||
// panel exists for and a list of only the failures cannot answer the second
|
||||
// half of it.
|
||||
//
|
||||
// **The `@step` placeholders are filtered out.** They are core's own
|
||||
// bookkeeping — a row that says "a dispatch is in flight and may have made
|
||||
// something" — and the console's list is of things in the world. One left in
|
||||
// would read as a resource nobody can name, which is exactly the confusion it
|
||||
// exists to prevent internally.
|
||||
resources: resources
|
||||
.filter((r) => r.kind !== resourcesDb.STEP_KIND)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
stepId: r.step_id,
|
||||
module: r.owner_module,
|
||||
kind: r.kind,
|
||||
ref: r.ref,
|
||||
payload: r.payload,
|
||||
leaseUntil: r.lease_until,
|
||||
status: r.status,
|
||||
revertAttempts: r.revert_attempts,
|
||||
lastError: r.last_error,
|
||||
memberKey: r.member_key,
|
||||
createdAt: r.created_at,
|
||||
})),
|
||||
// How many rows are still unresolved, counted over the WHOLE ledger rather
|
||||
// than over the list above — a placeholder left standing by a lost
|
||||
// acknowledgement is exactly the case `cleanup_status` must not call clean.
|
||||
unresolvedResources: resources.filter((r) => resourcesDb.UNRESOLVED.includes(r.status)).length,
|
||||
// Who took part, best first (Phase 10). Returned on every run rather than
|
||||
// only on a published one: the console's question is "what did this event
|
||||
// record", and a run whose module has collected but whose author never
|
||||
// placed a publish step is exactly the case an operator needs to see. What
|
||||
// `results_published_at` on the run row then says is whether anyone OUTSIDE
|
||||
// this screen may read it — which is Phase 14's question, not this one's.
|
||||
//
|
||||
// **`rank` is `rank_at`, renamed at the boundary and not in the column.**
|
||||
// `rank` is a reserved word in MariaDB 10.2+ (it is the window function),
|
||||
// so the column carries the suffix and the API carries the name a client
|
||||
// wants. The alternative — backticking the column at every use — is one
|
||||
// forgotten pair of backticks away from a syntax error in a query nobody
|
||||
// runs until a run completes at four in the morning.
|
||||
participants: attendees.map((p) => ({
|
||||
memberKey: p.member_key,
|
||||
userId: p.user_id,
|
||||
score: p.score,
|
||||
rank: p.rank_at,
|
||||
joinedAt: p.joined_at,
|
||||
meta: p.meta,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { create, detail, renderConcurrencyKey }
|
||||
78
server/src/model/events/eventSeries.db.js
Normal file
78
server/src/model/events/eventSeries.db.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// ── event_series — SQL only ────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D. The arc a definition may belong to. Phase 1 needed only the
|
||||
// reads — `event_definitions.series_id` is a foreign key and the definition save
|
||||
// path has to check it resolves — and Phase 4 adds the writes, because the
|
||||
// calendar is what makes an arc visible and a form cannot offer a value nobody
|
||||
// can create.
|
||||
//
|
||||
// `ordering` here places a SERIES among the others on the calendar. A
|
||||
// definition's place WITHIN its arc is `event_definitions.series_order`, which
|
||||
// is the column an editor drags; the two are deliberately different columns on
|
||||
// different tables and the schema comment says so.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// `definition_count` is a correlated subquery rather than a join with a GROUP BY:
|
||||
// the list is a handful of rows, and the delete path needs the same number to
|
||||
// tell an operator what they are about to detach.
|
||||
const SELECT_LIST = `
|
||||
SELECT s.*,
|
||||
(SELECT COUNT(*) FROM event_definitions d WHERE d.series_id = s.id) AS definition_count
|
||||
FROM event_series s
|
||||
`
|
||||
|
||||
const list = async () => query(`${SELECT_LIST} ORDER BY s.ordering, s.name, s.id`)
|
||||
|
||||
const getById = async (id) => {
|
||||
const [row] = await query(`${SELECT_LIST} WHERE s.id = ?`, [id])
|
||||
return row || null
|
||||
}
|
||||
|
||||
const getBySlug = async (slug) => {
|
||||
const [row] = await query(`${SELECT_LIST} WHERE s.slug = ?`, [slug])
|
||||
return row || null
|
||||
}
|
||||
|
||||
const exists = async (id) => {
|
||||
const [row] = await query('SELECT id FROM event_series WHERE id = ?', [id])
|
||||
return Boolean(row)
|
||||
}
|
||||
|
||||
/** Does any OTHER series hold this slug? The uniqueness pre-check. */
|
||||
const slugTaken = async (slug, exceptId = null) => {
|
||||
const rows = exceptId
|
||||
? await query('SELECT id FROM event_series WHERE slug = ? AND id <> ?', [slug, exceptId])
|
||||
: await query('SELECT id FROM event_series WHERE slug = ?', [slug])
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
const insert = async (s) => {
|
||||
const result = await query(
|
||||
`INSERT INTO event_series (name, slug, description, ordering, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[s.name, s.slug, s.description, s.ordering, s.created_by],
|
||||
)
|
||||
return Number(result.insertId)
|
||||
}
|
||||
|
||||
const update = (id, s) =>
|
||||
query(
|
||||
`UPDATE event_series SET name = ?, slug = ?, description = ?, ordering = ? WHERE id = ?`,
|
||||
[s.name, s.slug, s.description, s.ordering, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* A hard delete, and the one place in this feature that is one.
|
||||
*
|
||||
* A series is a label rather than authored content: nothing pins one, no run
|
||||
* references one, and `event_definitions.series_id` is `ON DELETE SET NULL`, so
|
||||
* removing a series detaches its definitions and destroys nothing. That is why
|
||||
* it is not archived the way a definition is — an archived label would be a
|
||||
* state every calendar query has to remember for no benefit. The model answers
|
||||
* with how many definitions were detached, so the operator learns what happened
|
||||
* rather than discovering it on the calendar.
|
||||
*/
|
||||
const remove = (id) => query('DELETE FROM event_series WHERE id = ?', [id])
|
||||
|
||||
module.exports = { list, getById, getBySlug, exists, slugTaken, insert, update, remove }
|
||||
93
server/src/model/events/eventSeries.model.js
Normal file
93
server/src/model/events/eventSeries.model.js
Normal file
@@ -0,0 +1,93 @@
|
||||
// ── Event series — the arc ─────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D and §I. "Royal Spy Mission → Risky Partner → Message From the
|
||||
// Void" is continuity that exists nowhere in the tooling this feature replaces
|
||||
// (§ "What the real calendar shows, and what it is missing": *no series or
|
||||
// recurrence field*). One small table buys it, and this is the policy half.
|
||||
//
|
||||
// **Why the writes are `admin, editor` and not `admin`.** A series is authoring,
|
||||
// and it is the same act as writing the definition that goes in it — §N2's
|
||||
// narrow gate is about *committing the deployment to a run* (publish, start),
|
||||
// which naming an arc does not do. An editor who can write the events but not
|
||||
// the arc they belong to would have to ask an admin to type a title.
|
||||
//
|
||||
// **A slug is derived once and then frozen**, exactly as a definition's is: the
|
||||
// public arc page lives at `/events/series/:slug` (Phase 14), and a slug that
|
||||
// moved would break every link to it. Renaming the series is free.
|
||||
|
||||
const db = require('./eventSeries.db')
|
||||
const { slugify, uniqueSlug } = require('../teams/teamSlug')
|
||||
|
||||
const MAX_NAME = 160
|
||||
const MAX_DESCRIPTION = 2000
|
||||
|
||||
const trimOrNull = (v, max) => {
|
||||
if (v === undefined || v === null) return null
|
||||
const s = String(v).trim()
|
||||
return s === '' ? null : s.slice(0, max)
|
||||
}
|
||||
|
||||
const list = () => db.list()
|
||||
|
||||
const getById = (id) => db.getById(id)
|
||||
|
||||
async function validate(input, { existing = null } = {}) {
|
||||
const errors = []
|
||||
const body = input && typeof input === 'object' ? input : {}
|
||||
|
||||
const name = trimOrNull(body.name, MAX_NAME)
|
||||
if (!name) errors.push('name is required')
|
||||
|
||||
const description = trimOrNull(body.description, MAX_DESCRIPTION)
|
||||
|
||||
const orderingRaw = body.ordering === undefined ? (existing?.ordering ?? 0) : body.ordering
|
||||
const ordering = Number(orderingRaw)
|
||||
if (!Number.isInteger(ordering) || ordering < 0 || ordering > 9999) {
|
||||
errors.push('ordering must be an integer 0..9999')
|
||||
}
|
||||
|
||||
if (errors.length) return { ok: false, errors }
|
||||
return { ok: true, series: { name, description, ordering } }
|
||||
}
|
||||
|
||||
async function create(input, userId) {
|
||||
const checked = await validate(input)
|
||||
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
|
||||
|
||||
// The taken set is read here rather than inside `uniqueSlug` because that
|
||||
// helper is pure — the same shape the team and definition paths use.
|
||||
const taken = (await db.list()).map((s) => s.slug)
|
||||
const slug = uniqueSlug(checked.series.name, taken, { fallback: 'series' })
|
||||
|
||||
const id = await db.insert({ ...checked.series, slug, created_by: userId || null })
|
||||
return { ok: true, status: 201, series: await db.getById(id) }
|
||||
}
|
||||
|
||||
async function update(id, input, userId) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such series'] }
|
||||
|
||||
const checked = await validate(input, { existing })
|
||||
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
|
||||
|
||||
// The slug is the existing one, deliberately: renaming a series must not move
|
||||
// the address its arc page lives at.
|
||||
await db.update(id, { ...checked.series, slug: existing.slug })
|
||||
return { ok: true, status: 200, series: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a series, detaching whatever belonged to it.
|
||||
*
|
||||
* The count comes back so the caller can say *"3 events were detached"* rather
|
||||
* than leaving an operator to notice on the calendar. `series_id` is
|
||||
* `ON DELETE SET NULL`, so nothing is destroyed and re-attaching is a dropdown.
|
||||
*/
|
||||
async function remove(id) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such series'] }
|
||||
await db.remove(id)
|
||||
return { ok: true, status: 200, detached: Number(existing.definition_count || 0) }
|
||||
}
|
||||
|
||||
module.exports = { list, getById, validate, create, update, remove, slugify, MAX_NAME }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user