Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5d615e398 | ||
|
|
0253a1d5fa | ||
|
|
740897635f | ||
|
|
7ae0b6d23b |
@@ -7,6 +7,41 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.47.2] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Series: year on year** report (Reports → Other) — pick a series and see each of its events side
|
||||
by side, oldest first: registrations (not cancelled), tickets, income (as on the dashboard: refunds
|
||||
counted, donations applied to orders left out) and people checked in (a multi-session event shows
|
||||
the average per session), with the attendance rate and the change from the event before. Bar charts
|
||||
of registrations and income; exports to PDF, Excel, email and WhatsApp like the other reports.
|
||||
Campus-scoped staff see only their campuses' events. **Compare** on each series on the Event series
|
||||
page opens it (`?report=seriesComparison&series=<id>`).
|
||||
- `GET /api/series/:id/compare`.
|
||||
|
||||
## [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
|
||||
|
||||
+32
-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)
|
||||
|
||||
---
|
||||
|
||||
@@ -508,6 +509,7 @@ a viewer with its own filters, stat tiles and bar charts.
|
||||
| Ticketing | Ticket usage summary |
|
||||
| Donations | Donations breakdown (used / unused per event, plus the sponsorship pool) |
|
||||
| Cashup | Cashup reconciliation · Cashup audit trail |
|
||||
| Other | Series: year on year (a series' events side by side — registrations, tickets, income, checked in, attendance rate, change from the one before) |
|
||||
|
||||
**Export:** Print, branded PDF download, styled Excel (`.xlsx`) workbook,
|
||||
Email (to yourself), or WhatsApp (the PDF to your own number). Dashboard tiles
|
||||
@@ -539,6 +541,25 @@ 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.
|
||||
- **Year on year**: Reports → Series: year on year (or **Compare** on the Event series page)
|
||||
compares the series' events — registrations, tickets, income, checked in (average per session on
|
||||
multi-session events), attendance rate — each against the one before.
|
||||
|
||||
## Multi-campus
|
||||
|
||||
For a church with several campuses in one install (Site settings → Campuses, off by
|
||||
@@ -762,6 +783,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 +838,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,20 @@ 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.2
|
||||
|
||||
- **Compare a series year on year.** A new report puts the events in a series side by side —
|
||||
registrations, tickets, income and how many came — with how much each went up or down from the
|
||||
one before. Open it from Reports, or with **Compare** on the Event series page.
|
||||
|
||||
## 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,22 @@ 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 |
|
||||
| GET | `/api/series/:id/compare` | supervisor+ | Year on year: `{ series, events: [{ eventId, title, startDate, registrations, tickets, income, checkedIn, attendanceRate, sessions, change }] }`, oldest first; `change` is % against the event before. Scoped staff: their campuses' events only |
|
||||
|
||||
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.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.47.0",
|
||||
"version": "1.47.2",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.47.0",
|
||||
"version": "1.47.2",
|
||||
"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,118 @@
|
||||
const prisma = require('../config/db');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { cleanSeriesInput, publicEventWhere, splitEvents, follow, unfollow, compareSeries } = require('../utils/series');
|
||||
const { scopeFor, canView, eventWhere } = 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); }
|
||||
};
|
||||
|
||||
// @desc Year-on-year: registrations, tickets, income and attendance per event in the series
|
||||
// (the events this person may see), oldest first, with the change from the one before
|
||||
// @route GET /api/series/:id/compare
|
||||
// @access Private/Supervisor
|
||||
const compare = async (req, res) => {
|
||||
try {
|
||||
res.json(await compareSeries(req.params.id, { scopeWhere: eventWhere(await scopeFor(req)) }));
|
||||
} 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, compare };
|
||||
@@ -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,18 @@ 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' },
|
||||
{ method:'GET', path:'/api/series/:id/compare', auth:'supervisor+', desc:'Year on year: registrations, tickets, income (as on the dashboard), checked in (average per session on multi-session events) and attendance rate per event in the series, oldest first, with the % change from the one before. Campus-scoped staff: their campuses\x27 events only' },
|
||||
]},
|
||||
|
||||
{ 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,21 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries, compare } = require('../controllers/seriesController');
|
||||
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent, scopeList } = 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);
|
||||
// Year-on-year report: only the events the person may see (filtered in the controller).
|
||||
router.get('/:id/compare', protect, supervisor, scopeList, compare);
|
||||
|
||||
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,240 @@
|
||||
/**
|
||||
* 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 }) };
|
||||
}
|
||||
|
||||
// ─── Year-on-year comparison (1.47.2) ────────────────────────────────────────
|
||||
|
||||
// The same "real income" as the dashboard's top events: everything except the legs that apply an
|
||||
// earlier donation to an order (not new money). Refunds count, as negatives.
|
||||
const EXCLUDE_DONATION_LEGS = {
|
||||
NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] },
|
||||
};
|
||||
|
||||
const pct = (now, before) => (before > 0 ? Math.round(((now - before) / before) * 100) : null);
|
||||
|
||||
/**
|
||||
* Adds `change` to each row against the row before it (rows oldest first): the percentage up or
|
||||
* down for registrations, tickets, income and checked in, null when there's nothing to compare.
|
||||
* Pure, for tests.
|
||||
*/
|
||||
function withChanges(rows) {
|
||||
return rows.map((r, i) => {
|
||||
const prev = rows[i - 1];
|
||||
return {
|
||||
...r,
|
||||
change: prev
|
||||
? { registrations: pct(r.registrations, prev.registrations), tickets: pct(r.tickets, prev.tickets), income: pct(r.income, prev.income), checkedIn: pct(r.checkedIn, prev.checkedIn) }
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrations, tickets, income and attendance for each event in a series that the person may
|
||||
* see (`scopeWhere`, from campusScope.eventWhere), oldest first, with the change from the one
|
||||
* before. Checked in: check-ins on a single-date event; on a multi-session event, the average per
|
||||
* session that wasn't cancelled.
|
||||
*/
|
||||
async function compareSeries(seriesId, { scopeWhere = {} } = {}, db = prisma) {
|
||||
const series = await db.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true, name: true, slug: true } });
|
||||
if (!series) throw seriesError('Series not found', 404);
|
||||
const events = await db.event.findMany({
|
||||
where: { AND: [{ seriesId }, scopeWhere] },
|
||||
orderBy: { startDate: 'asc' },
|
||||
select: {
|
||||
id: true, title: true, startDate: true, endDate: true, scheduleType: true, isActive: true,
|
||||
campus: { select: { id: true, name: true, slug: true, colour: true } },
|
||||
_count: { select: { sessions: { where: { cancelledAt: null } } } },
|
||||
},
|
||||
});
|
||||
const ids = events.map(e => e.id);
|
||||
const [regs, tickets, income, usages] = ids.length ? await Promise.all([
|
||||
db.registration.groupBy({ by: ['eventId'], where: { eventId: { in: ids }, status: { not: 'cancelled' } }, _count: { _all: true } }),
|
||||
db.ticket.groupBy({ by: ['eventId'], where: { eventId: { in: ids } }, _sum: { quantity: true } }),
|
||||
db.payment.groupBy({ by: ['eventId'], where: { ...EXCLUDE_DONATION_LEGS, eventId: { in: ids } }, _sum: { amount: true } }),
|
||||
// People checked in (a group scan can let several in at once), per event through the ticket.
|
||||
Promise.all(ids.map(async eventId => ({ eventId, n: (await db.ticketUsage.aggregate({ where: { ticket: { eventId } }, _sum: { quantityRedeemed: true } }))._sum.quantityRedeemed || 0 }))),
|
||||
]) : [[], [], [], []];
|
||||
const map = (rows, pick) => new Map(rows.map(r => [r.eventId, pick(r)]));
|
||||
const regBy = map(regs, r => r._count._all);
|
||||
const ticketBy = map(tickets, r => r._sum.quantity || 0);
|
||||
const incomeBy = map(income, r => r._sum.amount || 0);
|
||||
const usageBy = map(usages, r => r.n);
|
||||
|
||||
const rows = events.map(e => {
|
||||
const scans = usageBy.get(e.id) || 0;
|
||||
const sessions = e.scheduleType === 'sessions' ? e._count.sessions : 0;
|
||||
const t = ticketBy.get(e.id) || 0;
|
||||
const checkedIn = sessions > 0 ? Math.round(scans / sessions) : scans;
|
||||
return {
|
||||
eventId: e.id, title: e.title, startDate: e.startDate, endDate: e.endDate, campus: e.campus, isActive: e.isActive,
|
||||
sessions,
|
||||
registrations: regBy.get(e.id) || 0,
|
||||
tickets: t,
|
||||
income: Math.round((incomeBy.get(e.id) || 0) * 100) / 100,
|
||||
checkedIn,
|
||||
attendanceRate: t > 0 ? Math.min(100, Math.round((checkedIn / t) * 100)) : null,
|
||||
};
|
||||
});
|
||||
return { series, events: withChanges(rows) };
|
||||
}
|
||||
|
||||
// ─── 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, withChanges, compareSeries,
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
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('year-on-year comparison', () => {
|
||||
test('each event against the one before it', () => {
|
||||
const rows = series.withChanges([
|
||||
{ registrations: 40, tickets: 50, income: 10000, checkedIn: 45 },
|
||||
{ registrations: 50, tickets: 50, income: 9000, checkedIn: 0 },
|
||||
{ registrations: 25, tickets: 60, income: 9000, checkedIn: 30 },
|
||||
]);
|
||||
expect(rows[0].change).toBeNull();
|
||||
expect(rows[1].change).toEqual({ registrations: 25, tickets: 0, income: -10, checkedIn: -100 });
|
||||
// Nothing to compare with after a zero.
|
||||
expect(rows[2].change).toEqual({ registrations: -50, tickets: 20, income: 0, checkedIn: null });
|
||||
});
|
||||
|
||||
test('counts per event, check-ins averaged per session, only events in scope', async () => {
|
||||
const db = {
|
||||
eventSeries: { findUnique: jest.fn().mockResolvedValue({ id: 's1', name: 'Family Camp', slug: 'family-camp' }) },
|
||||
event: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 'e25', title: 'Camp 2025', startDate: new Date('2025-03-01'), endDate: new Date('2025-03-04'), scheduleType: 'single', isActive: true, campus: null, _count: { sessions: 0 } },
|
||||
{ id: 'e26', title: 'Camp 2026', startDate: new Date('2026-03-01'), endDate: new Date('2026-03-04'), scheduleType: 'sessions', isActive: true, campus: null, _count: { sessions: 3 } },
|
||||
]),
|
||||
},
|
||||
registration: { groupBy: jest.fn().mockResolvedValue([{ eventId: 'e25', _count: { _all: 40 } }, { eventId: 'e26', _count: { _all: 50 } }]) },
|
||||
ticket: { groupBy: jest.fn().mockResolvedValue([{ eventId: 'e25', _sum: { quantity: 50 } }, { eventId: 'e26', _sum: { quantity: 60 } }]) },
|
||||
payment: { groupBy: jest.fn().mockResolvedValue([{ eventId: 'e25', _sum: { amount: 10000 } }, { eventId: 'e26', _sum: { amount: 12000.456 } }]) },
|
||||
ticketUsage: { aggregate: jest.fn(({ where }) => Promise.resolve({ _sum: { quantityRedeemed: where.ticket.eventId === 'e25' ? 45 : 150 } })) },
|
||||
};
|
||||
const scopeWhere = { OR: [{ campusId: { in: ['north'] } }, { campusId: null }] };
|
||||
const out = await series.compareSeries('s1', { scopeWhere }, db);
|
||||
expect(db.event.findMany.mock.calls[0][0].where).toEqual({ AND: [{ seriesId: 's1' }, scopeWhere] });
|
||||
expect(out.events.map(e => [e.title, e.registrations, e.tickets, e.income, e.checkedIn, e.attendanceRate])).toEqual([
|
||||
['Camp 2025', 40, 50, 10000, 45, 90],
|
||||
['Camp 2026', 50, 60, 12000.46, 50, 83], // 150 check-ins over 3 sessions
|
||||
]);
|
||||
expect(out.events[1].change).toEqual({ registrations: 25, tickets: 20, income: 20, checkedIn: 11 });
|
||||
});
|
||||
|
||||
test('an unknown series is a 404', async () => {
|
||||
const db = { eventSeries: { findUnique: jest.fn().mockResolvedValue(null) } };
|
||||
await expect(series.compareSeries('nope', {}, db)).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+20
-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.2** (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,8 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.47.2 | **Series: year on year** report (§6 part 3): `GET /api/series/:id/compare` (`compareSeries` in `utils/series.js`), report `seriesComparison` in `ReportsV2`, "Compare" on the Event series page |
|
||||
| 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 +50,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. **1.47.3 season pass** (§6: a bundle across a series' events, extending event bundles) — then
|
||||
**1.48.0 — outbound webhooks** (§8).
|
||||
|
||||
## Working on this repo
|
||||
|
||||
@@ -297,6 +299,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
|
||||
|
||||
+9
-7
@@ -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) and 1.47.2 **year-on-year
|
||||
report** shipped. **Next: 1.47.3 Season pass.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -470,10 +470,12 @@ 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.**
|
||||
- **Year-on-year reports (1.47.2):** registrations, income and attendance compared
|
||||
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.2 ✅ – 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,75 @@
|
||||
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);
|
||||
});
|
||||
|
||||
test("year on year: the series report compares its events, oldest first", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/series");
|
||||
await page.getByTestId("series-row").filter({ hasText: "E2E Breakfast" }).getByRole("link", { name: "Compare" }).click();
|
||||
const rows = page.getByTestId("comparison-row");
|
||||
await expect(rows).toHaveCount(3);
|
||||
await expect(rows.first()).toContainText("E2E Breakfast (last month)");
|
||||
await expect(rows.last()).toContainText("E2E Breakfast (in two months)");
|
||||
});
|
||||
});
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.47.0",
|
||||
"version": "1.47.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.47.0",
|
||||
"version": "1.47.2",
|
||||
"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.2",
|
||||
"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,195 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Layers, ExternalLink, Users, BarChart3 } 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">
|
||||
{s.events.length > 0 && (
|
||||
<Link href={`/dashboard/supervisor/reports?report=seriesComparison&series=${s.id}`} className="inline-flex items-center gap-1 text-xs px-3 py-1.5 rounded border bg-white hover:bg-gray-50">
|
||||
<BarChart3 className="w-3.5 h-3.5" /> Compare
|
||||
</Link>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
HeartPulse,
|
||||
MessageSquareHeart,
|
||||
Filter,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
|
||||
export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other";
|
||||
@@ -42,6 +43,7 @@ export const REPORTS = [
|
||||
{ key: "funnel", label: "Registration funnel", description: "Event page views → started → submitted → paid, with trend and sources", category: "Registration", icon: Filter, fields: 5 },
|
||||
{ key: "feedbackSummary", label: "Feedback summary", description: "Post-event feedback: ratings, choices and comments; compare events", category: "Registration", icon: MessageSquareHeart, fields: 4 },
|
||||
{ key: "medicalList", label: "Medical & emergency list", description: "Youth events: confidential list for leaders (audited)", category: "Registration", icon: HeartPulse, fields: 5 },
|
||||
{ key: "seriesComparison", label: "Series: year on year", description: "Registrations, income and attendance compared across the events in a series", category: "Other", icon: Layers, fields: 6 },
|
||||
{ key: "discounts", label: "Discounts (codes & bundles)", description: "Uses and value given away per code, bundle deal and event", category: "Financial", icon: TicketPercent, fields: 4 },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
key: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
|
||||
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck, Star, MessageSquareHeart, Send, Eye, MousePointerClick, BadgeCheck } from "lucide-react";
|
||||
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck, Star, MessageSquareHeart, Send, Eye, MousePointerClick, BadgeCheck, Users, Ticket as TicketIcon } from "lucide-react";
|
||||
import { REPORTS, type ReportKey } from "./ReportCatalog";
|
||||
import ReportsShell from "./ReportsShell";
|
||||
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
|
||||
@@ -15,7 +15,7 @@ import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { registrationOptionUnitPrice } from "@/lib/pricing";
|
||||
import { CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
|
||||
import type { ConsentRow, Event, EventCashup, EventFinancials, MedicalRow, Payment, Registration, Ticket, UserSummary } from "@/types";
|
||||
import type { ConsentRow, Event, EventCashup, EventFinancials, EventSeries, MedicalRow, Payment, Registration, SeriesComparison, Ticket, UserSummary } from "@/types";
|
||||
|
||||
const METHOD_KEYS = ["cash", "card", "eft", "other"] as const;
|
||||
const METHOD_LABEL: Record<string, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
|
||||
@@ -147,6 +147,9 @@ type FunnelReport = {
|
||||
perEvent?: { eventId: string; title: string; totals: FunnelCounts; conversion: FunnelConversion }[];
|
||||
};
|
||||
|
||||
/** "+12%", "−5%", "no change" — a year-on-year change. */
|
||||
const changeLabel = (c: number) => (c > 0 ? `+${c}%` : c < 0 ? `−${Math.abs(c)}%` : "no change");
|
||||
|
||||
// Simple MultiSelect control used in filters
|
||||
export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
const { token, user } = useAuth();
|
||||
@@ -241,6 +244,18 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
const [feedbackRows, setFeedbackRows] = useState<FeedbackEventSummary[]>([]);
|
||||
// Registration funnel for the selected events (null until viewed).
|
||||
const [funnel, setFunnel] = useState<FunnelReport | null>(null);
|
||||
// Series: year on year — picked by series, not by the events selection.
|
||||
const [seriesList, setSeriesList] = useState<EventSeries[]>([]);
|
||||
const [compareSeriesId, setCompareSeriesId] = useState<string>("");
|
||||
const [comparison, setComparison] = useState<SeriesComparison | null>(null);
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<EventSeries[]>("/api/series", { authToken: token }).then(l => {
|
||||
const list = Array.isArray(l) ? l : [];
|
||||
setSeriesList(list);
|
||||
setCompareSeriesId(id => id || list[0]?.id || "");
|
||||
}).catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
// Deep-linking: dashboards link here with ?report=<key>&range=trailing_month to jump
|
||||
// straight into a specific report, pre-filtered to every currently-visible event and the
|
||||
@@ -259,6 +274,8 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
deepLinkHandledRef.current = true;
|
||||
setReport(reportParam as ReportKey);
|
||||
setSelectedEventIds(filteredEvents.map((e) => e.id));
|
||||
const seriesParam = searchParams.get("series");
|
||||
if (seriesParam) setCompareSeriesId(seriesParam);
|
||||
if (searchParams.get("range") === "trailing_month") {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30);
|
||||
@@ -527,6 +544,10 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
setFeedbackRows(await loadFeedback(selectedEventIds));
|
||||
} else if (report === "medicalList") {
|
||||
setMedicalRows(await loadConsent(selectedEventIds, "medical"));
|
||||
} else if (report === "seriesComparison") {
|
||||
setComparison(compareSeriesId && token
|
||||
? await apiFetch<SeriesComparison>(`/api/series/${encodeURIComponent(compareSeriesId)}/compare`, { authToken: token }).catch(() => null)
|
||||
: null);
|
||||
}
|
||||
setReady(true);
|
||||
} finally {
|
||||
@@ -959,6 +980,24 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
// mirror exactly what the on-screen report shows above its table, computed the same way.
|
||||
const buildReportPayload = (): ReportPdfPayload | null => {
|
||||
const subtitle = `Generated ${new Date().toLocaleString()}`;
|
||||
if (report === "seriesComparison") {
|
||||
if (!comparison) return null;
|
||||
const withChange = (v: string, c: number | null | undefined) => (c == null ? v : `${v} (${changeLabel(c)})`);
|
||||
return {
|
||||
title: `${comparison.series.name}: year on year`, subtitle, kind: "table", orientation: "landscape",
|
||||
note: "Each change is against the event before. Checked in on a multi-session event is the average per session. Income leaves out donations applied to orders (counted when they were given).",
|
||||
...(comparison.events.length > 1 ? { chart: { title: "Income, by event", data: comparison.events.map(e => ({ label: e.title, value: e.income, displayValue: `R ${e.income.toFixed(2)}` })) } } : {}),
|
||||
table: {
|
||||
columns: ["Event", "Date", "Registrations", "Tickets", "Income", "Checked in", "Attendance"],
|
||||
rows: comparison.events.map(e => [
|
||||
e.title, new Date(e.startDate).toLocaleDateString(),
|
||||
withChange(String(e.registrations), e.change?.registrations), withChange(String(e.tickets), e.change?.tickets),
|
||||
withChange(`R ${e.income.toFixed(2)}`, e.change?.income), withChange(String(e.checkedIn), e.change?.checkedIn),
|
||||
e.attendanceRate != null ? `${e.attendanceRate}%` : "—",
|
||||
]),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (report === "payments") {
|
||||
return {
|
||||
title: 'Payments Report', subtitle, kind: 'table', orientation: 'portrait',
|
||||
@@ -1372,6 +1411,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
discounts: "Discounts Report",
|
||||
consentStatus: "Parental Consent Status",
|
||||
medicalList: "Medical & Emergency List",
|
||||
seriesComparison: "Series: year on year",
|
||||
};
|
||||
|
||||
const doPrintPdf = async () => {
|
||||
@@ -1412,7 +1452,18 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
// Report-specific filters shown inside the popup — whatever each report had beyond the
|
||||
// universal Events/Date range, which now live in the sidebar (ReportsShell) instead. `null`
|
||||
// when a report has none, so the modal chrome shows its own fallback note.
|
||||
const reportSpecificFilters = report === "attendees" ? (
|
||||
const reportSpecificFilters = report === "seriesComparison" ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-sm">
|
||||
<span className="mr-2">Series</span>
|
||||
<select className="border rounded px-2 py-1 text-sm" value={compareSeriesId} onChange={e => setCompareSeriesId(e.target.value)} aria-label="Series">
|
||||
{seriesList.length === 0 && <option value="">No series yet</option>}
|
||||
{seriesList.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<span className="text-xs text-gray-500">Press refresh after changing the series. The events selection on the left doesn't apply.</span>
|
||||
</div>
|
||||
) : report === "attendees" ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-sm">
|
||||
<span className="mr-2">Event</span>
|
||||
@@ -1869,6 +1920,60 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{report === "seriesComparison" && (
|
||||
<div data-testid="series-comparison">
|
||||
{!comparison ? (
|
||||
<div className="text-sm text-gray-500">{seriesList.length === 0 ? "No series yet — make one on the Event series page and put events in it." : "Choose a series above."}</div>
|
||||
) : comparison.events.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">{comparison.series.name} has no events yet (or none at your campuses).</div>
|
||||
) : (() => {
|
||||
const last = comparison.events[comparison.events.length - 1];
|
||||
const change = (c: number | null | undefined) => c == null ? null : <span className={`ml-1 text-xs ${c > 0 ? "text-green-700" : c < 0 ? "text-rose-700" : "text-gray-500"}`}>{changeLabel(c)}</span>;
|
||||
return (
|
||||
<>
|
||||
<StatTileRow>
|
||||
<StatTile icon={Users} label={`Registrations, ${last.title}`} value={`${last.registrations}${last.change?.registrations != null ? ` (${changeLabel(last.change.registrations)})` : ""}`} tone="violet" />
|
||||
<StatTile icon={TrendingUp} label="Income" value={`R ${last.income.toFixed(2)}${last.change?.income != null ? ` (${changeLabel(last.change.income)})` : ""}`} tone="green" />
|
||||
<StatTile icon={TicketIcon} label="Attendance" value={last.attendanceRate != null ? `${last.attendanceRate}%` : "—"} tone="blue" />
|
||||
</StatTileRow>
|
||||
{comparison.events.length > 1 && (
|
||||
<div className="mt-4 grid md:grid-cols-2 gap-4">
|
||||
<div className="border border-gray-100 rounded-xl p-4">
|
||||
<div className="text-xs text-gray-500 mb-3">Registrations, by event</div>
|
||||
<HorizontalBarChart data={comparison.events.map(e => ({ label: e.title, value: e.registrations }))} valueFormatter={v => String(v)} />
|
||||
</div>
|
||||
<div className="border border-gray-100 rounded-xl p-4">
|
||||
<div className="text-xs text-gray-500 mb-3">Income, by event</div>
|
||||
<HorizontalBarChart data={comparison.events.map(e => ({ label: e.title, value: e.income }))} valueFormatter={v => `R ${v.toFixed(2)}`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-xs text-gray-500 border-b">
|
||||
<th className="py-2 pr-3">Event</th><th className="py-2 pr-3">Date</th><th className="py-2 pr-3">Registrations</th><th className="py-2 pr-3">Tickets</th><th className="py-2 pr-3">Income</th><th className="py-2 pr-3">Checked in</th><th className="py-2">Attendance</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{comparison.events.map(e => (
|
||||
<tr key={e.eventId} className="border-b last:border-0" data-testid="comparison-row">
|
||||
<td className="py-2 pr-3 font-medium">{e.title}{e.campus && <span className="ml-1 text-[10px] text-gray-500">· {e.campus.name}</span>}</td>
|
||||
<td className="py-2 pr-3 text-gray-600">{new Date(e.startDate).toLocaleDateString()}</td>
|
||||
<td className="py-2 pr-3">{e.registrations}{change(e.change?.registrations)}</td>
|
||||
<td className="py-2 pr-3">{e.tickets}{change(e.change?.tickets)}</td>
|
||||
<td className="py-2 pr-3">R {e.income.toFixed(2)}{change(e.change?.income)}</td>
|
||||
<td className="py-2 pr-3">{e.checkedIn}{e.sessions > 0 && <span className="text-[10px] text-gray-500"> avg/session</span>}{change(e.change?.checkedIn)}</td>
|
||||
<td className="py-2">{e.attendanceRate != null ? `${e.attendanceRate}%` : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">Each change is against the event before. Income leaves out donations applied to orders (they count when they were given).</p>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{report === "feedbackSummary" && (
|
||||
<div>
|
||||
{feedbackSummary.perEvent.length === 0 ? (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -29,7 +29,7 @@ export const reportsHelpContent: HelpContent = {
|
||||
Preview your report, adjust filters, and choose how you want the data to appear.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Download} title="Export or email" tone="amber">
|
||||
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026). The Registration funnel shows where people drop off between viewing an event and paying, by source and device. The source comes from short links and utm_source links, else the site the visitor came from (Facebook, Instagram, Google…). The event page's Share and Save QR buttons tag their links as "share" and "qr". WhatsApp doesn't say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as "direct". It counts only visitors who allowed cookies.
|
||||
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026), or use Series: year on year to compare a whole series' registrations, income and attendance. The Registration funnel shows where people drop off between viewing an event and paying, by source and device. The source comes from short links and utm_source links, else the site the visitor came from (Facebook, Instagram, Google…). The event page's Share and Save QR buttons tag their links as "share" and "qr". WhatsApp doesn't say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as "direct". It counts only visitors who allowed cookies.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { Layers, Calendar, Bell, MapPin, Copy, BarChart3 } 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={BarChart3} title="Year on year" tone="green">
|
||||
"Compare" opens Reports → Series: year on year — each event in the series side by side (registrations, tickets, income, how many came) with how much it went up or down from the one before.
|
||||
</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,27 @@ 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"] }[];
|
||||
};
|
||||
|
||||
/** GET /api/series/:id/compare — one row per event in the series, oldest first. */
|
||||
export type SeriesComparisonRow = {
|
||||
eventId: string; title: string; startDate: string; endDate: string; campus?: Event["campus"]; isActive: boolean;
|
||||
sessions: number; registrations: number; tickets: number; income: number; checkedIn: number; attendanceRate: number | null;
|
||||
// % up or down from the event before (null on the first, or when the one before had none).
|
||||
change: { registrations: number | null; tickets: number | null; income: number | null; checkedIn: number | null } | null;
|
||||
};
|
||||
export type SeriesComparison = { series: EventSeriesRef; events: SeriesComparisonRow[] };
|
||||
|
||||
export type EventSession = {
|
||||
id: string;
|
||||
eventId?: string;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.47.0",
|
||||
"version": "1.47.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.47.0",
|
||||
"version": "1.47.2",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.47.0",
|
||||
"version": "1.47.2",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user