Compare commits

...
Author SHA1 Message Date
joshuaandClaude Opus 5.5 740897635f Event series (1.47.1)
Group separate events (Camp 2025, Camp 2026) on a series page at
/series/<slug> with upcoming and past events. Events join a series from
the event editor (copies keep it); a series can span campuses. Event
pages show "Part of ..." and "Next in this series". Signed-in people can
follow a series; the new `series` job emails followers once when a new
event in it goes live, as News & announcements (opted-in only, with
unsubscribe). Follows are in the data export and removed on
anonymising. Migration 20261028140000_event_series.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:29:59 +02:00
38 changed files with 1176 additions and 34 deletions
+22
View File
@@ -7,6 +7,28 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.47.1] - 2026-10-28
### Added
- **Event series** — group separate events (Camp 2025, Camp 2026; the monthly men's breakfast) on
one page. Supervisors make series on the new **Event series** page (name, web address,
description, picture) and put an event in one with the **Series** box in the event editor; a
copied event keeps its series. A series can have events at several campuses.
- **Series page** `/series/<slug>`: what's coming up (event cards) and past events. Each event in a
series shows "Part of …" and **Next in this series** on its page.
- **Follow a series** — signed-in people follow on the series page. When a new event in the series
goes live (and hasn't started) followers get one email, sent as News & announcements: only to
people opted in to news, with the unsubscribe link and one-click headers. New `series` job every
15 minutes (`SERIES_JOBS_ENABLED`); an event moved into another series is announced to that
series' followers.
- Followed series are in "Download my data" and removed when an account is anonymised.
- API: `/api/series` (list, create, change; delete for admins; `by-slug`; follow/unfollow),
`seriesId` on event create/update, `?series=<slug>` on `GET /api/events`, `series` and
`nextInSeries` on `GET /api/events/:id`. Creating and changing series is blocked in licence
read-only mode. Audit actions `series_created`, `series_updated`, `series_deleted` (migration
`20261028140000_event_series`).
## [1.47.0] - 2026-10-28
### Added
+28 -9
View File
@@ -29,15 +29,16 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
13. [Cashup & event costs](#cashup--event-costs)
14. [Reports](#reports)
15. [Site settings & setup wizard](#site-settings--setup-wizard)
16. [Multi-campus](#multi-campus)
17. [Kids check-in](#kids-check-in)
18. [Volunteer rosters](#volunteer-rosters)
19. [Accounts & security](#accounts--security)
20. [Privacy (POPIA)](#privacy-popia)
21. [Help system](#help-system)
22. [Background jobs](#background-jobs)
23. [Operations](#operations)
24. [Page map](#page-map)
16. [Event series](#event-series)
17. [Multi-campus](#multi-campus)
18. [Kids check-in](#kids-check-in)
19. [Volunteer rosters](#volunteer-rosters)
20. [Accounts & security](#accounts--security)
21. [Privacy (POPIA)](#privacy-popia)
22. [Help system](#help-system)
23. [Background jobs](#background-jobs)
24. [Operations](#operations)
25. [Page map](#page-map)
---
@@ -539,6 +540,22 @@ See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
---
## Event series
Separate events grouped on one page — every year's camp, the monthly men's breakfast (each event
keeps its own registrations; for one event on several dates see multi-day events).
- **Event series** page (supervisors): name, web address (`/series/<slug>`), description, picture;
each series lists its events (campus-scoped supervisors see only their campuses' events in it)
and its number of followers. Admins can delete a series (its events stay).
- **Series box** in the event editor's Basic details. A copied event keeps its series. A series can
have events at several campuses.
- **Public series page**: upcoming events as cards, then past events. Event pages in a series show
"Part of …" and **Next in this series** (the next public event in it).
- **Follow a series**: signed-in people follow on the series page; when a new event in it goes live
(before it starts) they get one email, sent as News & announcements (opted-in only, with an
unsubscribe link). The `series` job runs every 15 minutes.
## Multi-campus
For a church with several campuses in one install (Site settings → Campuses, off by
@@ -762,6 +779,7 @@ can't start, the same jobs run on in-process timers.
| `feedback` — post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false`) |
| `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) |
| `volunteers` — volunteer reminders the day before a shift | Every 15 minutes (`VOLUNTEER_JOBS_ENABLED=false`) |
| `series` — email a series' followers when a new event in it goes live | Every 15 minutes (`SERIES_JOBS_ENABLED=false`) |
| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) |
| `privacy` — remind admins of privacy requests due within a week or overdue | 08:00 daily (`PRIVACY_JOBS_ENABLED=false`) |
| `retention` — delete/anonymise personal information past its retention period (Admin → Privacy → Retention) | 03:30 daily (`RETENTION_JOBS_ENABLED=false`) |
@@ -816,6 +834,7 @@ can't start, the same jobs run on in-process timers.
| `/` | Public | Home |
| `/events`, `/events/[id]` | Public | Event list and detail |
| `/campus/[slug]` | Public | A campus's page and its events (while campuses are on) |
| `/series/[slug]` | Public | A series' page: upcoming and past events, Follow |
| `/[alias]` | Public | Event short link |
| `/register/[eventId]` | Public / User | Registration |
| `/registration/success` | Public / User | Confirmation, form and pay |
+1
View File
@@ -117,6 +117,7 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
| `retention_policies` | Inactive accounts 60 months (on), form answers 12, login history 24, payment details 24, children at kids check-in (`kids_profiles`) 6, audit/access logs 60 — all on | Admin → Privacy → Retention (JSON `{ policies: { key: { enabled, months } }, reviewedAt, reviewedBy }`). Minimums: inactive accounts and logs 12 months, login history and payment details 3, form answers and kids profiles 1; maximum 240. Saving marks them reviewed (readiness check). |
| `retention_last_run` | — | When the `retention` job last ran and what it did (readiness check: within 2 days). |
| `VOLUNTEER_JOBS_ENABLED` (env) | `true` | The `volunteers` job (every 15 minutes) that sends volunteer reminders the day before a shift. |
| `SERIES_JOBS_ENABLED` (env) | `true` | The `series` job (every 15 minutes) that emails a series' followers once when a new event in it goes live (News & announcements: only people opted in to news). |
| `RETENTION_JOBS_ENABLED` (env) | `true` | The nightly 03:30 `retention` job. Off means nothing expires automatically. |
| `wawp_webhook_last_seen` | — | Set when WAWP's webhook arrives with the right secret; the readiness page uses it to check WhatsApp STOP is set up. |
| `privacy_notice_draft` | — | The unpublished draft from Admin → Privacy → Privacy notice (JSON). Published versions are in the `PrivacyNotice` table. With no published version the built-in notice (`backend/src/utils/privacyNotice.js`) is shown. The notice's org name, Information Officer, website, emails and medical retention days come from the settings above when it's shown. |
+8
View File
@@ -12,6 +12,14 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 1.47.1
- **Event series.** Group events that come back — every year's camp, the monthly breakfast — on
one page that shows what's coming up and what's been. Each event in a series links to the next one.
- **Follow a series.** People can follow a series and get an email when the next event in it is
out. It counts as news, so only people who chose to get news and announcements receive it.
- A copied event stays in its series, and a series can have events at different campuses.
## 1.47.0
- **Copy an event.** Running the same camp, course or breakfast again? Press **Copy** on the event
+1
View File
@@ -77,6 +77,7 @@ WAITLIST_JOB_ENABLED=true
PAYMENT_STATUS_JOB_ENABLED=true
# Volunteer rosters: reminders the day before a shift (every 15 minutes)
VOLUNTEER_JOBS_ENABLED=true
SERIES_JOBS_ENABLED=true
# Privacy (POPIA): daily 08:00 reminder to admins of data requests due within a week
PRIVACY_JOBS_ENABLED=true
# Privacy (POPIA): nightly 03:30 retention job (Admin → Privacy → Retention). Note: inactive
+15
View File
@@ -933,6 +933,21 @@ Allergies, medical and custody notes are stored encrypted. Staff routes are camp
Since 1.46.0 board rooms also carry `rostered: [{ userId, name, checkedIn }]` (volunteers on a serving
role linked to the room); checked-in ones are counted in `leaders`.
## Event series
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/series` | supervisor+ | Every series with `followers`, `eventCount` and its events (campus-scoped: their campuses' events only) |
| POST | `/api/series` | supervisor+ | Create: `{ name, slug?, description?, picture? }` (slug made from the name). 409 when the slug is taken |
| PUT | `/api/series/:id` | supervisor+ | Change any of those fields |
| DELETE | `/api/series/:id` | admin | Delete; events stay without a series, followers dropped |
| GET | `/api/series/by-slug/:slug` | public | `{ id, name, slug, description, picture, upcoming, past, following }` — public events only |
| POST / DELETE | `/api/series/:id/follow` | user | Follow / unfollow |
Events take `seriesId` (null = none) on create and update; `GET /api/events?series=<slug>` lists one
series; `GET /api/events/:id` includes `series` and `nextInSeries`. The `series` job emails
followers once per event (`Event.seriesAnnouncedAt`) when it is public and hasn't started.
## Volunteer rosters
Volunteer rosters (1.46.0, `src/utils/volunteers.js`). A **serving role** belongs to an event, or to
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.47.0",
"version": "1.47.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.47.0",
"version": "1.47.1",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.47.0",
"version": "1.47.1",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,59 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'series_created';
ALTER TYPE "AdminAuditAction" ADD VALUE 'series_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'series_deleted';
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "seriesAnnouncedAt" TIMESTAMP(3),
ADD COLUMN "seriesId" TEXT;
-- CreateTable
CREATE TABLE "EventSeries" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"picture" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventSeries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SeriesFollow" (
"id" TEXT NOT NULL,
"seriesId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SeriesFollow_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "EventSeries_slug_key" ON "EventSeries"("slug");
-- CreateIndex
CREATE INDEX "SeriesFollow_userId_idx" ON "SeriesFollow"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "SeriesFollow_seriesId_userId_key" ON "SeriesFollow"("seriesId", "userId");
-- CreateIndex
CREATE INDEX "Event_seriesId_idx" ON "Event"("seriesId");
-- AddForeignKey
ALTER TABLE "Event" ADD CONSTRAINT "Event_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "EventSeries"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeriesFollow" ADD CONSTRAINT "SeriesFollow_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "EventSeries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeriesFollow" ADD CONSTRAINT "SeriesFollow_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+38
View File
@@ -162,6 +162,7 @@ model User {
volunteerSlots VolunteerSlot[] @relation("VolunteerSlotUser")
volunteersAssigned VolunteerSlot[] @relation("VolunteerSlotAssignedBy")
volunteersCheckedIn VolunteerSlot[] @relation("VolunteerSlotCheckedInBy")
seriesFollows SeriesFollow[]
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
@@ -319,10 +320,44 @@ model Event {
volunteerOptionId String?
volunteerCheckInToken String? @unique
// Event series (1.47.1): separate events grouped on one series page (Camp 2025, Camp 2026).
// seriesAnnouncedAt: when followers were emailed that this event is out (once, when it goes live).
seriesId String?
series EventSeries? @relation(fields: [seriesId], references: [id], onDelete: SetNull)
seriesAnnouncedAt DateTime?
@@index([createdById])
@@index([closedById])
@@index([reopenedById])
@@index([campusId])
@@index([seriesId])
}
/// Separate events grouped together, e.g. every year's camp or the monthly men's breakfast.
/// Not tied to a campus: a series can have events at several campuses.
model EventSeries {
id String @id @default(uuid())
name String
slug String @unique
description String?
picture String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events Event[]
followers SeriesFollow[]
}
/// Someone who wants to hear when a new event in the series is out (sent as news).
model SeriesFollow {
id String @id @default(uuid())
seriesId String
userId String
createdAt DateTime @default(now())
series EventSeries @relation(fields: [seriesId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([seriesId, userId])
@@index([userId])
}
model EventOption {
@@ -702,6 +737,9 @@ enum AdminAuditAction {
volunteer_assigned
volunteer_removed
volunteer_checked_in_manual
series_created
series_updated
series_deleted
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
+15 -2
View File
@@ -11,6 +11,9 @@ const sessionsUtil = require('../utils/sessions');
const { publicOptions } = require('../utils/volunteers');
const { campusesEnabled, campusForEvent, campusWhere, resolveCampusFilter, EVENT_CAMPUS_SELECT } = require('../utils/campuses');
const { scopeFor, canView, canManage, eventWhere: scopeEventWhere, withScope } = require('../utils/campusScope');
const seriesUtil = require('../utils/series');
const SERIES_SELECT = { select: { id: true, name: true, slug: true } };
/** A campus-scoped supervisor may only create or move events to one of their campuses. */
async function assertCampusAllowed(req, campusId) {
@@ -158,6 +161,7 @@ const createEvent = async (req, res) => {
if (campusPick.error) { const err = new Error(campusPick.error); err.statusCode = 400; throw err; }
// Not choosing a campus means all campuses, which a scoped supervisor can't create.
await assertCampusAllowed(req, campusPick.campusId === undefined ? null : campusPick.campusId);
const seriesId = await seriesUtil.seriesForEvent(req.body.seriesId);
const data = {
id: uuidv4(),
@@ -195,6 +199,7 @@ const createEvent = async (req, res) => {
contactEmail: contactEmail || null,
location: location || null,
...(campusPick.campusId !== undefined ? { campusId: campusPick.campusId } : {}),
...(seriesId ? { seriesId } : {}),
...scheduleData(req.body),
};
if (data.scheduleType === 'sessions') {
@@ -363,6 +368,8 @@ const getEvents = async (req, res) => {
// ?campus=<slug or id>: that campus's events plus church-wide ones.
const enabled = await campusesEnabled();
const campusFilter = campusWhere(enabled ? await resolveCampusFilter(req.query.campus) : '', { enabled, withAllCampuses: true });
// ?series=<slug>: one series' events.
if (req.query.series) campusFilter.series = { slug: String(req.query.series) };
try {
events = await prisma.event.findMany({
where: {
@@ -372,7 +379,7 @@ const getEvents = async (req, res) => {
endDate: { gte: new Date() },
...campusFilter,
},
include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT } }
include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT }, series: SERIES_SELECT }
});
} catch (e) {
// Fallback if goLiveAt/isHidden not available yet (pre-migration)
@@ -471,6 +478,7 @@ const getEventsAll = async (req, res) => {
form: { include: { fields: true } },
sessions: SESSIONS_INCLUDE,
campus: { select: EVENT_CAMPUS_SELECT },
series: SERIES_SELECT,
},
orderBy: { startDate: 'asc' }
});
@@ -498,7 +506,7 @@ const getEventById = async (req, res) => {
const optionInclude = canIncludeTiers
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
: undefined;
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT } };
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT }, series: SERIES_SELECT };
// Staff/supervisor/admin need the creator + notify-recipient list to populate the
// event edit screen's Notifications step; public callers don't need this.
@@ -653,6 +661,7 @@ const getEventById = async (req, res) => {
})),
form,
bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []),
nextInSeries: await seriesUtil.nextInSeries(event).catch(() => null),
}, bookings);
res.json(mapped);
} catch (error) {
@@ -742,6 +751,9 @@ const updateEvent = async (req, res) => {
const campusPick = await campusForEvent(req.body.campusId, { enabled: await campusesEnabled(), currentCampusId: event.campusId });
if (campusPick.error) throw new Error(campusPick.error);
const campusChanged = campusPick.campusId !== undefined && campusPick.campusId !== event.campusId;
const seriesPick = await seriesUtil.seriesForEvent(req.body.seriesId);
// Moved into another series: that series' followers hear about it (once it's live).
const seriesChanged = seriesPick !== undefined && seriesPick !== event.seriesId;
if (campusChanged) {
try { await assertCampusAllowed(req, campusPick.campusId); } catch (e) { return res.status(403).json({ message: e.message }); }
}
@@ -784,6 +796,7 @@ const updateEvent = async (req, res) => {
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
location: location !== undefined ? (location || null) : event.location,
...(campusChanged ? { campusId: campusPick.campusId } : {}),
...(seriesChanged ? { seriesId: seriesPick, seriesAnnouncedAt: null } : {}),
updatedAt: new Date(),
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
...scheduleData(req.body, event),
+108
View File
@@ -0,0 +1,108 @@
const prisma = require('../config/db');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { cleanSeriesInput, publicEventWhere, splitEvents, follow, unfollow } = require('../utils/series');
const { scopeFor, canView } = require('../utils/campusScope');
const { EVENT_CAMPUS_SELECT } = require('../utils/campuses');
const fail = (res, error) => res.status(error.statusCode || (error.code === 'P2002' ? 409 : 400)).json({
message: error.code === 'P2002' ? 'Another series already uses that web address' : error.message,
});
const audit = (req, action, series, metadata = {}) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'EventSeries', targetId: series.id,
metadata: { name: series.name, ...metadata }, ip: getClientIp(req),
});
// @desc Every series, with its events (the ones this person may see) and number of followers
// @route GET /api/series
// @access Private/Supervisor
const listSeries = async (req, res) => {
try {
const scope = await scopeFor(req);
const rows = await prisma.eventSeries.findMany({
orderBy: { name: 'asc' },
include: {
events: { orderBy: { startDate: 'desc' }, select: { id: true, title: true, startDate: true, endDate: true, isActive: true, isHidden: true, campusId: true, campus: { select: EVENT_CAMPUS_SELECT } } },
_count: { select: { followers: true, events: true } },
},
});
// A series spans campuses; campus-scoped staff see the series, but only their campuses' events in it.
res.json(rows.map(({ _count, events, ...s }) => ({
...s,
followers: _count.followers,
eventCount: _count.events,
events: events.filter(e => canView(scope, e.campusId)),
})));
} catch (error) { fail(res, error); }
};
// @desc Create a series
// @route POST /api/series
// @access Private/Supervisor
const createSeries = async (req, res) => {
try {
const series = await prisma.eventSeries.create({ data: cleanSeriesInput(req.body) });
audit(req, 'series_created', series);
res.status(201).json(series);
} catch (error) { fail(res, error); }
};
// @desc Rename a series or change its page
// @route PUT /api/series/:id
// @access Private/Supervisor
const updateSeries = async (req, res) => {
try {
const current = await prisma.eventSeries.findUnique({ where: { id: req.params.id } });
if (!current) return res.status(404).json({ message: 'Series not found' });
const data = cleanSeriesInput(req.body, current);
const series = await prisma.eventSeries.update({ where: { id: current.id }, data });
audit(req, 'series_updated', series, { changedKeys: Object.keys(data) });
res.json(series);
} catch (error) { fail(res, error); }
};
// @desc Delete a series (its events stay, just without a series; followers are dropped)
// @route DELETE /api/series/:id
// @access Private/Admin
const deleteSeries = async (req, res) => {
try {
const current = await prisma.eventSeries.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true, followers: true } } } });
if (!current) return res.status(404).json({ message: 'Series not found' });
await prisma.eventSeries.delete({ where: { id: current.id } });
audit(req, 'series_deleted', current, { events: current._count.events, followers: current._count.followers });
res.json({ ok: true });
} catch (error) { fail(res, error); }
};
// @desc A series' public page: its details, upcoming and past public events, and whether the
// signed-in person follows it
// @route GET /api/series/by-slug/:slug
// @access Public
const getSeriesBySlug = async (req, res) => {
try {
const series = await prisma.eventSeries.findUnique({
where: { slug: String(req.params.slug) },
select: { id: true, name: true, slug: true, description: true, picture: true },
});
if (!series) return res.status(404).json({ message: 'Series not found' });
const events = await prisma.event.findMany({
where: { ...publicEventWhere(), seriesId: series.id },
select: { id: true, title: true, startDate: true, endDate: true, location: true, campus: { select: EVENT_CAMPUS_SELECT } },
});
const { upcoming, past } = splitEvents(events);
const following = req.user ? !!(await prisma.seriesFollow.findUnique({ where: { seriesId_userId: { seriesId: series.id, userId: req.user.id } } })) : false;
res.json({ ...series, upcoming, past, following });
} catch (error) { fail(res, error); }
};
// @route POST /api/series/:id/follow · DELETE /api/series/:id/follow
// @access Private
const followSeries = async (req, res) => {
try { await follow(req.params.id, req.user.id); res.json({ following: true }); } catch (error) { fail(res, error); }
};
const unfollowSeries = async (req, res) => {
try { await unfollow(req.params.id, req.user.id); res.json({ following: false }); } catch (error) { fail(res, error); }
};
module.exports = { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries };
+2
View File
@@ -14,6 +14,8 @@ const BLOCKED = [
['POST', /^\/api\/events\/?$/],
['PUT', /^\/api\/events\/[^/]+\/?$/],
['POST', /^\/api\/events\/[^/]+\/copy-from\/?$/],
['POST', /^\/api\/series\/?$/],
['PUT', /^\/api\/series\/[^/]+\/?$/],
['POST', /^\/api\/events\/[^/]+\/options\/?$/],
['PUT', /^\/api\/events\/options\/[^/]+\/?$/],
['POST', /^\/api\/events\/options\/[^/]+\/variants\/?$/],
+12
View File
@@ -185,6 +185,7 @@ app.use('/api/hub', require('./routes/hubRoutes'));
app.use('/api/campuses', require('./routes/campusRoutes'));
app.use('/api/kids', require('./routes/kidsRoutes'));
app.use('/api/volunteers', require('./routes/volunteerRoutes'));
app.use('/api/series', require('./routes/seriesRoutes'));
app.use('/api/users', userRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/registrations', registrationRoutes);
@@ -997,6 +998,17 @@ app.get('/docs', async (req, res) => {
{ method:'POST', path:'/api/kids/events/:eventId/sync', auth:'staff+', desc:'Sync an offline desk\'s check-ins and check-outs (idempotent by clientId). Audited as kids_offline_sync' },
]},
{ title: 'Event series', base: '/api/series', endpoints: [
{ method:'GET', path:'/api/series', auth:'supervisor+', desc:'Every series with followers, eventCount and its events (campus-scoped staff see only their campuses\' events in each)' },
{ method:'POST', path:'/api/series', auth:'supervisor+', desc:'Create a series (slug made from the name when not given). Audited as series_created',
request:{ body:{ name:'Family Camp', slug:'family-camp', description:'Every year in March', picture:null } } },
{ method:'PUT', path:'/api/series/:id', auth:'supervisor+', desc:'Change a series. Audited as series_updated' },
{ method:'DELETE', path:'/api/series/:id', auth:'admin', desc:'Delete a series; its events stay without a series and followers are dropped. Audited as series_deleted' },
{ method:'GET', path:'/api/series/by-slug/:slug', auth:'public', desc:'The series page: details, upcoming and past public events, and following (signed in)' },
{ method:'POST', path:'/api/series/:id/follow', auth:'user', desc:'Follow a series: an email (News & announcements) when a new event in it goes live' },
{ method:'DELETE', path:'/api/series/:id/follow', auth:'user', desc:'Unfollow a series' },
]},
{ title: 'Volunteer rosters', base: '/api/volunteers', endpoints: [
{ method:'GET', path:'/api/volunteers/me', auth:'user', desc:'My shifts (upcoming and the last week), cover requests I could take, and events looking for volunteers' },
{ method:'GET', path:'/api/volunteers/events/:eventId', auth:'user', desc:'An event\'s serving roles: shift, how many are needed and signed up, whether I\'m in it, open cover requests' },
+4
View File
@@ -49,6 +49,10 @@ const JOBS = [
name: 'volunteers', label: 'Volunteer reminders the day before a shift', cron: '*/15 * * * *', env: 'VOLUNTEER_JOBS_ENABLED',
run: () => require('../utils/volunteers').runReminderJob(),
},
{
name: 'series', label: 'Event series: tell followers when a new event goes live', cron: '*/15 * * * *', env: 'SERIES_JOBS_ENABLED',
run: () => require('../utils/series').runAnnounceJob(),
},
{
name: 'consent', label: 'Parental consent reminders and medical-detail deletion', cron: '0 9 * * *', env: 'CONSENT_JOBS_ENABLED',
run: async () => {
+19
View File
@@ -0,0 +1,19 @@
const express = require('express');
const router = express.Router();
const { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries } = require('../controllers/seriesController');
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
const SPANS = campusIndependent('a series spans campuses; adding an event to one is checked on the event');
router.get('/by-slug/:slug', optionalAuth, getSeriesBySlug);
router.post('/:id/follow', protect, followSeries);
router.delete('/:id/follow', protect, unfollowSeries);
// The list filters each series' events by the person's campuses itself.
router.get('/', protect, supervisor, SPANS, listSeries);
router.post('/', protect, supervisor, SPANS, createSeries);
router.put('/:id', protect, supervisor, SPANS, updateSeries);
router.delete('/:id', protect, admin, deleteSeries);
module.exports = router;
+10 -1
View File
@@ -463,6 +463,9 @@ async function anonymiseUser(userId, db = prisma) {
// Volunteer rosters: shifts still to come are given up; past ones stay for hours served.
const shiftsGivenUp = await require('./volunteers').forgetVolunteer(tx, userId);
// Event series they followed.
await tx.seriesFollow.deleteMany({ where: { userId } });
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
});
@@ -485,7 +488,7 @@ async function exportUserData(userId, db = prisma) {
});
if (!user) return null;
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices, family, volunteering] = await Promise.all([
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices, family, volunteering, seriesFollowed] = await Promise.all([
db.registration.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
@@ -511,6 +514,7 @@ async function exportUserData(userId, db = prisma) {
db.noticeAcceptance.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { version: true, source: true, createdAt: true } }),
require('./kids').exportFamily(userId, db),
require('./volunteers').exportVolunteering(userId, db),
require('./series').exportFollows(userId, db),
]);
return {
@@ -554,6 +558,7 @@ async function exportUserData(userId, db = prisma) {
dataRequests: requests,
kidsFamily: family,
volunteering,
seriesFollowed,
};
}
@@ -614,6 +619,10 @@ async function dataExportPdf(data) {
heading('Serving (volunteer rosters)');
for (const s of data.volunteering) para(`${fmt(s.from)} — ${s.role}, ${s.event}${s.checkedIn ? ' (checked in)' : ''}`);
}
if (data.seriesFollowed?.length) {
heading('Event series you follow');
for (const f of data.seriesFollowed) para(`${f.series} — since ${fmt(f.since)}`);
}
if (data.feedback.length) {
heading('Feedback you gave');
for (const f of data.feedback) {
+163
View File
@@ -0,0 +1,163 @@
/**
* Event series (1.47.1): separate events grouped on one series page — every year's camp, the
* monthly men's breakfast. A series isn't tied to a campus, so it can have events at several.
* Each event page links to the next public event in its series, and people can follow a series:
* when a new event in it goes live they get one email, sent as News & announcements (so only to
* people who opted in to news, with an unsubscribe link).
*/
const prisma = require('../config/db');
const { slugify } = require('./campuses');
function seriesError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/** Events anyone can see: active, not hidden and live. */
function publicEventWhere(now = new Date()) {
return { isActive: true, isHidden: false, goLiveAt: { lte: now } };
}
/** Name, slug, description and picture from a create/update body (`current` on update). */
function cleanSeriesInput(body = {}, current = null) {
const data = {};
if (body.name !== undefined || !current) {
const name = String(body.name || '').trim();
if (!name) throw seriesError('Give the series a name');
if (name.length > 120) throw seriesError('The name is too long');
data.name = name;
}
if (body.slug !== undefined || !current) {
const slug = slugify(body.slug || data.name || current?.name);
if (!slug) throw seriesError('The web address needs letters or numbers');
data.slug = slug;
}
if (body.description !== undefined) data.description = String(body.description || '').trim() || null;
if (body.picture !== undefined) data.picture = String(body.picture || '').trim() || null;
return data;
}
/** The series to save on an event from a body's `seriesId`: undefined = leave as is, null = none. */
async function seriesForEvent(seriesId, db = prisma) {
if (seriesId === undefined) return undefined;
if (!seriesId) return null;
const found = await db.eventSeries.findUnique({ where: { id: String(seriesId) }, select: { id: true } });
if (!found) throw seriesError('That series no longer exists');
return found.id;
}
const NEXT_SELECT = { id: true, title: true, startDate: true, endDate: true, location: true, redirectUrl: true };
/**
* The next public event in the same series after `event` (starting later, not over yet), or null.
*/
async function nextInSeries(event, db = prisma, now = new Date()) {
if (!event?.seriesId) return null;
return db.event.findFirst({
where: { ...publicEventWhere(now), seriesId: event.seriesId, id: { not: event.id }, startDate: { gt: event.startDate }, endDate: { gte: now } },
orderBy: { startDate: 'asc' },
select: NEXT_SELECT,
});
}
/**
* Splits a series' public events into upcoming (not over yet, soonest first) and past (latest
* first). Pure, for the series page.
*/
function splitEvents(events, now = new Date()) {
const upcoming = events.filter(e => new Date(e.endDate) >= now).sort((a, b) => new Date(a.startDate) - new Date(b.startDate));
const past = events.filter(e => new Date(e.endDate) < now).sort((a, b) => new Date(b.startDate) - new Date(a.startDate));
return { upcoming, past };
}
// ─── Following ───────────────────────────────────────────────────────────────
async function follow(seriesId, userId) {
const series = await prisma.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true } });
if (!series) throw seriesError('Series not found', 404);
await prisma.seriesFollow.upsert({ where: { seriesId_userId: { seriesId, userId } }, create: { seriesId, userId }, update: {} });
}
async function unfollow(seriesId, userId) {
await prisma.seriesFollow.deleteMany({ where: { seriesId, userId } });
}
function frontendUrl() {
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
}
/**
* The `series` job: for each event in a series that is now public and hasn't started, email the
* series' followers once. The event is claimed first (seriesAnnouncedAt), so a slow run or two
* workers never send twice. An event made public after it started is never announced.
*/
async function runAnnounceJob(now = new Date()) {
const due = await prisma.event.findMany({
where: { ...publicEventWhere(now), seriesId: { not: null }, seriesAnnouncedAt: null, startDate: { gt: now } },
select: { id: true, title: true, startDate: true, location: true, redirectUrl: true, series: { select: { id: true, name: true, slug: true } } },
take: 50,
});
let announced = 0;
let sent = 0;
let skipped = 0;
for (const ev of due) {
const claimed = await prisma.event.updateMany({ where: { id: ev.id, seriesAnnouncedAt: null }, data: { seriesAnnouncedAt: now } });
if (!claimed.count) continue;
announced++;
const follows = await prisma.seriesFollow.findMany({
where: { seriesId: ev.series.id, user: { isActive: true } },
select: { user: { select: { id: true, name: true, email: true } } },
});
const { filterRecipients, bulkEmailFooter } = require('./privacy');
const { recipients, skipped: notNews } = await filterRecipients(
follows.filter(f => f.user.email).map(f => ({ email: f.user.email, name: f.user.name, userId: f.user.id })),
{ channel: 'email', kind: 'news' },
);
skipped += notNews;
const { sendMail } = require('./email');
for (const r of recipients) {
const footer = bulkEmailFooter({ kind: 'news', recipient: r });
const msg = announcementEmail({ name: r.name, event: ev, series: ev.series });
try {
await sendMail({ to: r.email, subject: msg.subject, html: msg.html + footer.html, text: msg.text + footer.text, headers: footer.headers });
sent++;
} catch (e) {
try { console.warn('[series] failed to email', r.email, e?.message || e); } catch {}
}
}
}
return { announced, sent, skipped };
}
function announcementEmail({ name, event, series }) {
const { emailWrapper, ctaButton } = require('./email');
const esc = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const when = new Date(event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const url = `${frontendUrl()}/events/${event.id}`;
const seriesUrl = `${frontendUrl()}/series/${series.slug}`;
const subject = `New in ${series.name}: ${event.title}`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">${esc(event.title)}</p>
<p style="color:#64748b;font-size:14px;margin:0 0 24px 0">New in ${esc(series.name)}</p>
<p style="margin:0 0 16px 0;color:#374151">Hi <strong>${esc(name || 'there')}</strong>,</p>
<p style="margin:0 0 16px 0;color:#374151">You follow <strong>${esc(series.name)}</strong>, and the next one is out: <strong>${esc(event.title)}</strong> on ${esc(when)}${event.location ? `, ${esc(event.location)}` : ''}.</p>
${ctaButton('See the event', url)}
<p style="margin:16px 0 0 0;font-size:13px;color:#64748b">To stop hearing about this series, unfollow it on <a href="${seriesUrl}" style="color:#64748b">its page</a>.</p>`;
const text = `Hi ${name || 'there'},\n\nYou follow ${series.name}, and the next one is out: ${event.title} on ${when}${event.location ? `, ${event.location}` : ''}.\n\nSee the event: ${url}\n\nTo stop hearing about this series, unfollow it on its page: ${seriesUrl}`;
return { subject, text, html: emailWrapper(body, { preheader: subject }) };
}
// ─── Privacy ─────────────────────────────────────────────────────────────────
/** Series someone follows, for "Download my data". */
async function exportFollows(userId, db = prisma) {
const rows = await db.seriesFollow.findMany({ where: { userId }, orderBy: { createdAt: 'asc' }, select: { createdAt: true, series: { select: { name: true } } } });
return rows.map(r => ({ series: r.series.name, since: r.createdAt }));
}
module.exports = {
publicEventWhere, cleanSeriesInput, seriesForEvent, nextInSeries, splitEvents,
follow, unfollow, runAnnounceJob, announcementEmail, exportFollows, seriesError,
};
+92
View File
@@ -0,0 +1,92 @@
const mockDb = {
event: { findMany: jest.fn(), updateMany: jest.fn() },
seriesFollow: { findMany: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
const mockFilter = jest.fn();
jest.mock('../src/utils/privacy', () => ({
filterRecipients: (...a) => mockFilter(...a),
bulkEmailFooter: () => ({ html: '<p>unsubscribe</p>', text: '\nunsubscribe', headers: { 'List-Unsubscribe': '<x>' } }),
}));
const mockSend = jest.fn();
jest.mock('../src/utils/email', () => ({
sendMail: (...a) => mockSend(...a),
emailWrapper: body => `<html>${body}</html>`,
ctaButton: (label, url) => `<a href="${url}">${label}</a>`,
}));
const series = require('../src/utils/series');
const { isBlocked } = require('../src/hub/readOnly');
describe('event series', () => {
test('a new series needs a name; the web address comes from it', () => {
expect(() => series.cleanSeriesInput({ name: ' ' })).toThrow('name');
expect(series.cleanSeriesInput({ name: "Men's Breakfast", description: ' Monthly ' })).toEqual({ name: "Men's Breakfast", slug: 'men-s-breakfast', description: 'Monthly' });
expect(series.cleanSeriesInput({ name: 'Camp', slug: 'Family Camp!' }).slug).toBe('family-camp');
});
test('changing a series only touches what was sent', () => {
expect(series.cleanSeriesInput({ description: '' }, { name: 'Camp' })).toEqual({ description: null });
});
test('upcoming soonest first, past latest first', () => {
const now = new Date('2026-06-01T00:00:00Z');
const ev = (id, start, end) => ({ id, startDate: new Date(start), endDate: new Date(end) });
const { upcoming, past } = series.splitEvents([
ev('2024', '2024-03-01', '2024-03-04'), ev('2027', '2027-03-01', '2027-03-04'),
ev('2025', '2025-03-01', '2025-03-04'), ev('2026', '2026-05-30', '2026-06-02'),
], now);
expect(upcoming.map(e => e.id)).toEqual(['2026', '2027']);
expect(past.map(e => e.id)).toEqual(['2025', '2024']);
});
test('creating and changing series is blocked in licence read-only mode; following is not', () => {
expect(isBlocked('POST', '/api/series')).toBe(true);
expect(isBlocked('PUT', '/api/series/abc')).toBe(true);
expect(isBlocked('POST', '/api/series/abc/follow')).toBe(false);
});
describe('announcing a new event to followers', () => {
const now = new Date('2026-06-01T08:00:00Z');
const event = { id: 'e1', title: 'Camp 2027', startDate: new Date('2027-03-01T08:00:00Z'), location: 'The farm', series: { id: 's1', name: 'Family Camp', slug: 'family-camp' } };
beforeEach(() => {
jest.clearAllMocks();
mockDb.event.findMany.mockResolvedValue([event]);
mockDb.seriesFollow.findMany.mockResolvedValue([
{ user: { id: 'u1', name: 'Thandi', email: 'thandi@test.local' } },
{ user: { id: 'u2', name: 'No News', email: 'nonews@test.local' } },
]);
// Only Thandi opted in to news.
mockFilter.mockImplementation(async list => ({ recipients: list.filter(r => r.userId === 'u1'), skipped: list.length - 1 }));
mockSend.mockResolvedValue();
});
test('only public events in a series that have not started and were not announced yet', async () => {
mockDb.event.updateMany.mockResolvedValue({ count: 1 });
await series.runAnnounceJob(now);
const where = mockDb.event.findMany.mock.calls[0][0].where;
expect(where).toMatchObject({ isActive: true, isHidden: false, goLiveAt: { lte: now }, seriesId: { not: null }, seriesAnnouncedAt: null, startDate: { gt: now } });
});
test('emails followers who want news, as news', async () => {
mockDb.event.updateMany.mockResolvedValue({ count: 1 });
const out = await series.runAnnounceJob(now);
expect(out).toEqual({ announced: 1, sent: 1, skipped: 1 });
expect(mockFilter.mock.calls[0][1]).toEqual({ channel: 'email', kind: 'news' });
const mail = mockSend.mock.calls[0][0];
expect(mail.to).toBe('thandi@test.local');
expect(mail.subject).toBe('New in Family Camp: Camp 2027');
expect(mail.html).toContain('/events/e1');
expect(mail.html).toContain('unsubscribe');
expect(mail.headers['List-Unsubscribe']).toBeTruthy();
});
test('an event another run already claimed is not sent again', async () => {
mockDb.event.updateMany.mockResolvedValue({ count: 0 });
const out = await series.runAnnounceJob(now);
expect(out).toEqual({ announced: 0, sent: 0, skipped: 0 });
expect(mockSend).not.toHaveBeenCalled();
});
});
});
+19 -3
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-28, after **1.47.0** (and Hub 1.4.0). The plan itself is in
Last updated: 2026-10-28, after **1.47.1** (and Hub 1.4.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -14,6 +14,7 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.47.1 | **Event series** (§6 part 2): Event series page, Series box on events, public `/series/<slug>`, "Next in this series", follow (News & announcements email from the `series` job when a new event goes live). Migration `20261028140000_event_series` |
| 1.47.0 | **Copy an event** (§6 part 1): "Copy" opens the event editor filled in from the source (campus, sessions, options, tiers, sections, form, recipients, feedback, certificates); "New start" moves every date; allocation groups and (admins) costs copied by `POST /api/events/:id/copy-from`. No registrations, coupons, attachments, kids check-in or volunteer roles |
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
@@ -48,8 +49,8 @@ encrypted settings unreadable or break existing installs.
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. **Event series** (§6), one patch per part: **1.47.1 series** (can span campuses), **1.47.2
year-on-year reports**, **1.47.3 season pass** — then **1.48.0 — outbound webhooks** (§8).
1. The rest of §6, one patch per part: **1.47.2 year-on-year reports** (compare a series' events),
**1.47.3 season pass** (a bundle across a series' events) — then **1.48.0 — outbound webhooks** (§8).
## Working on this repo
@@ -297,6 +298,21 @@ a test server (both done 2026-10-28).
costs are admin-only) via `POST /api/events/:id/copy-from`. Tests: `tests/eventClone.test.js`,
e2e "copy an event" in `supervisor.spec.ts`.
## Where event series live (1.47.1)
- `backend/src/utils/series.js` — input cleaning, `seriesForEvent`, `nextInSeries` (next public event
starting later), `splitEvents`, follow/unfollow, the `series` job (`runAnnounceJob`: public, not
started, `seriesAnnouncedAt` null → claimed, then followers filtered with `filterRecipients` as
news), `exportFollows`. Tests: `tests/series.test.js`; e2e `series.spec.ts`.
- `controllers/seriesController.js` + `routes/seriesRoutes.js` (`/api/series`, campus-independent:
the list filters each series' events by scope). Event create/update take `seriesId`; changing it
clears `seriesAnnouncedAt` so the new series' followers hear.
- `SeriesFollow` is personal data: in `anonymiseUser` and `exportUserData`. No retention rule of its
own — follows go when the account is anonymised.
- Frontend: `app/dashboard/supervisor/series`, `app/series/[slug]`, `components/events/SeriesLinks.tsx`,
`FollowSeriesButton.tsx` (asks the API itself: the page is cached for everyone), the Series box in
the event editor. Series pages aren't in the sitemap yet (no public list endpoint).
## Known gaps / follow-ups
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
+8 -6
View File
@@ -34,8 +34,8 @@ Wallet passes are removed from scope (2026-10-28), so everything after volunteer
up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticker panel, rooms with
ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or session, sign-ups and
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** shipped.
**Next: 1.47.1 Event series.**
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) shipped.
**Next: 1.47.2 Year-on-year reports.**
**Repositories:**
@@ -470,9 +470,11 @@ year-on-year reports, **1.47.3** season pass.
groups, cost template (admins) and sessions. A "New start" moves every date with it. Of the
newer features only the **campus** is copied — not kids check-in or volunteer roles. No
registrations, **no coupons**.
- **Series (1.47.1):** group separate events (Camp 2025, Camp 2026; monthly Men's
- **Series (1.47.1 ✅):** group separate events (Camp 2025, Camp 2026; monthly Men's
Breakfast) on one series page, with "Next in this series" and a series
follow/notify option. **A series can span several campuses.**
follow/notify option. **A series can span several campuses.** Following is **News &
announcements** (opted-in only, unsubscribe link); followers get one email when a new event in the
series goes live.
- **Year-on-year reports (1.47.2):** registrations, income and attendance compared
across events in a series.
- **Season pass (1.47.3):** a bundle across events in a series (extends event bundles).
@@ -960,7 +962,7 @@ income justify the hardware:
| 6 | 1.41.0 ✅ – 1.42.0 ✅ + Hub 1.0 ✅ – 1.1 ✅ | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo); rename to Cross Events (1.41.3); Request update (1.42.0 / Hub 1.1.0) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
| 7 | 1.43.0 ✅ – 1.44.0 ✅ | **Multi-campus**: 1.43.0 campuses (settings, events, public filter and landing pages, home campus, filters, messages, summaries); 1.44.0 campus-scoped staff | Kids rooms and volunteer roles are per campus, so campuses come first; scoping is split out because it's security-heavy |
| 8 | 1.45.0 ✅ – 1.46.0 ✅ | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.47.0 ✅ – 1.47.3, 1.48.0 | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 9 | 1.47.0 ✅ 1.47.1 ✅ – 1.47.3, 1.48.0 | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
@@ -1015,7 +1017,7 @@ feature.
| — | Wallet passes | **Removed from scope** (2026-10-28); everything after volunteer rosters moves up by one |
| — | Kids check-in (1.45.0) | One release. **Fully digital** — no label printers: pickup code on the parent's phone, a "write on the sticker" panel for blank stickers. Staff desk only (no kiosk). Anyone with the pickup code can collect; families list authorised adults without accounts. Child profiles deleted after 6 months without a check-in (2026-10-28) |
| — | Volunteer rosters (1.46.0) | Anyone with an account can volunteer. Roles are per event (or per session) — rosters cover one event/session and never repeat. A swap needs no approval: it happens once someone accepts, and the event's notify recipients are emailed. Volunteers check in by scanning a QR code or are checked in by staff. The free volunteer ticket is off by default and chosen per event. A role linked to a kids room counts its checked-in volunteers as leaders on duty for the room's ratio (2026-10-28) |
| — | Event series and cloning (1.47.x) | One release per part: 1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass. A clone opens the New event popup filled in from the source, so everything is set there before saving. Of the newer features a clone copies only the campus (not kids check-in or volunteer roles); coupons are never copied. A series can span several campuses (2026-10-28) |
| — | Event series and cloning (1.47.x) | One release per part: 1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass. A clone opens the New event popup filled in from the source, so everything is set there before saving. Of the newer features a clone copies only the campus (not kids check-in or volunteer roles); coupons are never copied. A series can span several campuses. Following a series is **News & announcements**; copies keep the source's certificate and reminder settings (2026-10-28) |
| — | POPIA: WhatsApp STOP | In 1.38.0, via WAWP's incoming `message` webhook with a secret in the URL (WAWP doesn't sign webhooks) (2026-10-23) |
## Still open
+65
View File
@@ -0,0 +1,65 @@
import { test, expect, loginAs, api } from "./fixtures";
// Event series (1.47.1): a supervisor makes a series, events join it, the public series page
// lists them, an event page links to the next one, a user follows it, and the series job tells
// followers once when a new event goes live.
type Created = { id: string; title: string };
type Series = { id: string; slug: string; name: string };
type JobRun = { queued: boolean; output: { announced: number; sent: number; skipped: number } };
const day = 24 * 60 * 60 * 1000;
const event = (title: string, startsInDays: number, seriesId: string) => {
const start = new Date(Date.now() + startsInDays * day);
return { title, description: "A breakfast for the tests.", startDate: start.toISOString(), endDate: new Date(start.getTime() + 2 * 60 * 60 * 1000).toISOString(), price: 0, location: "1 Test Street, Pretoria", seriesId };
};
test.describe.serial("event series", () => {
let series: Series;
let past: Created;
let next: Created;
test("a supervisor makes a series", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/series");
await page.getByRole("button", { name: "+ New series" }).click();
await page.getByLabel("Name").fill("E2E Breakfast");
await page.getByLabel("Description").fill("Men's breakfast, once a month.");
await page.getByRole("button", { name: "Create series" }).click();
await expect(page.getByRole("status")).toContainText("E2E Breakfast");
await expect(page.getByTestId("series-row").filter({ hasText: "E2E Breakfast" })).toContainText("/series/e2e-breakfast");
const all = await api<Series[]>(page, "supervisor", "GET", "/api/series");
series = all.find((s) => s.name === "E2E Breakfast")!;
past = await api<Created>(page, "supervisor", "POST", "/api/events", event("E2E Breakfast (last month)", -30, series.id));
next = await api<Created>(page, "supervisor", "POST", "/api/events", event("E2E Breakfast (next month)", 30, series.id));
// Both are live already; announce them now so only the event made after following counts below.
await api<JobRun>(page, "admin", "POST", "/api/admin/jobs/series/run");
});
test("the series page lists what's coming up and what's past", async ({ page }) => {
await page.goto(`/series/${series.slug}`);
await expect(page.getByRole("heading", { name: "E2E Breakfast", exact: true })).toBeVisible();
await expect(page.getByText("E2E Breakfast (next month)")).toBeVisible();
await expect(page.getByTestId("series-past")).toContainText("E2E Breakfast (last month)");
});
test("an event page links to its series and the next event in it", async ({ page }) => {
await page.goto(`/events/${past.id}`);
await expect(page.getByTestId("event-series")).toContainText("Part of E2E Breakfast");
await page.getByTestId("next-in-series").click();
await expect(page).toHaveURL(new RegExp(`/events/${next.id}`));
});
test("a user follows the series and hears once about a new event", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/series/${series.slug}`);
await page.getByRole("button", { name: "Follow this series" }).click();
await expect(page.getByRole("button", { name: "Unfollow" })).toBeVisible();
await api<Created>(page, "supervisor", "POST", "/api/events", event("E2E Breakfast (in two months)", 60, series.id));
const first = await api<JobRun>(page, "admin", "POST", "/api/admin/jobs/series/run");
expect(first.output.announced).toBe(1);
const again = await api<JobRun>(page, "admin", "POST", "/api/admin/jobs/series/run");
expect(again.output.announced).toBe(0);
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.47.0",
"version": "1.47.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.47.0",
"version": "1.47.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.47.0",
"version": "1.47.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -117,6 +117,9 @@ const ACTION_LABELS: Record<string, string> = {
volunteer_assigned: "Volunteer put on a roster",
volunteer_removed: "Volunteer taken off a roster",
volunteer_checked_in_manual: "Volunteer checked in by hand",
series_created: "Event series created",
series_updated: "Event series changed",
series_deleted: "Event series deleted",
};
interface AuditLogEntry {
+2 -1
View File
@@ -9,7 +9,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck, Inbox,
Baby, HandHeart,
Baby, HandHeart, Layers,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -26,6 +26,7 @@ const REPORTS_URL = "/dashboard/supervisor/reports";
const QUICK_ACTIONS = [
{ href: "/dashboard/admin/users", label: "Manage users", description: "Create, edit, change roles and passwords", icon: Users },
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
{ href: "/dashboard/supervisor/series", label: "Event series", description: "Group events like every year's camp on one page", icon: Layers },
{ href: "/dashboard/admin/registrations", label: "Manage registrations", description: "Cancel, update status, and search registrations", icon: ClipboardList },
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
@@ -13,7 +13,7 @@ import { CampusBadge, CampusFilter, matchesCampus } from "@/components/shared/Ca
import { Calendar, Copy } from "lucide-react";
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
import { errorMessage } from "@/lib/utils";
import type { Event, EventAttachment, EventFormField, EventOption, Paginated, Section } from "@/types";
import type { Event, EventAttachment, EventFormField, EventOption, EventSeries, Paginated, Section } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import {
SessionsEditor, AttendanceSettings, keyed, sortSessions, sessionsProblem, defaultCertificate,
@@ -833,6 +833,7 @@ interface EventDraft {
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
location: string; allowNamedAttendees: boolean;
campusId: string; // "" = all campuses
seriesId: string; // "" = not in a series
requiresParentalConsent: boolean; consentText: string;
consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean;
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
@@ -849,6 +850,7 @@ const blankDraft = (location = ""): EventDraft => ({
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
location, allowNamedAttendees: false,
campusId: "",
seriesId: "",
requiresParentalConsent: false, consentText: "",
consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true,
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
@@ -942,6 +944,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
contactName: base.contactName || "", contactPhone: base.contactPhone || "", contactEmail: base.contactEmail || "",
location: base.location || "",
campusId: base.campusId || "",
seriesId: base.seriesId || "",
scheduleType: base.scheduleType === "sessions" ? "sessions" : "single",
attendanceMode: base.attendanceMode === "pick" ? "pick" : "whole",
} : blankDraft(settings.org_address || ""));
@@ -958,6 +961,13 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
});
}, [mode, campusInfo.scoped, campusInfo.campuses, settings.org_address]);
// Series to choose from (Basic Details).
const [seriesList, setSeriesList] = useState<EventSeries[]>([]);
useEffect(() => {
if (!token) return;
apiFetch<EventSeries[]>("/api/series", { authToken: token }).then(l => setSeriesList(Array.isArray(l) ? l : [])).catch(() => {});
}, [token]);
// ── multi-day schedule + attendance settings ──
// A copy leaves out cancelled sessions and saves the rest as new ones; each keeps the source
// session's id as its key so "late joining until" still points at the same session.
@@ -1394,6 +1404,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
location: draft.location.trim(),
// Only sent while campuses are on; the server ignores it otherwise.
...(campusInfo.enabled ? { campusId: draft.campusId || null } : {}),
seriesId: draft.seriesId || null,
};
let notice: { eventId: string; moved: number; cancelled: number } | null = null;
@@ -1627,6 +1638,14 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
<p className="text-[10px] text-gray-400 mt-0.5">&quot;All campuses&quot; events show at every campus — for church-wide conferences and camps.</p>
</div>
)}
<div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="event-series">Series</label>
<select id="event-series" className="w-full border rounded px-3 py-2 text-sm bg-white" value={draft.seriesId} onChange={e => upd({ seriesId: e.target.value })}>
<option value="">Not in a series</option>
{seriesList.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<p className="text-[10px] text-gray-400 mt-0.5">Groups separate events, like every year&apos;s camp, on one page with &quot;Next in this series&quot;. <a href="/dashboard/supervisor/series" target="_blank" className="underline">Manage series</a></p>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Location</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.location} onChange={e => upd({ location: e.target.value })} placeholder="e.g. 123 Church St, City" />
@@ -1999,6 +2018,7 @@ function EventCard({ ev, onEdit, onCopy }: { ev: Event; onEdit: () => void; onCo
{ev.isHidden && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Hidden</span>}
{isClosed && <span className="text-[10px] px-1.5 py-0.5 rounded bg-rose-50 text-rose-700">Closed</span>}
<CampusBadge campus={ev.campus} />
{ev.series && <span className="text-[10px] px-1.5 py-0.5 rounded bg-brand-50 text-brand-700">{ev.series.name}</span>}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{scheduleSummary(ev) || <>{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` – ${new Date(ev.endDate).toLocaleDateString()}` : ""}</>}
@@ -9,7 +9,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck,
Baby, HandHeart,
Baby, HandHeart, Layers,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -26,6 +26,7 @@ const REPORTS_URL = "/dashboard/supervisor/reports";
const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
{ href: "/dashboard/supervisor/series", label: "Event series", description: "Group events like every year's camp on one page", icon: Layers },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
@@ -0,0 +1,190 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Layers, ExternalLink, Users } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { CampusBadge } from "@/components/shared/CampusFilter";
import type { EventSeries } from "@/types";
type Draft = { id?: string; name: string; slug: string; description: string; picture: string };
const blank: Draft = { name: "", slug: "", description: "", picture: "" };
function SeriesForm({ draft, onChange, onSave, onCancel, saving }: { draft: Draft; onChange: (d: Draft) => void; onSave: () => void; onCancel: () => void; saving: boolean }) {
const upd = (patch: Partial<Draft>) => onChange({ ...draft, ...patch });
return (
<div className="border rounded-xl p-4 bg-gray-50 space-y-3" data-testid="series-form">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="series-name">Name</label>
<input id="series-name" className="w-full border rounded px-3 py-2 text-sm" value={draft.name} onChange={e => upd({ name: e.target.value })} placeholder="e.g. Family Camp" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="series-slug">Web address</label>
<div className="flex items-center text-sm">
<span className="text-gray-400 mr-1">/series/</span>
<input id="series-slug" className="w-full border rounded px-3 py-2 text-sm" value={draft.slug} onChange={e => upd({ slug: e.target.value })} placeholder="made from the name" />
</div>
</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="series-description">Description</label>
<textarea id="series-description" rows={3} className="w-full border rounded px-3 py-2 text-sm" value={draft.description} onChange={e => upd({ description: e.target.value })} placeholder="Shown at the top of the series page" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="series-picture">Picture (web address, optional)</label>
<input id="series-picture" className="w-full border rounded px-3 py-2 text-sm" value={draft.picture} onChange={e => upd({ picture: e.target.value })} placeholder="https://…" />
</div>
<div className="flex gap-2">
<button type="button" onClick={onSave} disabled={saving || !draft.name.trim()} className="px-4 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
{saving ? "Saving…" : draft.id ? "Save series" : "Create series"}
</button>
<button type="button" onClick={onCancel} className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50">Cancel</button>
</div>
</div>
);
}
export default function SeriesPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const isAdmin = user?.role === "admin";
const canView = user?.role === "admin" || user?.role === "supervisor";
useEffect(() => {
if (!loading && !user) router.replace("/login");
}, [user, loading, router]);
const [list, setList] = useState<EventSeries[]>([]);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
if (!token) return;
try {
setList(await apiFetch<EventSeries[]>("/api/series", { authToken: token }));
} catch (e) {
setError(errorMessage(e) || "Couldn't load the series");
}
}, [token]);
useEffect(() => { load(); }, [load]);
const save = async () => {
if (!draft) return;
setSaving(true); setError(null); setNotice(null);
try {
const body = { name: draft.name, slug: draft.slug || undefined, description: draft.description, picture: draft.picture };
await apiFetch(draft.id ? `/api/series/${draft.id}` : "/api/series", { method: draft.id ? "PUT" : "POST", authToken: token || undefined, body });
setNotice(draft.id ? "Series saved." : `"${draft.name.trim()}" created — choose it in the event editor to add events.`);
setDraft(null);
await load();
} catch (e) {
setError(errorMessage(e) || "Couldn't save the series");
} finally {
setSaving(false);
}
};
const remove = async (s: EventSeries) => {
if (!window.confirm(`Delete "${s.name}"? Its ${s.eventCount} event${s.eventCount === 1 ? "" : "s"} stay, just without a series, and ${s.followers} follower${s.followers === 1 ? "" : "s"} won't hear about new ones.`)) return;
setError(null); setNotice(null);
try {
await apiFetch(`/api/series/${s.id}`, { method: "DELETE", authToken: token || undefined });
setNotice(`"${s.name}" deleted.`);
await load();
} catch (e) {
setError(errorMessage(e) || "Couldn't delete the series");
}
};
const now = Date.now();
return (
<div className="max-w-4xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Layers className="w-5 h-5 text-brand-600" />
</div>
<div>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Event series</h1>
<p className="text-sm text-gray-500 mt-0.5">Group events like every year&apos;s camp on one page</p>
</div>
</div>
{!draft && (
<button type="button" className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 shadow-sm font-medium" onClick={() => { setDraft({ ...blank }); setNotice(null); }}>+ New series</button>
)}
</div>
{!canView && !loading && <div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor or admin access.</div>}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{notice && <div className="p-3 mb-3 border rounded bg-green-50 text-green-800 text-sm" role="status">{notice}</div>}
{draft && !draft.id && (
<div className="mb-4"><SeriesForm draft={draft} onChange={setDraft} onSave={save} onCancel={() => setDraft(null)} saving={saving} /></div>
)}
{list.length === 0 ? (
<p className="text-sm text-gray-500 border rounded-xl bg-white p-4">No series yet. A series groups separate events — Camp 2025, Camp 2026 — on one page, with &quot;Next in this series&quot; and followers.</p>
) : (
<ul className="space-y-3">
{list.map(s => (
<li key={s.id} className="border rounded-xl bg-white shadow-sm p-4" data-testid="series-row">
{draft?.id === s.id ? (
<SeriesForm draft={draft} onChange={setDraft} onSave={save} onCancel={() => setDraft(null)} saving={saving} />
) : (
<>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="min-w-0">
<div className="font-medium text-gray-900">{s.name}</div>
<div className="text-xs text-gray-500 mt-0.5 flex items-center gap-3 flex-wrap">
<Link href={`/series/${s.slug}`} target="_blank" className="inline-flex items-center gap-1 text-brand-700 hover:underline">/series/{s.slug} <ExternalLink className="w-3 h-3" /></Link>
<span className="inline-flex items-center gap-1"><Users className="w-3 h-3" /> {s.followers} follower{s.followers === 1 ? "" : "s"}</span>
</div>
{s.description && <p className="text-sm text-gray-600 mt-1 whitespace-pre-line">{s.description}</p>}
</div>
<div className="flex gap-2 shrink-0">
<button type="button" className="text-xs px-3 py-1.5 rounded border bg-white hover:bg-gray-50"
onClick={() => { setDraft({ id: s.id, name: s.name, slug: s.slug, description: s.description || "", picture: s.picture || "" }); setNotice(null); }}>Edit</button>
{isAdmin && <button type="button" className="text-xs px-3 py-1.5 rounded bg-red-50 text-red-700 border border-red-200" onClick={() => remove(s)}>Delete</button>}
</div>
</div>
<div className="mt-3">
{s.events.length === 0 ? (
<p className="text-xs text-gray-500">No events yet — choose this series in the event editor (Basic details).</p>
) : (
<ul className="divide-y border rounded-lg">
{s.events.map(e => (
<li key={e.id} className="px-3 py-2 flex items-center justify-between gap-2 text-sm flex-wrap">
<span className="flex items-center gap-2 min-w-0">
<span className="truncate">{e.title}</span>
{!e.isActive && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Inactive</span>}
{e.isHidden && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Hidden</span>}
{new Date(e.endDate).getTime() < now && <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Past</span>}
<CampusBadge campus={e.campus} />
</span>
<span className="text-xs text-gray-500">{new Date(e.startDate).toLocaleDateString()}</span>
</li>
))}
</ul>
)}
{s.eventCount > s.events.length && (
<p className="text-[11px] text-gray-400 mt-1">{s.eventCount - s.events.length} more at other campuses.</p>
)}
</div>
</>
)}
</li>
))}
</ul>
)}
</div>
);
}
+6 -1
View File
@@ -55,13 +55,16 @@ type Event = {
sessions?: EventSession[];
registrationClosesAt?: string;
lateJoin?: LateJoinInfo | null;
series?: EventSeriesRef | null;
nextInSeries?: NextInSeries | null;
};
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { formatDateTimeRange } from "@/lib/date";
import { BundleOffers } from "@/components/shared/BundleDeals";
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
import type { EventSeriesRef, EventSession, LateJoinInfo, NextInSeries, PublicBundle } from "@/types";
import { SeriesLinks } from "@/components/events/SeriesLinks";
import { isPickOption, latePrice, registrationClosesAt, scheduleSummary } from "@/lib/sessions";
import { SessionSchedule } from "@/components/events/SessionSchedule";
import { appUrl, appName } from "@/lib/siteConfig";
@@ -222,6 +225,8 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
<SeriesLinks series={event.series} next={event.nextInSeries} />
{event.scheduleType === "sessions" && (event.sessions || []).length > 0 && (
<SessionSchedule sessions={event.sessions || []} defaultLocation={event.location} />
)}
+104
View File
@@ -0,0 +1,104 @@
import { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { EventCard } from "@/components/events/EventCard";
import { ApiImage } from "@/components/shared/ApiImage";
import { CampusBadge } from "@/components/shared/CampusFilter";
import { FollowSeriesButton } from "@/components/events/FollowSeriesButton";
import { apiFetch, apiFetchListForPrerender, ApiError } from "@/lib/api";
import { formatDateTimeRange } from "@/lib/date";
import { ArrowLeft, CalendarX, Layers } from "lucide-react";
import type { Event as FullEvent } from "@/types";
export const revalidate = 60;
type Event = React.ComponentProps<typeof EventCard>["event"] & { cashupStatus?: string; nextSession?: { startsAt: string } | null };
type PastEvent = { id: string; title: string; startDate: string; endDate: string; campus?: FullEvent["campus"] };
type Series = { id: string; name: string; slug: string; description: string | null; picture: string | null; past: PastEvent[] };
async function loadSeries(slug: string): Promise<Series | null> {
try {
return await apiFetch<Series>(`/api/series/by-slug/${encodeURIComponent(slug)}`, { nextOptions: { next: { revalidate } } });
} catch (e) {
if (e instanceof ApiError && e.status === 404) return null;
throw e;
}
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
const series = await loadSeries(slug);
if (!series) return {};
return {
title: series.name,
description: series.description || `Every ${series.name} event, past and upcoming.`,
alternates: { canonical: `/series/${series.slug}` },
};
}
/** A series' page: what it is, the upcoming events in it (any campus), and past ones. */
export default async function SeriesPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const series = await loadSeries(slug);
if (!series) notFound();
const events = await apiFetchListForPrerender<Event>(`/api/events?series=${encodeURIComponent(series.slug)}`, { nextOptions: { next: { revalidate } } });
const now = Date.now();
const upcoming = (events || [])
.filter(e => e.cashupStatus !== "closed" && new Date(e.endDate || e.startDate).getTime() > now)
.sort((a, b) => new Date(a.nextSession?.startsAt || a.startDate).getTime() - new Date(b.nextSession?.startsAt || b.startDate).getTime());
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 py-10 px-4 max-w-7xl mx-auto w-full">
<Link href="/events" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4">
<ArrowLeft className="w-4 h-4" /> All events
</Link>
<div className="border rounded-2xl p-6 mb-8 bg-white shadow-sm flex flex-col sm:flex-row gap-6">
{series.picture && <ApiImage src={series.picture} alt="" className="w-full sm:w-56 aspect-video object-cover rounded-xl shrink-0" />}
<div className="min-w-0 flex-1">
<p className="text-xs uppercase tracking-wide text-brand-700 flex items-center gap-1"><Layers className="w-3.5 h-3.5" /> Series</p>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mt-1">{series.name}</h1>
{series.description && <p className="text-gray-700 mt-2 whitespace-pre-line">{series.description}</p>}
<div className="mt-4">
<FollowSeriesButton slug={series.slug} />
</div>
</div>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Coming up</h2>
{upcoming.length === 0 ? (
<div className="text-center py-12 border rounded-xl bg-gray-50 mb-10">
<CalendarX className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-gray-500 text-sm">Nothing planned yet — follow the series to hear when the next one is out.</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-3 sm:grid-cols-2 mb-10">
{upcoming.map(event => <EventCard key={event.id} event={event} />)}
</div>
)}
{series.past.length > 0 && (
<>
<h2 className="text-xl font-semibold text-gray-900 mb-3">Past events</h2>
<ul className="border rounded-xl bg-white divide-y" data-testid="series-past">
{series.past.map(e => (
<li key={e.id} className="px-4 py-3 flex flex-wrap items-center justify-between gap-2">
<span className="font-medium text-gray-800">{e.title}</span>
<span className="text-sm text-gray-500 flex items-center gap-2">
<CampusBadge campus={e.campus} className="text-[10px]" />
{formatDateTimeRange(e.startDate, e.endDate)}
</span>
</li>
))}
</ul>
</>
)}
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,61 @@
"use client";
import { useEffect, useState } from "react";
import { Bell, BellOff } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
/** Follow / unfollow a series. Signed out, it links to the login page and back. */
export function FollowSeriesButton({ slug }: { slug: string }) {
const { user, token, loading } = useAuth();
const [following, setFollowing] = useState<boolean | null>(null);
const [seriesId, setSeriesId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// The page itself is cached for everyone, so whether this person follows (and the series as it
// is now) is asked separately.
useEffect(() => {
if (!token) return;
apiFetch<{ id: string; following: boolean }>(`/api/series/by-slug/${encodeURIComponent(slug)}`, { authToken: token })
.then(s => { setSeriesId(s.id); setFollowing(!!s.following); })
.catch(() => setFollowing(null));
}, [slug, token]);
if (loading) return null;
if (!user) {
return (
<a href={`/login?redirect=/series/${slug}`} className="inline-flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50">
<Bell className="w-4 h-4" /> Log in to follow
</a>
);
}
const toggle = async () => {
setBusy(true); setError(null);
try {
const res = await apiFetch<{ following: boolean }>(`/api/series/${seriesId}/follow`, { method: following ? "DELETE" : "POST", authToken: token || undefined });
setFollowing(res.following);
} catch (e) {
setError(errorMessage(e) || "Couldn't change that");
} finally {
setBusy(false);
}
};
return (
<div>
<button type="button" onClick={toggle} disabled={busy || following === null || !seriesId}
className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg font-medium disabled:opacity-50 ${following ? "border bg-white text-gray-700 hover:bg-gray-50" : "bg-brand-600 text-white hover:bg-brand-700"}`}>
{following ? <><BellOff className="w-4 h-4" /> Unfollow</> : <><Bell className="w-4 h-4" /> Follow this series</>}
</button>
<p className="text-xs text-gray-500 mt-1.5" role="status">
{following
? "You'll get an email when a new event in this series is out (if you get news and announcements from us)."
: "Get an email when the next one is out."}
</p>
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
</div>
);
}
@@ -0,0 +1,27 @@
import Link from "next/link";
import { ArrowRight, Layers } from "lucide-react";
import { formatDateTimeRange } from "@/lib/date";
import type { EventSeriesRef, NextInSeries } from "@/types";
/** On an event page: the series it belongs to, and the next event in that series. */
export function SeriesLinks({ series, next }: { series?: EventSeriesRef | null; next?: NextInSeries | null }) {
if (!series) return null;
return (
<div className="border rounded-xl p-4 bg-white shadow-sm space-y-2" data-testid="event-series">
<p className="text-sm text-gray-600 flex items-center gap-1.5">
<Layers className="w-4 h-4 shrink-0 text-brand-600" />
Part of <Link href={`/series/${series.slug}`} className="font-medium text-brand-700 hover:underline">{series.name}</Link>
</p>
{next && (
<Link href={`/events/${next.id}`} className="flex items-center justify-between gap-3 rounded-lg bg-brand-50 px-3 py-2 hover:bg-brand-100" data-testid="next-in-series">
<span className="min-w-0">
<span className="block text-[11px] uppercase tracking-wide text-brand-700">Next in this series</span>
<span className="block text-sm font-medium text-gray-900 truncate">{next.title}</span>
<span className="block text-xs text-gray-500">{formatDateTimeRange(next.startDate, next.endDate)}</span>
</span>
<ArrowRight className="w-4 h-4 shrink-0 text-brand-700" />
</Link>
)}
</div>
);
}
+6
View File
@@ -36,6 +36,7 @@ import { staffTicketScanningHelpContent } from "./staff-ticket-scanning";
import { userFormsHelpContent } from "./user-forms";
import { kidsDeskHelpContent, kidsRoomsHelpContent, kidsSetupHelpContent, userKidsHelpContent } from "./kids";
import { staffVolunteersHelpContent, userServingHelpContent, volunteerRostersHelpContent } from "./volunteers";
import { seriesHelpContent } from "./series";
type HelpRegistryEntry = {
/** Unique, stable identifier for this guide. */
@@ -182,6 +183,11 @@ const REGISTRY: HelpRegistryEntry[] = [
matches: pathname => pathname === "/dashboard/user/kids",
content: userKidsHelpContent,
},
{
slug: "supervisor-series",
matches: pathname => pathname === "/dashboard/supervisor/series",
content: seriesHelpContent,
},
{
slug: "supervisor-volunteers",
matches: pathname => pathname === "/dashboard/supervisor/volunteers",
+40
View File
@@ -0,0 +1,40 @@
import React from "react";
import { Layers, Calendar, Bell, MapPin, Copy } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for /dashboard/supervisor/series. */
export const seriesHelpContent: HelpContent = {
title: "Event series",
subtitle: "Group separate events — every year's camp, the monthly breakfast — on one page.",
tabs: [
{
key: "overview",
label: "Overview",
icon: Layers,
content: (
<div className="space-y-4">
<p>A series groups events that each have their own registrations, like Camp 2025 and Camp 2026. It isn&apos;t the same as a multi-session event, which is one event on several dates with one ticket.</p>
<div className="space-y-4">
<GuideItem icon={Layers} title="Make a series" tone="brand">
&quot;+ New series&quot;: a name, the web address of its page (<code>/series/…</code>), and optionally a description and a picture.
</GuideItem>
<GuideItem icon={Calendar} title="Put events in it" tone="green">
In the event editor (Basic details) choose the <strong>Series</strong>. Copying an event keeps its series, so next year&apos;s copy joins it straight away.
</GuideItem>
<GuideItem icon={Copy} title="What people see" tone="violet">
The series page lists what&apos;s coming up and past events. Each event in a series links to its series and to the next event in it (&quot;Next in this series&quot;). Hidden, inactive and not-yet-live events are left out.
</GuideItem>
<GuideItem icon={Bell} title="Following" tone="amber">
People with an account can follow a series on its page. When a new event in the series goes live (and before it starts) followers get one email. It counts as <strong>News &amp; announcements</strong>, so only people who chose to get news receive it, with an unsubscribe link.
</GuideItem>
<GuideItem icon={MapPin} title="Campuses" tone="gray">
A series can have events at different campuses. Supervisors limited to some campuses see the series, but only their campuses&apos; events in it.
</GuideItem>
</div>
<p className="text-xs text-gray-500">Deleting a series (admins) keeps its events — they just aren&apos;t in a series any more — and stops it for its followers.</p>
</div>
),
},
],
};
+16
View File
@@ -178,6 +178,10 @@ export type Event = {
sessionReminders?: { offsets: number[]; channels: ("email" | "whatsapp")[] } | null;
certificatesEnabled?: boolean;
certificateConfig?: CertificateConfig | null;
// Event series (1.47.1): null = not in a series. `nextInSeries` on single-event responses.
seriesId?: string | null;
series?: EventSeriesRef | null;
nextInSeries?: NextInSeries | null;
// Staff-only tickets and rooms (kids check-in 1.45.0, volunteer rosters 1.46.0).
kidsOptionId?: string | null;
kidsRoomSetId?: string | null;
@@ -187,6 +191,18 @@ export type Event = {
isSoldOut?: boolean;
};
export type EventSeriesRef = { id: string; name: string; slug: string };
export type NextInSeries = { id: string; title: string; startDate: string; endDate: string; location?: string | null };
/** GET /api/series (supervisors): a series with its events and number of followers. */
export type EventSeries = EventSeriesRef & {
description: string | null;
picture: string | null;
followers: number;
eventCount: number;
events: { id: string; title: string; startDate: string; endDate: string; isActive: boolean; isHidden: boolean; campus?: Event["campus"] }[];
};
export type EventSession = {
id: string;
eventId?: string;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.47.0",
"version": "1.47.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.47.0",
"version": "1.47.1",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.47.0",
"version": "1.47.1",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",