feat(events): the public calendar, event pages and participation history (Phase 14a) #196
@@ -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'
|
||||
@@ -74,6 +77,7 @@ 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 (
|
||||
@@ -107,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 />} />
|
||||
@@ -247,6 +262,15 @@ export default function App() {
|
||||
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
|
||||
@@ -290,6 +314,11 @@ export default function App() {
|
||||
<Route path="/player" element={<PlayerIndex />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
{/* 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
|
||||
|
||||
@@ -256,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
|
||||
@@ -708,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())}`)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -29,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' },
|
||||
|
||||
@@ -232,6 +232,14 @@ export function formFromDefinition(event) {
|
||||
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 || '',
|
||||
@@ -351,6 +359,8 @@ export function payloadFromForm(form, { triggersById = new Map() } = {}) {
|
||||
concurrencyKey: form.concurrencyKey || null,
|
||||
graceSeconds: Number(form.graceSeconds),
|
||||
timezone: form.timezone,
|
||||
listed: Boolean(form.listed),
|
||||
listed: Boolean(form.listed),
|
||||
spec: { schedule: scheduleFromForm(form), phases },
|
||||
},
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
@@ -19,3 +19,17 @@ export const inboxPath = (user) => (isStaff(user) ? '/admin/notifications' : '/a
|
||||
/** 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'
|
||||
|
||||
@@ -142,6 +142,12 @@ export const NAV = [
|
||||
// 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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -229,6 +235,7 @@ const TITLES = {
|
||||
'/admin/events': 'Events',
|
||||
'/admin/events/calendar': 'Event calendar',
|
||||
'/admin/events/actions': 'Event actions',
|
||||
'/admin/events/mine': 'My participation',
|
||||
'/admin/events/new': 'New event',
|
||||
}
|
||||
|
||||
|
||||
@@ -1164,11 +1164,24 @@ export default function EventEditor() {
|
||||
<span className="field-label">Storyline</span>
|
||||
<textarea className="input" rows={4} value={form.body} onChange={(e) => set({ body: e.target.value })} />
|
||||
</label>
|
||||
{/* Announcement, not permission. Publishing is what makes an event
|
||||
RUNNABLE, so without this switch a surprise invasion would have to be
|
||||
advertised a fortnight in advance in order to be allowed to happen. */}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 14 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(form.listed)}
|
||||
onChange={(e) => set({ listed: e.target.checked })}
|
||||
/>
|
||||
<span className="field-label" style={{ margin: 0 }}>Show on the public calendar</span>
|
||||
</label>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '10px 0 0' }}>
|
||||
The grace window is how late this event may still start: past it an occurrence becomes
|
||||
<em> missed</em> rather than beginning hours after it was announced. Two runs sharing a
|
||||
concurrency key never overlap — <code>{'{placeholders}'}</code> are filled from the run’s own
|
||||
params.
|
||||
params. An event that is <em>not</em> shown publicly still schedules, still runs and is
|
||||
still on this calendar — it is simply not announced, and its lifecycle mails carry no
|
||||
link because there is no page to link to.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -39,6 +39,11 @@ const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-1
|
||||
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;
|
||||
@@ -51,6 +56,7 @@ const IconBellGear = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h11" />
|
||||
// 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', end: true, icon: IconBell },
|
||||
{ to: '/account/notifications/settings', label: 'Notification settings', icon: IconBellGear },
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
@@ -252,6 +252,29 @@ test('the form round-trips a definition without losing a step', () => {
|
||||
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
|
||||
|
||||
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')
|
||||
})
|
||||
@@ -2598,6 +2598,25 @@ CREATE TABLE IF NOT EXISTS event_run_resources (
|
||||
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) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
@@ -54,6 +54,13 @@
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -66,7 +73,7 @@
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
@@ -88,6 +95,13 @@
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -100,7 +114,7 @@
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
@@ -136,6 +150,13 @@
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -148,7 +169,7 @@
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
@@ -163,6 +184,13 @@
|
||||
"required": true,
|
||||
"example": "The Yew Invasion",
|
||||
"description": "The event title."
|
||||
},
|
||||
{
|
||||
"name": "eventUrl",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"example": "/site/events/the-yew-invasion?run=3692",
|
||||
"description": "The public page for this occurrence."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -223,7 +251,7 @@
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
@@ -273,6 +301,13 @@
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -285,7 +320,7 @@
|
||||
"subjectKey": "runId",
|
||||
"audience": "subscribers",
|
||||
"ceiling": "authenticated",
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"variables": [
|
||||
{
|
||||
"name": "runId",
|
||||
@@ -335,6 +370,13 @@
|
||||
"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."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2267,6 +2267,15 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/events/history",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams",
|
||||
@@ -2444,6 +2453,30 @@
|
||||
"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",
|
||||
|
||||
@@ -933,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"
|
||||
@@ -1005,6 +1009,18 @@
|
||||
"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"
|
||||
|
||||
@@ -185,15 +185,22 @@ const TRIGGERS = [
|
||||
// runs of the same definition the ids differ, so a weekly event is not
|
||||
// throttled by last week's.
|
||||
//
|
||||
// **None of the six public ones declares a `url` variable, deliberately.**
|
||||
// There is no public event page until Phase 14 — `App.jsx` mounts nothing
|
||||
// under `/site/events` — and `news.post` has already paid for this mistake
|
||||
// once: its `postUrl` example named `/news/<slug>`, a path that does not
|
||||
// exist, and the template editor previewed a link that was dead in every mail
|
||||
// it sent. A variable added in Phase 14 alongside the page it points at is a
|
||||
// version bump; a variable shipped now is a 404 in an operator's first
|
||||
// announcement. `run.failed` is the exception because its destination exists
|
||||
// today: `/admin/events/runs/:runId` is a real route and an admin can read it.
|
||||
// **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',
|
||||
@@ -202,7 +209,7 @@ const TRIGGERS = [
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
@@ -225,6 +232,12 @@ const TRIGGERS = [
|
||||
{ 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.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -235,7 +248,7 @@ const TRIGGERS = [
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
@@ -258,6 +271,12 @@ const TRIGGERS = [
|
||||
{ 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.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -268,7 +287,7 @@ const TRIGGERS = [
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
@@ -282,6 +301,12 @@ const TRIGGERS = [
|
||||
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.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -292,12 +317,18 @@ const TRIGGERS = [
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
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.' },
|
||||
// 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.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -308,7 +339,7 @@ const TRIGGERS = [
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
@@ -324,6 +355,12 @@ const TRIGGERS = [
|
||||
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.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -334,7 +371,7 @@ const TRIGGERS = [
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
version: 2,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
@@ -345,6 +382,12 @@ const TRIGGERS = [
|
||||
// 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.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -369,8 +412,9 @@ const TRIGGERS = [
|
||||
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 one url variable in this file's Phase 10 block, and the reason is
|
||||
// that this route exists TODAY. See the note above the six.
|
||||
// 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.' },
|
||||
],
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
|
||||
@@ -332,23 +332,29 @@ const SEEDS = [
|
||||
// single absent variable renders nothing, in both parts). A standalone event
|
||||
// has no `seriesName` and its line disappears rather than reading "Part of .".
|
||||
//
|
||||
// **No `{{actionUrl}}` and no button, deliberately.** There is no public event
|
||||
// page until Phase 14, so the six public triggers declare no `url` variable at
|
||||
// all (see `coreTriggers.js`), and a button here would render as an inert grey
|
||||
// label in every mail — worse than none, because it advertises a link the
|
||||
// reader cannot follow. Phase 14 adds the variable and the block together.
|
||||
// **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,
|
||||
seedVersion: 1,
|
||||
// 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: [
|
||||
@@ -356,6 +362,7 @@ const SEEDS = [
|
||||
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:'),
|
||||
],
|
||||
|
||||
@@ -92,10 +92,32 @@ async function baseFor(run) {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -11,6 +11,9 @@ 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
|
||||
@@ -60,8 +63,8 @@ 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, spec, created_by, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
concurrency_key, grace_seconds, timezone, listed, spec, created_by, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
d.title,
|
||||
d.slug,
|
||||
@@ -74,6 +77,7 @@ const insert = async (d) => {
|
||||
d.concurrency_key,
|
||||
d.grace_seconds,
|
||||
d.timezone,
|
||||
d.listed ? 1 : 0,
|
||||
JSON.stringify(d.spec),
|
||||
d.created_by,
|
||||
d.created_by,
|
||||
@@ -87,7 +91,7 @@ const update = (id, d) =>
|
||||
`UPDATE event_definitions
|
||||
SET title = ?, slug = ?, summary = ?, body = ?, image_url = ?, series_id = ?,
|
||||
series_order = ?, concurrency_key = ?, grace_seconds = ?, timezone = ?,
|
||||
spec = ?, updated_by = ?
|
||||
listed = ?, spec = ?, updated_by = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
d.title,
|
||||
@@ -100,6 +104,7 @@ const update = (id, d) =>
|
||||
d.concurrency_key,
|
||||
d.grace_seconds,
|
||||
d.timezone,
|
||||
d.listed ? 1 : 0,
|
||||
JSON.stringify(d.spec),
|
||||
d.updated_by,
|
||||
id,
|
||||
@@ -140,20 +145,51 @@ const markReady = (id, versionId, userId) =>
|
||||
* 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 () => {
|
||||
const findSchedulable = async ({ listedOnly = false } = {}) => {
|
||||
const rows = await query(
|
||||
`SELECT d.id, d.title, d.slug, d.timezone, d.grace_seconds, d.concurrency_key,
|
||||
d.current_version_id, d.series_id, v.spec AS version_spec,
|
||||
`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'
|
||||
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.
|
||||
@@ -166,6 +202,8 @@ module.exports = {
|
||||
list,
|
||||
getById,
|
||||
getBySlug,
|
||||
getPublicBySlug,
|
||||
listPublicBySeries,
|
||||
slugTaken,
|
||||
findSchedulable,
|
||||
insert,
|
||||
|
||||
@@ -124,6 +124,15 @@ async function validate(input, { existing = null } = {}) {
|
||||
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) : []
|
||||
@@ -159,6 +168,7 @@ async function validate(input, { existing = null } = {}) {
|
||||
concurrency_key: concurrencyKey,
|
||||
grace_seconds: graceSeconds,
|
||||
timezone,
|
||||
listed,
|
||||
spec: checked.spec,
|
||||
},
|
||||
}
|
||||
|
||||
409
server/src/model/events/eventPublic.model.js
Normal file
409
server/src/model/events/eventPublic.model.js
Normal file
@@ -0,0 +1,409 @@
|
||||
// ── 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: now 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.
|
||||
const DEFAULT_WINDOW_DAYS = 31
|
||||
|
||||
// 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() } = {}) {
|
||||
const start = from ? new Date(from) : new Date(now)
|
||||
const end = to ? new Date(to) : new Date(start.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 {
|
||||
occurrences = recurrence.occurrencesBetween(schedule, definition.timezone || 'UTC', start, 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,
|
||||
PAST_RUNS,
|
||||
}
|
||||
@@ -75,6 +75,52 @@ async function listForRun(runId, limit = 500) {
|
||||
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])
|
||||
@@ -116,4 +162,4 @@ async function rankRun(runId) {
|
||||
return Number(result.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { record, listForRun, countForRun, rankRun }
|
||||
module.exports = { record, listForRun, listForUser, countForRun, rankRun }
|
||||
|
||||
@@ -116,13 +116,30 @@ const materialise = async (run) => {
|
||||
* a run records the zone it was COMPUTED in and a definition's zone can be
|
||||
* edited afterwards.
|
||||
*/
|
||||
const listInWindow = async ({ from, to, status = null, scope = null, seriesId = null, limit = 500 } = {}) => {
|
||||
const listInWindow = async ({
|
||||
from,
|
||||
to,
|
||||
status = null,
|
||||
scope = null,
|
||||
seriesId = null,
|
||||
limit = 500,
|
||||
publicOnly = false,
|
||||
} = {}) => {
|
||||
const where = ['r.scheduled_for >= ?', 'r.scheduled_for < ?']
|
||||
const args = [from, to]
|
||||
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)
|
||||
@@ -497,6 +514,33 @@ const reclaimStale = async (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)
|
||||
@@ -516,6 +560,7 @@ module.exports = {
|
||||
getById,
|
||||
materialise,
|
||||
listInWindow,
|
||||
listPublicForDefinition,
|
||||
repinScheduled,
|
||||
listScheduledFor,
|
||||
findOccurrence,
|
||||
|
||||
@@ -29,6 +29,11 @@ const getById = async (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)
|
||||
@@ -70,4 +75,4 @@ const update = (id, s) =>
|
||||
*/
|
||||
const remove = (id) => query('DELETE FROM event_series WHERE id = ?', [id])
|
||||
|
||||
module.exports = { list, getById, exists, slugTaken, insert, update, remove }
|
||||
module.exports = { list, getById, getBySlug, exists, slugTaken, insert, update, remove }
|
||||
|
||||
@@ -69,6 +69,10 @@ const shapeDefinition = (d) => ({
|
||||
concurrencyKey: d.concurrency_key,
|
||||
graceSeconds: d.grace_seconds,
|
||||
timezone: d.timezone,
|
||||
// Whether the public calendar announces it (Phase 14a). Not whether it may
|
||||
// run — an unlisted event schedules and runs exactly as a listed one does,
|
||||
// and is on THIS screen either way.
|
||||
listed: Boolean(d.listed),
|
||||
spec: d.spec,
|
||||
createdAt: d.created_at,
|
||||
updatedAt: d.updated_at,
|
||||
|
||||
29
server/src/router/v1/player/events.controller.js
Normal file
29
server/src/router/v1/player/events.controller.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// Player · Events — the one handler behind /player/events/history (Phase 14a).
|
||||
//
|
||||
// Self-scoped on `req.user.id` and on nothing the caller sent. The model does
|
||||
// the same joins the public surface does — rehearsals and unlisted events are
|
||||
// absent — so a participant cannot learn from their own history that an
|
||||
// unannounced event exists.
|
||||
|
||||
const events = require('../../../model/events/eventPublic.model')
|
||||
const log = require('../../../utils/logger')('player:events')
|
||||
|
||||
async function getHistory(req, res) {
|
||||
try {
|
||||
// A non-integer cursor is dropped rather than bound. `Number('abc')` is NaN,
|
||||
// and NaN reaching a placeholder is a driver-level failure — a 500 for what
|
||||
// is a malformed query string, and the honest answer to one is the first
|
||||
// page.
|
||||
const cursor = Number(req.query.before)
|
||||
const result = await events.history(req.user.id, {
|
||||
limit: req.query.limit ? Number(req.query.limit) : undefined,
|
||||
before: Number.isInteger(cursor) && cursor > 0 ? cursor : null,
|
||||
})
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.error('participation history failed', { message: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getHistory }
|
||||
39
server/src/router/v1/player/events.router.js
Normal file
39
server/src/router/v1/player/events.router.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Player · Events — this account's participation history (EVENTS.md § API
|
||||
// surface, Phase 14a). Mounted at /api/v1/player/events by player/index.js.
|
||||
//
|
||||
// The group gate is `requireAuth` and it is the whole gate: this is role-agnostic
|
||||
// self-service, like the rest of /player. Staff are a superset of players (see
|
||||
// player/index.js), and an admin reading their own attendance is exactly as
|
||||
// ordinary as a player doing it.
|
||||
//
|
||||
// **No backtick in a `#swagger.parameters` annotation.** Unlike `#swagger.summary`
|
||||
// and `#swagger.description`, which are plain strings, a parameters annotation is
|
||||
// parsed as an object literal — a backtick inside its quoted `description` is
|
||||
// rewritten as a quote, and swagger-autogen then DROPS the whole annotation with a
|
||||
// syntax error rather than failing the build.
|
||||
//
|
||||
// **There is no id parameter, deliberately.** The history is `req.user.id`'s and
|
||||
// nothing else's; a route that took a user id would be one middleware mistake
|
||||
// away from publishing who attended what, which is a question about people
|
||||
// rather than about events.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./events.controller')
|
||||
|
||||
const eventsRouter = express.Router()
|
||||
|
||||
eventsRouter.get(
|
||||
'/history',
|
||||
// #swagger.tags = ['Player · Events']
|
||||
// #swagger.summary = 'This account’s event participation'
|
||||
// #swagger.description = 'The events this account took part in, most recent first — the run, when it was, the score a module reported, and the rank once results were published. `rank` is null until then, which is a real state rather than an error. Rehearsals and unlisted events are absent, the same rule the public calendar follows. Keyset paging: pass the last entry’s `id` as `before`.'
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 200 (default 50).' }
|
||||
// #swagger.parameters['before'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Cursor: the id of the last entry on the previous page.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Participation history', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerEventHistory" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not signed in', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.getHistory,
|
||||
)
|
||||
|
||||
module.exports = eventsRouter
|
||||
@@ -27,6 +27,7 @@ const noindex = require('../../../middleware/noindex')
|
||||
const appealsRouter = require('./appeals.router')
|
||||
const teamsRouter = require('./teams.router')
|
||||
const teamForumRouter = require('./teamForum.router')
|
||||
const eventsRouter = require('./events.router')
|
||||
|
||||
const playerRouter = express.Router()
|
||||
|
||||
@@ -39,6 +40,9 @@ const playerRouter = express.Router()
|
||||
playerRouter.use(noindex, requireAuth)
|
||||
|
||||
playerRouter.use('/appeals', appealsRouter)
|
||||
// This account's own event participation (Phase 14a). Self-scoped on
|
||||
// req.user.id, like everything else in this group.
|
||||
playerRouter.use('/events', eventsRouter)
|
||||
playerRouter.use('/teams', teamsRouter)
|
||||
// Same prefix, second router. The forum and the leader-exercised grant flow are a
|
||||
// different capability from "the caller's own Teams", and splitting them keeps
|
||||
|
||||
68
server/src/router/v1/public/events.controller.js
Normal file
68
server/src/router/v1/public/events.controller.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// Public · Events — the anonymous event surface (EVENTS.md § API surface).
|
||||
//
|
||||
// Phase 14a. Three reads and no writes: the calendar, one event, one arc.
|
||||
//
|
||||
// **Every one of them is a thin pass-through to `eventPublic.model`, and that is
|
||||
// deliberate.** The projection — which fields exist at all on a public entry — is
|
||||
// the security boundary, and it belongs in one file rather than in three
|
||||
// controllers that would each have to remember it. What is left here is the
|
||||
// HTTP: parse the query, map the model's `status` onto a response code, and turn
|
||||
// a thrown read into a 500 rather than a stack trace.
|
||||
//
|
||||
// **A 404 here means "no such public event"** and cannot be told from "no such
|
||||
// slug at all". A draft, an archived definition and an unlisted one answer
|
||||
// identically, which is the whole point: an operator who has not announced
|
||||
// something has not announced its existence either.
|
||||
|
||||
const events = require('../../../model/events/eventPublic.model')
|
||||
const log = require('../../../utils/logger')('public:events')
|
||||
|
||||
const fail = (res, err, what) => {
|
||||
log.error(`${what} failed`, { message: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
|
||||
const answer = (res, result) =>
|
||||
result.ok
|
||||
? res.json(result)
|
||||
: res.status(result.status || 400).json({ message: result.errors?.[0] || 'Bad Request', errors: result.errors })
|
||||
|
||||
async function getCalendar(req, res) {
|
||||
try {
|
||||
const seriesId = req.query.seriesId ? Number(req.query.seriesId) : null
|
||||
if (req.query.seriesId && !Number.isInteger(seriesId)) {
|
||||
return res.status(400).json({ message: 'seriesId must be an integer' })
|
||||
}
|
||||
const result = await events.calendar({
|
||||
from: req.query.from || null,
|
||||
to: req.query.to || null,
|
||||
seriesId,
|
||||
})
|
||||
return answer(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'public calendar')
|
||||
}
|
||||
}
|
||||
|
||||
async function getEvent(req, res) {
|
||||
try {
|
||||
// `run` is optional and un-validated beyond being carried through as a
|
||||
// string: the model matches it against this definition's own runs and
|
||||
// ignores anything else, so a garbage value renders the page rather than an
|
||||
// error. See the model's note on why it is not refused.
|
||||
const result = await events.event(req.params.slug, { runId: req.query.run || null })
|
||||
return answer(res, result)
|
||||
} catch (err) {
|
||||
return fail(res, err, 'public event')
|
||||
}
|
||||
}
|
||||
|
||||
async function getSeries(req, res) {
|
||||
try {
|
||||
return answer(res, await events.series(req.params.slug))
|
||||
} catch (err) {
|
||||
return fail(res, err, 'public series')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getCalendar, getEvent, getSeries }
|
||||
60
server/src/router/v1/public/events.router.js
Normal file
60
server/src/router/v1/public/events.router.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Public · Events — mounted at /api/v1/public/events by public/index.js.
|
||||
//
|
||||
// No group gate: this is the anonymous surface, and `siteMode` is applied per
|
||||
// route as it is everywhere else in this tier — during maintenance only an admin
|
||||
// with a valid session sees content.
|
||||
//
|
||||
// Declaration order: '/' is literal and precedes ':slug', and 'series/:slug' is
|
||||
// declared BEFORE ':slug' although it could not be shadowed by it (two segments
|
||||
// against one). It stays above so the relationship is visible to whoever adds
|
||||
// the next route here — and because the one route bug this feature has already
|
||||
// shipped was exactly a static/dynamic ranking surprise, one tier up in React
|
||||
// Router (see App.jsx's note above `events/:id`).
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./events.controller')
|
||||
const siteMode = require('../../../middleware/siteMode')
|
||||
|
||||
const eventsRouter = express.Router()
|
||||
|
||||
eventsRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Public · Events']
|
||||
// #swagger.summary = 'The public event calendar'
|
||||
// #swagger.description = 'Upcoming, live and recent events in a window, ascending by instant. An entry is one of two things and says which: a `run` is a materialised occurrence, and a `projected` entry is arithmetic past the materialisation horizon — a forecast, with nothing committed to it, which a client should draw as such. Instants are UTC and each entry carries the EVENT\'s own timezone, because a shard-local 8pm means the shard\'s evening to everyone reading it; the reader\'s own zone places the entry in a month grid. Rehearsals and unlisted events are absent. Defaults to now through 31 days out; the window may span at most 92 days.'
|
||||
// #swagger.parameters['from'] = { in: 'query', required: false, schema: { type: 'string', format: 'date-time' }, description: 'Window start (ISO). Defaults to now.' }
|
||||
// #swagger.parameters['to'] = { in: 'query', required: false, schema: { type: 'string', format: 'date-time' }, description: 'Window end (ISO). Defaults to 31 days after the start.' }
|
||||
// #swagger.parameters['seriesId'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Restrict to one arc.' }
|
||||
/* #swagger.responses[200] = { description: 'The calendar', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEventCalendar" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Bad window', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
ctrl.getCalendar,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/series/:slug',
|
||||
// #swagger.tags = ['Public · Events']
|
||||
// #swagger.summary = 'One arc'
|
||||
// #swagger.description = 'A series and the listed events in it, in the order an editor arranged them. A series with no listed events answers 404 rather than an empty page: the arc is a label on its definitions, so a page for an empty one would publish the fact that an operator has named something they have not announced.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The series slug.' }
|
||||
/* #swagger.responses[200] = { description: 'The arc', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEventSeries" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such arc, or nothing in it is listed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
ctrl.getSeries,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/:slug',
|
||||
// #swagger.tags = ['Public · Events']
|
||||
// #swagger.summary = 'One event'
|
||||
// #swagger.description = 'The storyline, the arc it belongs to, what is live, what is next, what happened recently, and a results table once one has been published. A draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. The plan behind the event — phases, steps, actions and their params — is never published; a live run carries the LABEL of the phase it is in and nothing more.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The event slug.' }
|
||||
// #swagger.parameters['run'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Which occurrence the results are about — what an announcement\'s link carries, so a mail about last Friday does not open next Friday\'s. A run that does not belong to this event is ignored rather than refused.' }
|
||||
/* #swagger.responses[200] = { description: 'The event', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEvent" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such public event', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
ctrl.getEvent,
|
||||
)
|
||||
|
||||
module.exports = eventsRouter
|
||||
@@ -22,6 +22,7 @@ const wikiRouter = require('./wiki.router')
|
||||
const pagesRouter = require('./pages.router')
|
||||
const modulesRouter = require('./modules.router')
|
||||
const teamsRouter = require('./teams.router')
|
||||
const eventsRouter = require('./events.router')
|
||||
const engagementRouter = require('./engagement.router')
|
||||
const siteRouter = require('./site.router')
|
||||
|
||||
@@ -42,6 +43,10 @@ publicRouter.use('/modules', modulesRouter)
|
||||
// is what populates it (TEAMS.md §10.3). Site-mode gated per route, like the
|
||||
// content above it.
|
||||
publicRouter.use('/teams', teamsRouter)
|
||||
// Events. A core prefix like /teams: the calendar, the event page and the arc
|
||||
// are core's surface even when every step an event dispatches belongs to a
|
||||
// module. Site-mode gated per route, like the content above it.
|
||||
publicRouter.use('/events', eventsRouter)
|
||||
// The unauthenticated half of the engagement system: today exactly the
|
||||
// unsubscribe pair. Its own prefix rather than a Teams sub-path, because what a
|
||||
// token names is a channel and a scope and a scope is not always a Team
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -991,6 +991,13 @@ const doc = {
|
||||
service: { type: 'string', example: 'runic-gateway', description: 'Stable backend identifier for first-run recognition.' },
|
||||
api: { type: 'string', example: 'v1', description: 'API contract version (matches the /api/v1 mount).' },
|
||||
server: { type: 'string', example: '1.0.0', description: 'Server package version (informational).' },
|
||||
capabilities: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['events'],
|
||||
description:
|
||||
'Opaque strings naming what CORE serves beyond the baseline every backend has — the same idea as a module’s `capabilities` on /public/modules, and a separate list because core is not a module. A backend released before a capability existed omits the key entirely, which is how a client tells an older site from one that simply has nothing to show. Treat an unknown string as absent.',
|
||||
},
|
||||
},
|
||||
},
|
||||
PublicModules: {
|
||||
@@ -1234,6 +1241,190 @@ const doc = {
|
||||
},
|
||||
},
|
||||
},
|
||||
PublicEventEntry: {
|
||||
type: 'object',
|
||||
description:
|
||||
'One calendar entry. `kind` says which of two things it is: a `run` is a materialised occurrence, a `projected` entry is arithmetic past the materialisation horizon — a forecast with nothing committed to it, which a client should draw as such.',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['run', 'projected'], example: 'run' },
|
||||
title: { type: 'string', example: 'The Yew Invasion' },
|
||||
slug: { type: 'string', example: 'the-yew-invasion' },
|
||||
seriesName: { type: 'string', nullable: true, example: 'The Yew Campaign' },
|
||||
seriesSlug: { type: 'string', nullable: true, example: 'the-yew-campaign' },
|
||||
scheduledFor: { type: 'string', format: 'date-time', description: 'The instant, UTC.' },
|
||||
timezone: {
|
||||
type: 'string',
|
||||
example: 'America/New_York',
|
||||
description:
|
||||
'The EVENT’s zone, not the reader’s. A shard-local 8pm means the shard’s evening to everyone reading it, so the time is rendered in this zone while the reader’s own zone places the entry in a month grid.',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['scheduled', 'live', 'completed', 'cancelled'],
|
||||
description:
|
||||
'The public status word. `failed` and `missed` are both published as `cancelled`: to a visitor they are one event — it was on the calendar and it did not happen — while the difference between them is about the deployment rather than about the event.',
|
||||
},
|
||||
live: { type: 'boolean', example: false },
|
||||
adjusted: {
|
||||
type: 'boolean',
|
||||
description: 'Projections only: this instant is not the wall clock the schedule names, because a DST change moved it.',
|
||||
},
|
||||
shiftMinutes: { type: 'integer', description: 'Projections only: by how much.' },
|
||||
},
|
||||
},
|
||||
PublicEventCalendar: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean', example: true },
|
||||
window: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string', format: 'date-time' },
|
||||
to: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
entries: { type: 'array', items: { $ref: '#/components/schemas/PublicEventEntry' } },
|
||||
truncated: { type: 'boolean', description: 'The window held more entries than the cap.', example: false },
|
||||
},
|
||||
},
|
||||
PublicEventOccurrence: {
|
||||
type: 'object',
|
||||
description:
|
||||
'One occurrence of an event, as a public page lists it. Health, cleanup state, claims and errors are never published — a degraded run is a fact about the deployment’s plumbing, while "the event is running" is the fact about the event.',
|
||||
properties: {
|
||||
runId: { type: 'integer', example: 3692 },
|
||||
scheduledFor: { type: 'string', format: 'date-time' },
|
||||
timezone: { type: 'string', example: 'America/New_York' },
|
||||
startedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
endedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
status: { type: 'string', enum: ['scheduled', 'live', 'completed', 'cancelled'] },
|
||||
live: { type: 'boolean' },
|
||||
scope: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Module-opaque — the shard or server this occurrence ran on, on a deployment that uses them.',
|
||||
},
|
||||
phase: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
example: 'The assault',
|
||||
description:
|
||||
'The LABEL of the phase a live run is in, resolved from the version the run pinned. Null unless it is live. The plan behind the event — phases, steps, actions and their params — is never published.',
|
||||
},
|
||||
resultsPublishedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
},
|
||||
},
|
||||
PublicEventParticipant: {
|
||||
type: 'object',
|
||||
description:
|
||||
'A results row. The member key is the game’s own identifier for a character and is module-opaque, so core cannot say what publishing one would disclose — it is not published. A display name is whatever the module chose to put in `meta`.',
|
||||
properties: {
|
||||
name: { type: 'string', nullable: true, example: 'Aldric' },
|
||||
score: { type: 'number', example: 1420 },
|
||||
rank: { type: 'integer', nullable: true, example: 3, description: 'Null until results were published.' },
|
||||
meta: { type: 'object', nullable: true, description: 'Module-opaque: whatever it wanted shown beside a name.' },
|
||||
},
|
||||
},
|
||||
PublicEvent: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean', example: true },
|
||||
event: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', example: 'The Yew Invasion' },
|
||||
slug: { type: 'string', example: 'the-yew-invasion' },
|
||||
summary: { type: 'string', nullable: true },
|
||||
body: { type: 'string', nullable: true, description: 'The storyline. Sanitized HTML, the treatment a wiki page gets.' },
|
||||
imageUrl: { type: 'string', nullable: true },
|
||||
timezone: { type: 'string', example: 'America/New_York' },
|
||||
series: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: { name: { type: 'string' }, slug: { type: 'string' } },
|
||||
},
|
||||
live: { type: 'boolean' },
|
||||
current: { $ref: '#/components/schemas/PublicEventOccurrence' },
|
||||
next: { $ref: '#/components/schemas/PublicEventOccurrence' },
|
||||
upcoming: { type: 'array', items: { $ref: '#/components/schemas/PublicEventOccurrence' } },
|
||||
past: { type: 'array', items: { $ref: '#/components/schemas/PublicEventOccurrence' } },
|
||||
results: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description:
|
||||
'Present only once an occurrence has published results. Which occurrence follows `?run=`, then a live one, then the most recent that published any.',
|
||||
properties: {
|
||||
runId: { type: 'integer' },
|
||||
scheduledFor: { type: 'string', format: 'date-time' },
|
||||
publishedAt: { type: 'string', format: 'date-time' },
|
||||
participants: { type: 'array', items: { $ref: '#/components/schemas/PublicEventParticipant' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
PublicEventSeries: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean', example: true },
|
||||
series: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', example: 'The Yew Campaign' },
|
||||
slug: { type: 'string', example: 'the-yew-campaign' },
|
||||
description: { type: 'string', nullable: true },
|
||||
events: {
|
||||
type: 'array',
|
||||
description: 'The listed events in the arc, in the order an editor arranged them.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
slug: { type: 'string' },
|
||||
summary: { type: 'string', nullable: true },
|
||||
imageUrl: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
PlayerEventHistory: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean', example: true },
|
||||
entries: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', description: 'The participation row. Pass the last one as `before` to page.' },
|
||||
runId: { type: 'integer' },
|
||||
title: { type: 'string' },
|
||||
slug: { type: 'string' },
|
||||
seriesName: { type: 'string', nullable: true },
|
||||
seriesSlug: { type: 'string', nullable: true },
|
||||
scheduledFor: { type: 'string', format: 'date-time' },
|
||||
startedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
endedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
timezone: { type: 'string' },
|
||||
status: { type: 'string', enum: ['scheduled', 'live', 'completed', 'cancelled'] },
|
||||
joinedAt: { type: 'string', format: 'date-time' },
|
||||
score: { type: 'number' },
|
||||
rank: {
|
||||
type: 'integer',
|
||||
nullable: true,
|
||||
description: 'Null until results were published — a real state rather than an error.',
|
||||
},
|
||||
resultsPublishedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
meta: { type: 'object', nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
PublicTeamMember: {
|
||||
type: 'object',
|
||||
description:
|
||||
|
||||
@@ -45,9 +45,16 @@ after(() => db.close())
|
||||
const DEFINITION = {
|
||||
id: 3,
|
||||
title: 'The Yew Invasion',
|
||||
slug: 'the-yew-invasion',
|
||||
summary: 'Orcish warbands are massing north of Yew.',
|
||||
series_name: 'The Yew Campaign',
|
||||
timezone: 'America/New_York',
|
||||
// Both are load-bearing for `eventUrl` (Phase 14a): an event with no public
|
||||
// page gets no link. They were absent from this fixture, which meant the url
|
||||
// was undefined in every test here and the new code was exercised by none of
|
||||
// them.
|
||||
state: 'ready',
|
||||
listed: true,
|
||||
}
|
||||
|
||||
const RUN = {
|
||||
@@ -201,6 +208,12 @@ test('run.cancelled carries the operator\'s reason, and omits it when none was g
|
||||
assert.equal(only().envelope.data.reason, undefined)
|
||||
})
|
||||
|
||||
// `run.failed` alone gets no public page, and the DECLARATION is what enforces
|
||||
// that rather than anything here: `baseFor` assembles `eventUrl` for every
|
||||
// trigger and the seam drops the keys a trigger does not declare. The test above
|
||||
// that asserts run.failed's url variables are exactly `['runUrl']` is therefore
|
||||
// the one that proves it — an assertion on this envelope would be reading the
|
||||
// wrong layer, because the filtering has not happened yet at this point.
|
||||
test('run.failed links the run console — the one destination that exists today', async () => {
|
||||
await announce.runFailed(RUN, 'sidecar responded 503')
|
||||
const { data } = only().envelope
|
||||
@@ -209,6 +222,29 @@ test('run.failed links the run console — the one destination that exists today
|
||||
assert.equal(data.runUrl, '/admin/events/runs/3692')
|
||||
})
|
||||
|
||||
test('every public emit carries the page for THIS occurrence', async () => {
|
||||
await announce.runStarted(RUN)
|
||||
// The slug is the definition's and the run is in the query string. Without
|
||||
// `?run=` a mail about last Friday's occurrence would open next Friday's.
|
||||
assert.equal(only().envelope.data.eventUrl, '/site/events/the-yew-invasion?run=3692')
|
||||
})
|
||||
|
||||
test('an UNLISTED event announces with no link rather than a link that 404s', async () => {
|
||||
// `eventUrl` is declared optional exactly so `email.button` can drop itself.
|
||||
// A path here would render as a dead button in every mail — worse than none,
|
||||
// because it advertises a link the reader cannot follow. `news.post` paid for
|
||||
// that once already.
|
||||
definitionsDb.getById = async () => ({ ...DEFINITION, listed: false })
|
||||
await announce.runStarted(RUN)
|
||||
assert.equal(only().envelope.data.eventUrl, undefined)
|
||||
})
|
||||
|
||||
test('a definition that is not yet `ready` has no page either', async () => {
|
||||
definitionsDb.getById = async () => ({ ...DEFINITION, state: 'draft' })
|
||||
await announce.runStarted(RUN)
|
||||
assert.equal(only().envelope.data.eventUrl, undefined)
|
||||
})
|
||||
|
||||
test('run.failed falls back to the run\'s own last error', async () => {
|
||||
await announce.runFailed({ ...RUN, last_error: 'the pinned version has no phases' }, null)
|
||||
assert.equal(only().envelope.data.error, 'the pinned version has no phases')
|
||||
@@ -283,21 +319,40 @@ test('six are ceilinged authenticated; run.failed is admin on both halves', () =
|
||||
}
|
||||
})
|
||||
|
||||
test('no public event trigger declares a url — there is no page for one to point at yet', () => {
|
||||
// `news.post` shipped an example naming `/news/<slug>`, a path that does not
|
||||
// exist, and the template editor previewed a link that was dead in every mail
|
||||
// it sent. Phase 14 adds the variable alongside the page.
|
||||
test('every public event trigger points at the page Phase 14a built, and run.failed at the console', () => {
|
||||
// The inverse of what this asserted from Phase 10 until Phase 14a, and the
|
||||
// inversion is the point: `news.post` shipped an example naming `/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 was withheld until there was a page,
|
||||
// and it arrived with it.
|
||||
for (const t of eventTriggers()) {
|
||||
const urls = t.variables.filter((v) => v.type === 'url')
|
||||
if (t.id === 'event.run.failed') {
|
||||
// No `eventUrl` here, deliberately: an admin reading that the machinery
|
||||
// broke wants the steps and the errors, not the storyline.
|
||||
assert.deepEqual(urls.map((v) => v.name), ['runUrl'])
|
||||
assert.match(urls[0].example, /^\/admin\/events\/runs\//)
|
||||
} else {
|
||||
assert.deepEqual(urls, [], `${t.id} must declare no url until Phase 14`)
|
||||
assert.deepEqual(urls.map((v) => v.name), ['eventUrl'], `${t.id}`)
|
||||
// The example has to carry `?run=`, because that is what makes a link in a
|
||||
// mail about last Friday open last Friday rather than next Friday.
|
||||
assert.match(urls[0].example, /^\/site\/events\/[^?]+\?run=/, `${t.id}`)
|
||||
// Optional, so `email.button` drops itself rather than rendering an inert
|
||||
// grey label when the event is not public and there is no page.
|
||||
assert.equal(urls[0].required, false, `${t.id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('the six public triggers are at version 2 — the url variable is a declaration change', () => {
|
||||
// A variable added to a declaration is a version bump, not a correction: a rule
|
||||
// written against version 1 was written against a payload with no link in it.
|
||||
// `run.failed` gained nothing and stays where it was.
|
||||
for (const t of eventTriggers()) {
|
||||
assert.equal(t.version, t.id === 'event.run.failed' ? 1 : 2, `${t.id}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every declared variable carries an example, which is what the editor previews with', () => {
|
||||
for (const t of eventTriggers()) {
|
||||
for (const v of t.variables) {
|
||||
|
||||
417
server/test/eventPublic.test.js
Normal file
417
server/test/eventPublic.test.js
Normal file
@@ -0,0 +1,417 @@
|
||||
// ── The public event surface (EVENTS_PLAN.md Phase 14a) ────────────────────
|
||||
//
|
||||
// The phase's shipped claim: **a visitor with no account sees the calendar, one
|
||||
// event's page and an arc, and sees nothing an operator did not announce.**
|
||||
//
|
||||
// What is worth testing here is almost entirely the second half. The reads
|
||||
// themselves are joins; the decisions are about what is absent:
|
||||
//
|
||||
// • a rehearsal, an unlisted definition and a draft are absent from every
|
||||
// surface, and absent the same way — a 404 that cannot be told from a slug
|
||||
// that never existed
|
||||
// • the plan behind an event (phases, steps, actions, params) is never
|
||||
// published; a live run carries the LABEL of its phase and nothing else
|
||||
// • `failed` and `missed` are published as `cancelled`, because the difference
|
||||
// between them is about the deployment rather than about the event
|
||||
// • `member_key` never leaves the server, even on a results table
|
||||
// • a participant's own history obeys the same two exclusions as the calendar,
|
||||
// so attending an unannounced event does not disclose that it exists
|
||||
//
|
||||
// Stubbed at the `.db` layer, the shape `eventSchedule.test.js` uses.
|
||||
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const publicModel = require('../src/model/events/eventPublic.model')
|
||||
const definitionsDb = require('../src/model/events/eventDefinitions.db')
|
||||
const runsDb = require('../src/model/events/eventRuns.db')
|
||||
const seriesDb = require('../src/model/events/eventSeries.db')
|
||||
const versionsDb = require('../src/model/events/eventVersions.db')
|
||||
const participantsDb = require('../src/model/events/eventRunParticipants.db')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const NOW = new Date('2026-09-01T12:00:00Z')
|
||||
|
||||
const SPEC = {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{ id: 'muster', label: 'The muster', steps: [{ id: 's1', action: 'core.announce' }] },
|
||||
{ id: 'assault', label: 'The assault', steps: [] },
|
||||
],
|
||||
}
|
||||
|
||||
const originals = {}
|
||||
for (const [name, mod] of [
|
||||
['definitionsDb', definitionsDb],
|
||||
['runsDb', runsDb],
|
||||
['seriesDb', seriesDb],
|
||||
['versionsDb', versionsDb],
|
||||
['participantsDb', participantsDb],
|
||||
]) {
|
||||
originals[name] = { mod, fns: { ...mod } }
|
||||
}
|
||||
const restoreOriginals = () => {
|
||||
for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
|
||||
}
|
||||
|
||||
let store
|
||||
|
||||
const definition = (over = {}) => ({
|
||||
id: 1,
|
||||
title: 'The Yew Invasion',
|
||||
slug: 'the-yew-invasion',
|
||||
summary: 'Orcish warbands are massing north of Yew.',
|
||||
body: '<p>They came at dusk.</p>',
|
||||
image_url: null,
|
||||
state: 'ready',
|
||||
listed: true,
|
||||
timezone: 'America/New_York',
|
||||
series_id: null,
|
||||
series_name: null,
|
||||
series_slug: null,
|
||||
current_version_id: 100,
|
||||
spec: SPEC,
|
||||
...over,
|
||||
})
|
||||
|
||||
const run = (over = {}) => ({
|
||||
id: 3692,
|
||||
definition_id: 1,
|
||||
version_id: 100,
|
||||
scope: '',
|
||||
status: 'completed',
|
||||
// Every one of these is a field the public shapes must NOT carry. They are on
|
||||
// the fixture on purpose: a `{ ...run }` anywhere in the model would publish
|
||||
// them, and the assertions below are what would catch it.
|
||||
health: 'degraded',
|
||||
cleanup_status: 'incomplete',
|
||||
claimed_by: 'worker-3',
|
||||
claim_expires_at: new Date(),
|
||||
last_error: 'sidecar responded 503',
|
||||
current_phase: 'assault',
|
||||
scheduled_for: new Date('2026-08-29T00:00:00Z'),
|
||||
started_at: new Date('2026-08-29T00:00:05Z'),
|
||||
ended_at: new Date('2026-08-29T01:30:00Z'),
|
||||
timezone: 'America/New_York',
|
||||
rehearsal: false,
|
||||
results_published_at: new Date('2026-08-29T02:00:00Z'),
|
||||
...over,
|
||||
})
|
||||
|
||||
function installStubs() {
|
||||
definitionsDb.getPublicBySlug = async (slug) => {
|
||||
const d = store.definitions.find((x) => x.slug === slug)
|
||||
return d && d.state === 'ready' && d.listed ? d : undefined
|
||||
}
|
||||
definitionsDb.listPublicBySeries = async (seriesId) =>
|
||||
store.definitions.filter((d) => d.series_id === seriesId && d.state === 'ready' && d.listed)
|
||||
definitionsDb.findSchedulable = async ({ listedOnly = false } = {}) =>
|
||||
store.definitions
|
||||
.filter((d) => d.state === 'ready' && (!listedOnly || d.listed))
|
||||
.map((d) => ({ ...d, version_spec: d.spec }))
|
||||
|
||||
runsDb.listInWindow = async ({ from, to, publicOnly = false }) =>
|
||||
store.runs.filter((r) => {
|
||||
const at = new Date(r.scheduled_for)
|
||||
if (at < from || at >= to) return false
|
||||
if (!publicOnly) return true
|
||||
const d = store.definitions.find((x) => x.id === r.definition_id)
|
||||
return !r.rehearsal && d && d.listed && d.state !== 'archived'
|
||||
})
|
||||
runsDb.listPublicForDefinition = async (id) =>
|
||||
store.runs
|
||||
.filter((r) => r.definition_id === id && !r.rehearsal)
|
||||
.sort((a, b) => new Date(b.scheduled_for) - new Date(a.scheduled_for))
|
||||
|
||||
seriesDb.getById = async (id) => store.series.find((s) => s.id === id) || null
|
||||
seriesDb.getBySlug = async (slug) => store.series.find((s) => s.slug === slug) || null
|
||||
versionsDb.getById = async (id) => (id === 100 ? { id, spec: SPEC } : null)
|
||||
participantsDb.listForRun = async () => store.participants
|
||||
participantsDb.listForUser = async () => store.history
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const d = definition()
|
||||
store = {
|
||||
definitions: [d],
|
||||
// The joined shape `listInWindow` answers with.
|
||||
runs: [{ ...run(), definition_title: d.title, definition_slug: d.slug }],
|
||||
series: [],
|
||||
participants: [],
|
||||
history: [],
|
||||
}
|
||||
installStubs()
|
||||
})
|
||||
afterEach(restoreOriginals)
|
||||
|
||||
// ── The calendar ───────────────────────────────────────────────────────────
|
||||
|
||||
test('a calendar entry carries no operational field at all', async () => {
|
||||
const result = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
|
||||
assert.equal(result.ok, true)
|
||||
const [entry] = result.entries
|
||||
assert.equal(entry.title, 'The Yew Invasion')
|
||||
// The whole security property of this file, asserted positively: the entry has
|
||||
// exactly these keys and gaining one is a deliberate act.
|
||||
assert.deepEqual(Object.keys(entry).sort(), [
|
||||
'kind', 'live', 'scheduledFor', 'seriesName', 'seriesSlug', 'slug', 'status', 'timezone', 'title',
|
||||
])
|
||||
})
|
||||
|
||||
test('the calendar defaults to a month from now when no window is given', async () => {
|
||||
const result = await publicModel.calendar({ now: NOW })
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(new Date(result.window.from).getTime(), NOW.getTime())
|
||||
const days = (new Date(result.window.to) - new Date(result.window.from)) / 86_400_000
|
||||
assert.equal(days, publicModel.DEFAULT_WINDOW_DAYS)
|
||||
})
|
||||
|
||||
test('a window wider than the cap is refused rather than served slowly', async () => {
|
||||
const result = await publicModel.calendar({ from: '2026-01-01', to: '2026-12-31', now: NOW })
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.status, 400)
|
||||
})
|
||||
|
||||
test('a projection is not emitted for an instant a run already occupies', async () => {
|
||||
// The definition recurs weekly on the Saturday its one run already sits on.
|
||||
store.definitions[0].spec = {
|
||||
...SPEC,
|
||||
schedule: { kind: 'weekly', days: ['saturday'], time: '00:00' },
|
||||
}
|
||||
const result = await publicModel.calendar({ from: '2026-08-28', to: '2026-08-31', now: NOW })
|
||||
const at = result.entries.filter(
|
||||
(e) => new Date(e.scheduledFor).getTime() === new Date('2026-08-29T00:00:00Z').getTime(),
|
||||
)
|
||||
assert.equal(at.length, 1)
|
||||
assert.equal(at[0].kind, 'run')
|
||||
})
|
||||
|
||||
// ── What the public never sees ─────────────────────────────────────────────
|
||||
|
||||
test('an unlisted event is absent from the calendar and 404s on its own page', async () => {
|
||||
store.definitions[0].listed = false
|
||||
const cal = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
|
||||
assert.deepEqual(cal.entries, [])
|
||||
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.ok, false)
|
||||
assert.equal(page.status, 404)
|
||||
})
|
||||
|
||||
test('a draft answers exactly as an unlisted one does — indistinguishable from no such slug', async () => {
|
||||
store.definitions[0].state = 'draft'
|
||||
const draft = await publicModel.event('the-yew-invasion')
|
||||
const missing = await publicModel.event('no-such-event')
|
||||
assert.deepEqual(draft, missing)
|
||||
})
|
||||
|
||||
test('a rehearsal is absent from the calendar and from an event page', async () => {
|
||||
store.runs[0].rehearsal = true
|
||||
const cal = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
|
||||
assert.deepEqual(cal.entries, [])
|
||||
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.ok, true)
|
||||
assert.deepEqual(page.event.past, [])
|
||||
})
|
||||
|
||||
test('an occurrence publishes no health, no cleanup state, no claim and no error', async () => {
|
||||
store.runs[0].status = 'running'
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
const occurrence = page.event.current
|
||||
for (const leaked of ['health', 'cleanupStatus', 'claimedBy', 'claimExpiresAt', 'lastError', 'versionId']) {
|
||||
assert.equal(occurrence[leaked], undefined, `${leaked} must not be published`)
|
||||
}
|
||||
assert.equal(JSON.stringify(page).includes('sidecar responded 503'), false)
|
||||
})
|
||||
|
||||
test('a live run carries its phase LABEL, and never the spec behind it', async () => {
|
||||
store.runs[0].status = 'running'
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.event.current.phase, 'The assault')
|
||||
// The step ids in the fixture spec are the tell: if the spec were published
|
||||
// anywhere in this answer, this would find it.
|
||||
assert.equal(JSON.stringify(page).includes('core.announce'), false)
|
||||
})
|
||||
|
||||
test('a phase the pinned version does not name renders nothing rather than an id', async () => {
|
||||
store.runs[0].status = 'running'
|
||||
store.runs[0].current_phase = 'a-phase-since-renamed'
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.event.current.phase, null)
|
||||
})
|
||||
|
||||
test('failed and missed are both published as cancelled', async () => {
|
||||
for (const status of ['failed', 'missed']) {
|
||||
store.runs[0].status = status
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.event.past[0].status, 'cancelled', status)
|
||||
}
|
||||
})
|
||||
|
||||
test('paused is published as live — an operator holding a run is not a public state', async () => {
|
||||
store.runs[0].status = 'paused'
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.event.current.status, 'live')
|
||||
assert.equal(page.event.live, true)
|
||||
})
|
||||
|
||||
// ── Which side of now an occurrence falls on ───────────────────────────────
|
||||
//
|
||||
// Both of these were found by the browser walk, and both are the same mistake:
|
||||
// the split reading a STATUS where it should read a clock. Dates here are
|
||||
// relative to the real clock, because `event()` asks `Date.now()` — a run
|
||||
// "next Friday" has to still be next Friday when this runs.
|
||||
|
||||
const inDays = (n) => new Date(Date.now() + n * 86_400_000)
|
||||
|
||||
test('a cancelled occurrence in the FUTURE is what is coming, not what happened', async () => {
|
||||
// It was announced and it has been called off, and "next Friday is off" is
|
||||
// exactly what somebody checking the calendar came to find out. Filing it
|
||||
// under "previously" tells them it already happened, which is the one thing
|
||||
// certainly untrue about it.
|
||||
store.runs = [
|
||||
{ ...run({ id: 1, status: 'cancelled', scheduled_for: inDays(4), ended_at: null, results_published_at: null }) },
|
||||
{ ...run({ id: 2, status: 'scheduled', scheduled_for: inDays(11), ended_at: null, results_published_at: null }) },
|
||||
]
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.deepEqual(page.event.upcoming.map((o) => o.runId), [1, 2])
|
||||
assert.deepEqual(page.event.past, [])
|
||||
})
|
||||
|
||||
test('`next` skips a cancelled occurrence even though it is listed as coming', async () => {
|
||||
// The headline answers "when is the next one", and a cancelled occurrence is
|
||||
// not one. An event whose only future occurrence was called off has no `next`
|
||||
// and says so, while the cancellation is still listed below.
|
||||
store.runs = [
|
||||
{ ...run({ id: 1, status: 'cancelled', scheduled_for: inDays(4), ended_at: null, results_published_at: null }) },
|
||||
]
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.event.next, null)
|
||||
assert.equal(page.event.upcoming.length, 1)
|
||||
})
|
||||
|
||||
test('a scheduled occurrence whose moment has gone by is in the past', async () => {
|
||||
// The other direction of the same rule: the runner had not reached it, so its
|
||||
// status still says `scheduled` while the evening it named is over.
|
||||
store.runs = [
|
||||
{ ...run({ id: 1, status: 'scheduled', scheduled_for: inDays(-3), ended_at: null, results_published_at: null }) },
|
||||
]
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.deepEqual(page.event.upcoming, [])
|
||||
assert.deepEqual(page.event.past.map((o) => o.runId), [1])
|
||||
})
|
||||
|
||||
// ── Results ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a results row publishes the score and the rank and never the member key', async () => {
|
||||
store.participants = [
|
||||
{ id: 1, member_key: 'serial:0x40001234', user_id: 7, score: 1420, rank_at: 1, meta: { name: 'Aldric' } },
|
||||
]
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.event.results.participants[0].name, 'Aldric')
|
||||
assert.equal(page.event.results.participants[0].rank, 1)
|
||||
assert.equal(JSON.stringify(page).includes('0x40001234'), false)
|
||||
assert.equal(JSON.stringify(page).includes('user_id'), false)
|
||||
})
|
||||
|
||||
test('an unpublished results table is absent rather than empty', async () => {
|
||||
store.runs[0].results_published_at = null
|
||||
store.participants = [{ id: 1, member_key: 'k', score: 10, rank_at: null, meta: null }]
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.event.results, null)
|
||||
})
|
||||
|
||||
test('?run= selects which occurrence the results are about', async () => {
|
||||
const older = {
|
||||
...run({ id: 3600, scheduled_for: new Date('2026-08-22T00:00:00Z') }),
|
||||
definition_title: 'The Yew Invasion',
|
||||
definition_slug: 'the-yew-invasion',
|
||||
}
|
||||
store.runs.push(older)
|
||||
const page = await publicModel.event('the-yew-invasion', { runId: '3600' })
|
||||
assert.equal(page.event.results.runId, 3600)
|
||||
})
|
||||
|
||||
test('a run id belonging to no occurrence of this event renders the page anyway', async () => {
|
||||
// A stale link in a months-old mail should land on the event it was about, not
|
||||
// on a dead end.
|
||||
const page = await publicModel.event('the-yew-invasion', { runId: '999999' })
|
||||
assert.equal(page.ok, true)
|
||||
assert.equal(page.event.slug, 'the-yew-invasion')
|
||||
})
|
||||
|
||||
// ── The arc ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a series with nothing listed in it is a 404, not an empty page', async () => {
|
||||
store.series = [{ id: 5, name: 'The Yew Campaign', slug: 'the-yew-campaign', description: null }]
|
||||
store.definitions[0].series_id = 5
|
||||
store.definitions[0].listed = false
|
||||
const arc = await publicModel.series('the-yew-campaign')
|
||||
assert.equal(arc.ok, false)
|
||||
assert.equal(arc.status, 404)
|
||||
})
|
||||
|
||||
test('an arc lists its listed events and nothing about their plans', async () => {
|
||||
store.series = [{ id: 5, name: 'The Yew Campaign', slug: 'the-yew-campaign', description: 'An arc.' }]
|
||||
store.definitions[0].series_id = 5
|
||||
const arc = await publicModel.series('the-yew-campaign')
|
||||
assert.equal(arc.ok, true)
|
||||
assert.deepEqual(Object.keys(arc.series.events[0]).sort(), ['imageUrl', 'slug', 'summary', 'title'])
|
||||
})
|
||||
|
||||
// ── Participation history ──────────────────────────────────────────────────
|
||||
|
||||
test('history publishes the rank as null until results were published', async () => {
|
||||
store.history = [
|
||||
{
|
||||
id: 9,
|
||||
run_id: 3692,
|
||||
score: 1420,
|
||||
rank_at: null,
|
||||
joined_at: new Date(),
|
||||
meta: null,
|
||||
scheduled_for: new Date('2026-08-29T00:00:00Z'),
|
||||
started_at: null,
|
||||
ended_at: null,
|
||||
status: 'completed',
|
||||
scope: '',
|
||||
timezone: 'UTC',
|
||||
results_published_at: null,
|
||||
definition_title: 'The Yew Invasion',
|
||||
definition_slug: 'the-yew-invasion',
|
||||
series_name: null,
|
||||
series_slug: null,
|
||||
},
|
||||
]
|
||||
const result = await publicModel.history(7)
|
||||
assert.equal(result.entries[0].rank, null)
|
||||
assert.equal(result.entries[0].resultsPublishedAt, null)
|
||||
assert.equal(result.entries[0].score, 1420)
|
||||
})
|
||||
|
||||
test('history publishes no member key', async () => {
|
||||
store.history = [
|
||||
{
|
||||
id: 9,
|
||||
run_id: 3692,
|
||||
member_key: 'serial:0x40001234',
|
||||
score: 1,
|
||||
rank_at: 1,
|
||||
joined_at: new Date(),
|
||||
meta: null,
|
||||
scheduled_for: new Date(),
|
||||
status: 'completed',
|
||||
timezone: 'UTC',
|
||||
definition_title: 't',
|
||||
definition_slug: 's',
|
||||
},
|
||||
]
|
||||
const result = await publicModel.history(7)
|
||||
assert.equal(JSON.stringify(result).includes('0x40001234'), false)
|
||||
})
|
||||
Reference in New Issue
Block a user