Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
740897635f |
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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,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;
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
@@ -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\/?$/],
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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,
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Generated
+2
-2
@@ -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,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 {
|
||||
|
||||
@@ -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">"All campuses" 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's camp, on one page with "Next in this series". <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'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 "Next in this series" 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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'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">
|
||||
"+ New series": 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's copy joins it straight away.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Copy} title="What people see" tone="violet">
|
||||
The series page lists what's coming up and past events. Each event in a series links to its series and to the next event in it ("Next in this series"). 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 & 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' events in it.
|
||||
</GuideItem>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Deleting a series (admins) keeps its events — they just aren't in a series any more — and stops it for its followers.</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user