diff --git a/client/src/App.jsx b/client/src/App.jsx
index 74db2f0..aac4eee 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -262,6 +262,15 @@ export default function App() {
two paths; `lib/notificationPaths.js` is the one mapping. */}
} />
} />
+ {/* 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. */}
+ } />
{/* Installed modules' admin pages, at /admin//…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
wrapper — only an optional { roles }, which core applies as the
diff --git a/client/src/lib/notificationPaths.js b/client/src/lib/notificationPaths.js
index f571447..48e378d 100644
--- a/client/src/lib/notificationPaths.js
+++ b/client/src/lib/notificationPaths.js
@@ -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'
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index 80c1db1..452611a 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -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',
}
diff --git a/server/test/eventAnnounce.test.js b/server/test/eventAnnounce.test.js
index cab381c..bb72fc3 100644
--- a/server/test/eventAnnounce.test.js
+++ b/server/test/eventAnnounce.test.js
@@ -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')