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')