Compare commits

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:29:59 +02:00
joshua 7ae0b6d23b Merge branch 'feat/event-clone' 2026-10-28 10:01:18 +02:00
joshuaandClaude Opus 5.5 6ef19d3290 Copy an event (1.47.0)
"Copy" on each event card opens the event editor filled in from that
event (campus, sessions, options, variants, early birds, sections, form,
recipients, feedback questions, certificates, reminders). "New start"
moves every date with it. Allocation groups and, for admins, planned
costs are copied by POST /api/events/:id/copy-from. Registrations,
coupons, attachments, kids check-in and volunteer roles are not copied.

Roadmap: event series and cloning ships as 1.47.0-1.47.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:01:17 +02:00
joshua 51355069da Merge branch 'chore/whats-new-history' 2026-10-28 09:25:08 +02:00
joshuaandClaude Opus 5.5 3a96f9c1dc WHATS-NEW.md: church-facing notes for every version back to 1.0.0
The Hub reads this file from main (Hub 1.4.1), so these show on sites' Licence
page without a new release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:25:08 +02:00
43 changed files with 2035 additions and 94 deletions
+38
View File
@@ -7,6 +7,44 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.47.1] - 2026-10-28
### Added
- **Event series** — group separate events (Camp 2025, Camp 2026; the monthly men's breakfast) on
one page. Supervisors make series on the new **Event series** page (name, web address,
description, picture) and put an event in one with the **Series** box in the event editor; a
copied event keeps its series. A series can have events at several campuses.
- **Series page** `/series/<slug>`: what's coming up (event cards) and past events. Each event in a
series shows "Part of …" and **Next in this series** on its page.
- **Follow a series** — signed-in people follow on the series page. When a new event in the series
goes live (and hasn't started) followers get one email, sent as News & announcements: only to
people opted in to news, with the unsubscribe link and one-click headers. New `series` job every
15 minutes (`SERIES_JOBS_ENABLED`); an event moved into another series is announced to that
series' followers.
- Followed series are in "Download my data" and removed when an account is anonymised.
- API: `/api/series` (list, create, change; delete for admins; `by-slug`; follow/unfollow),
`seriesId` on event create/update, `?series=<slug>` on `GET /api/events`, `series` and
`nextInSeries` on `GET /api/events/:id`. Creating and changing series is blocked in licence
read-only mode. Audit actions `series_created`, `series_updated`, `series_deleted` (migration
`20261028140000_event_series`).
## [1.47.0] - 2026-10-28
### Added
- **Copy an event** — "Copy" on each card on the Events page opens the event editor filled in from
that event: details, campus, sessions, ticket options, variants and early-bird tiers, sections,
the registration form, notification recipients, the feedback questions, certificates and session
reminders. Everything can be changed before saving. A **New start** box at the top moves every
date by the same amount — the end, the sessions, the registration deadline, early-bird deadlines
and the feedback date. On save the event's allocation groups (rooms, buses, teams) are copied
too, and for admins its planned costs (a cost on a ticket option goes to the option with the
same name). Not copied: registrations, coupons, attachments, bundles, the short link, the go-live
time, cancelled sessions, kids check-in (kids ticket and rooms) and volunteer roles.
- `POST /api/events/:id/copy-from` (blocked in licence read-only mode, campus-checked for both
events); `POST /api/events` takes `clonedFromId`, recorded in the `event_created` audit entry.
## [1.46.2] - 2026-10-28
### Added
+36 -9
View File
@@ -29,15 +29,16 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
13. [Cashup & event costs](#cashup--event-costs)
14. [Reports](#reports)
15. [Site settings & setup wizard](#site-settings--setup-wizard)
16. [Multi-campus](#multi-campus)
17. [Kids check-in](#kids-check-in)
18. [Volunteer rosters](#volunteer-rosters)
19. [Accounts & security](#accounts--security)
20. [Privacy (POPIA)](#privacy-popia)
21. [Help system](#help-system)
22. [Background jobs](#background-jobs)
23. [Operations](#operations)
24. [Page map](#page-map)
16. [Event series](#event-series)
17. [Multi-campus](#multi-campus)
18. [Kids check-in](#kids-check-in)
19. [Volunteer rosters](#volunteer-rosters)
20. [Accounts & security](#accounts--security)
21. [Privacy (POPIA)](#privacy-popia)
22. [Help system](#help-system)
23. [Background jobs](#background-jobs)
24. [Operations](#operations)
25. [Page map](#page-map)
---
@@ -90,6 +91,14 @@ Feedback → Attendance**.
- **Basic details** — title, description, start/end dates, registration
deadline, picture (upload or URL), location (defaults to the org's address),
contact person.
- **Copy an event** — "Copy" on an event card opens the editor filled in from that
event (details, campus, sessions, options, variants, early birds, sections, form,
notification recipients, feedback questions, certificates, reminders). Setting
the **New start** moves every date with it (end, sessions, registration deadline,
early-bird deadlines, feedback date). Allocation groups are copied on save, and
for admins the planned costs (matched to options by name). Not copied:
registrations, coupons, attachments, the short link, the go-live time, kids
check-in and volunteer roles.
- **Contact-only events** — for things like baptisms: shown publicly with a
"Contact us" popup instead of registration, with no tickets.
- **Multi-day events (sessions)** — one event on several dates, e.g. every
@@ -531,6 +540,22 @@ See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
---
## Event series
Separate events grouped on one page — every year's camp, the monthly men's breakfast (each event
keeps its own registrations; for one event on several dates see multi-day events).
- **Event series** page (supervisors): name, web address (`/series/<slug>`), description, picture;
each series lists its events (campus-scoped supervisors see only their campuses' events in it)
and its number of followers. Admins can delete a series (its events stay).
- **Series box** in the event editor's Basic details. A copied event keeps its series. A series can
have events at several campuses.
- **Public series page**: upcoming events as cards, then past events. Event pages in a series show
"Part of …" and **Next in this series** (the next public event in it).
- **Follow a series**: signed-in people follow on the series page; when a new event in it goes live
(before it starts) they get one email, sent as News & announcements (opted-in only, with an
unsubscribe link). The `series` job runs every 15 minutes.
## Multi-campus
For a church with several campuses in one install (Site settings → Campuses, off by
@@ -754,6 +779,7 @@ can't start, the same jobs run on in-process timers.
| `feedback` — post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false`) |
| `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) |
| `volunteers` — volunteer reminders the day before a shift | Every 15 minutes (`VOLUNTEER_JOBS_ENABLED=false`) |
| `series` — email a series' followers when a new event in it goes live | Every 15 minutes (`SERIES_JOBS_ENABLED=false`) |
| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) |
| `privacy` — remind admins of privacy requests due within a week or overdue | 08:00 daily (`PRIVACY_JOBS_ENABLED=false`) |
| `retention` — delete/anonymise personal information past its retention period (Admin → Privacy → Retention) | 03:30 daily (`RETENTION_JOBS_ENABLED=false`) |
@@ -808,6 +834,7 @@ can't start, the same jobs run on in-process timers.
| `/` | Public | Home |
| `/events`, `/events/[id]` | Public | Event list and detail |
| `/campus/[slug]` | Public | A campus's page and its events (while campuses are on) |
| `/series/[slug]` | Public | A series' page: upcoming and past events, Follow |
| `/[alias]` | Public | Event short link |
| `/register/[eventId]` | Public / User | Registration |
| `/registration/success` | Public / User | Confirmation, form and pay |
+1
View File
@@ -117,6 +117,7 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
| `retention_policies` | Inactive accounts 60 months (on), form answers 12, login history 24, payment details 24, children at kids check-in (`kids_profiles`) 6, audit/access logs 60 — all on | Admin → Privacy → Retention (JSON `{ policies: { key: { enabled, months } }, reviewedAt, reviewedBy }`). Minimums: inactive accounts and logs 12 months, login history and payment details 3, form answers and kids profiles 1; maximum 240. Saving marks them reviewed (readiness check). |
| `retention_last_run` | — | When the `retention` job last ran and what it did (readiness check: within 2 days). |
| `VOLUNTEER_JOBS_ENABLED` (env) | `true` | The `volunteers` job (every 15 minutes) that sends volunteer reminders the day before a shift. |
| `SERIES_JOBS_ENABLED` (env) | `true` | The `series` job (every 15 minutes) that emails a series' followers once when a new event in it goes live (News & announcements: only people opted in to news). |
| `RETENTION_JOBS_ENABLED` (env) | `true` | The nightly 03:30 `retention` job. Off means nothing expires automatically. |
| `wawp_webhook_last_seen` | — | Set when WAWP's webhook arrives with the right secret; the readiness page uses it to check WhatsApp STOP is set up. |
| `privacy_notice_draft` | — | The unpublished draft from Admin → Privacy → Privacy notice (JSON). Published versions are in the `PrivacyNotice` table. With no published version the built-in notice (`backend/src/utils/privacyNotice.js`) is shown. The notice's org name, Information Officer, website, emails and medical retention days come from the settings above when it's shown. |
+438 -2
View File
@@ -4,14 +4,31 @@ What each version changes for the church, in plain language. Churches see these
settings → Licence before they ask for an update, so they can decide whether they want it.
<!--
How this file is used: the Cross Events Hub reads it from git (as of the newest v* tag) every
15 minutes. Each version is a "## 1.2.3" heading; add "— urgent" (or "security") to the heading for a
How this file is used: the Cross Events Hub reads it from main every 15 minutes, and shows a section
only once its version is tagged (so write it before tagging; fixes to old sections show straight away). Each version is a "## 1.2.3" heading; add "— urgent" (or "security") to the heading for a
security or serious bug fix, which churches can't put off. Under it, lines starting with "- " show as
a list and other lines as paragraphs; **bold**, `code` and links are shown as plain text.
Write for church admins, not developers: no environment variables, endpoints or job names. The
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 1.47.1
- **Event series.** Group events that come back — every year's camp, the monthly breakfast — on
one page that shows what's coming up and what's been. Each event in a series links to the next one.
- **Follow a series.** People can follow a series and get an email when the next event in it is
out. It counts as news, so only people who chose to get news and announcements receive it.
- A copied event stays in its series, and a series can have events at different campuses.
## 1.47.0
- **Copy an event.** Running the same camp, course or breakfast again? Press **Copy** on the event
and the editor opens with everything filled in: tickets and prices, early birds, sessions, the
form, feedback questions, certificates and groups.
- **Pick the new start date** and every other date moves with it — sessions, deadlines and
early-bird dates included. Check each step, change what's different this time, and save.
- Registrations, coupons, attachments, kids check-in and volunteer roles aren't copied.
## 1.46.2
- **See what's new before you update.** When a new version is out, the Licence page lists what it
@@ -51,3 +68,422 @@ developer log is CHANGELOG.md. A tagged version without a section here shows "No
- Each check-in gives the family a pickup code by WhatsApp or email; children can only be collected
with the code.
- Rooms with age ranges, capacity and ratios, live room screens, and an offline desk mode.
## 1.44.0
- **Staff for some campuses:** supervisors and staff can be limited to certain campuses (Admin →
Users). They then see only those campuses' events, plus events for all campuses, everywhere in the
dashboard, reports and the scanner.
- The scanner says which campus a ticket is for.
## 1.43.0
- **Campuses** for churches with more than one campus (Site settings → Campuses, off by default).
- Events belong to one campus or to all campuses; the public events page has a campus filter and
each campus gets its own page.
- Reports, dashboards, broadcasts and the daily summary can be filtered by campus.
- People can choose their home campus on their profile.
## 1.42.1
- Behind-the-scenes improvements to licence checking. Nothing changes for you.
## 1.42.0
- **Request update:** when a new version is out, press Request update on Site settings → Licence and
your provider updates the site for you.
## 1.41.3
- The platform is now called **Cross Events**. Nothing else changes, and everyone stays signed in.
## 1.41.2
- Fixed: the Background jobs page showed last-run times two hours ahead.
## 1.41.1
- A site without a licence is now read-only until a licence key is connected.
- Fixed: a site whose licence was released kept showing it as active.
## 1.41.0
- **Licences:** the site connects to Cross Events with a licence key (Site settings → Licence) and
checks in every 15 minutes with its version and health — never anyone's personal information.
- After a licence's end date there's a grace period with a reminder for site admins.
- Site admins see when a newer version is available.
## 1.40.0
- **POPIA toolkit, part 3:** a privacy readiness checklist with a score, and links to fix each item.
- **Retention settings:** choose how long personal information is kept. Inactive accounts are warned
and then anonymised after 5 years (on by default).
- **Breach register:** record security incidents, with draft messages for the people affected and
for the Information Regulator.
## 1.39.0
- **POPIA toolkit, part 2:** Admin → Privacy (POPIA).
- Edit and publish your own privacy notice, with numbered versions, and ask everyone to accept a new
version when it changes significantly.
- A register of the outside services that handle personal information, a consent log, and an access
log of who downloaded personal information.
## 1.38.0
- **POPIA toolkit, part 1:** a Privacy page for everyone (My account → Privacy): choose whether to get
news by email or WhatsApp, download your data, ask for a correction or delete your account.
- Bulk messages are either **event updates** (to everyone booked) or **news** (only to people who
opted in, with an unsubscribe link). Replying STOP on WhatsApp stops news.
- Admins handle correction and deletion requests under Admin → Data requests.
## 1.37.4
- Behind-the-scenes improvements to updating the site. Nothing changes for you.
## 1.37.3
- Behind-the-scenes fix. Nothing changes for you.
## 1.37.2
- **Payment status checks:** online payments whose confirmation never arrived from PayFast are found
and recorded automatically, with a "Check status" button under Payments → Reconcile.
- A "Test connection" button for the PayFast settings.
## 1.37.1
- "Pay online" is no longer offered when online payments are off or the amount is under R15; people
are told to pay at the event instead.
## 1.37.0
- **PayFast:** a second online payment provider (card, instant EFT and more), set up in Site
settings → Payments.
- When more than one provider is on, people choose how to pay.
## 1.36.0
- **Site settings → Payments:** turn online payment providers on or off and choose their order.
- Online payments must be at least R15; smaller balances are paid at the door.
## 1.35.1
- **Waitlist per session** on events where people pick sessions.
- "All sessions" tickets let people choose which sessions they'll come to.
- At the door, walk-ins can be booked straight into today's session.
- Several fixes for per-session tickets and stock limits.
## 1.35.0
- **Pick sessions:** a multi-session event can let people choose which sessions to come to — day
passes for a conference, or a weekly drop-in class — with a price per session.
- Each session can have its own capacity, and tickets only scan on their own session.
## 1.34.0
- **Background jobs page** (Site settings → Background jobs): see every scheduled task, when it last
ran, and run it now.
- Scheduled tasks (reminders, summaries, backups) are more reliable and run on South African time.
- Fixed: at the door, multi-day tickets showed as used up from the second session.
## 1.33.0
- **Multi-day events:** an event can run over several sessions (e.g. every Wednesday for 5 weeks);
people register and pay once.
- Check-in per session, optional reminders before each session, and late joining at a lower price.
- **Attendance register** with ticks per session and Excel/PDF export.
- **Completion certificates** with four designs, issued automatically, each with a QR code anyone can
check.
## 1.32.0
- Event cards show an "Early bird" ribbon while early-bird pricing is running.
## 1.31.1
- A field for Google Search Console verification (Site settings → Organisation).
## 1.31.0
- The registration funnel recognises visits from Facebook, Instagram, Google and other sites without
tagged links, and shows shares and QR code scans separately.
- Events can appear in Google's event listings with their date, venue and tickets.
## 1.30.1
- All tickets — emailed, WhatsApped and printed — share one clean design, with bigger QR codes.
## 1.30.0
- People can add an optional **donation** when they register, paid together with their tickets.
- Fixed: event pages with a waitlist could crash.
## 1.29.1
- The Coupon codes page is easier to use, including on phones, and hides codes that are off, expired
or used up.
## 1.29.0
- Bundle deals come in four types: an exact set, pick any mix, buy X get Y free, and group discounts.
## 1.28.3
- Fixed: the bundle deals form didn't fit on the screen.
## 1.28.2
- The Bundle deals page has a new layout that works on phones.
## 1.28.1
- Bundle deals work at the door, in manual registration and on the self-service kiosk too.
## 1.28.0
- **Bundle deals:** sell a set of tickets together for one price, e.g. 2 adults + 3 kids for R220.
They apply automatically, with no code, and the saving shows on invoices and receipts.
## 1.27.1
- Behind-the-scenes improvements to the demo data. Nothing changes for you.
## 1.27.0
- **Cashups** record where each payment method's money ended up: in the bank, cash on hand, or still
pending. Card fees and bank charges can be written off with a reason.
- Pending money can be marked received later without reopening the event.
- Every dashboard page has the same "Back to dashboard" link.
## 1.26.1
- Behind-the-scenes improvements to the demo data. Nothing changes for you.
## 1.26.0
- Behind-the-scenes upgrades and security updates.
- Fixed: the refund reason didn't show at the door.
## 1.25.1
- On phones, the cookie notice and the help button no longer hide each other or the menu.
## 1.25.0
- **Yoco settings** in Site settings: enter and check the secret key, and set up payment
notifications with one click.
## 1.24.0
- **Ticket transfers** now need the new holder to accept, by a link sent to them.
- Updated terms of use and privacy policy, with a full cookie policy and a "Cookie settings" link.
- Fixed: activating an account didn't sign you in.
## 1.23.0
- **View as user:** admins can see the site exactly as someone else does, to help with problems.
It's read-only, limited to 30 minutes and recorded in the audit log.
## 1.22.0
- **Registration funnel report:** see where people drop off between viewing an event and paying, by
traffic source and device. Only visitors who allow it are counted.
## 1.21.0
- **Post-event feedback:** send a short survey after an event, with one reminder, and see the results
in a Feedback summary report.
- New form question types: star ratings and multiple choice.
## 1.20.0
- **Sponsor a spot:** people can pay for a place for someone who can't afford it, with an optional
goal and progress bar.
## 1.19.0
- **Rooms & groups** for camps: put attendees into rooms, cabins, small groups or buses by drag and
drop or automatically, with printable lists.
- Events can ask each person's gender.
## 1.18.0
- **Ticket transfer:** hand a ticket to someone else before the event; the old QR code stops working.
## 1.17.0
- **Offline ticket scanning:** download an event's tickets to a device and keep scanning without a
connection; scans sync when it's back online.
## 1.16.0
- **Waitlists** for sold-out tickets: when a place frees up, the next person is offered it
automatically.
## 1.15.0
- **Parental consent** for youth events: parents give consent online, with optional medical and
emergency details, before their child can be checked in.
## 1.14.0
- **Named attendees:** when booking for several people, each person can be named and get their own
ticket.
## 1.13.0
- **Coupon codes:** percentage, fixed-amount or free codes, with limits on events, dates and uses.
- Staff prices for tickets.
- A Discounts report.
## 1.12.0
- Two-factor login is now required for admins and supervisors.
## 1.11.0
- **Two-factor login** for everyone who wants it: a code by email or WhatsApp after the password, and
"Trust this device for 30 days".
## 1.10.6
- Security updates.
## 1.10.5
- Events that have started but not finished stay listed on the home and events pages.
## 1.10.4
- Fixed: "Add to calendar" didn't work.
## 1.10.3
- More admin actions are written to the audit log.
## 1.10.2
- Behind-the-scenes fix to error monitoring. Nothing changes for you.
## 1.10.1
- Security fixes for file uploads, and security updates.
## 1.10.0
- **Add to calendar** on event pages and in confirmation emails.
- Better link previews and search engine listings for events.
- **Nightly database backups**, which you can download from Site settings → Backups.
- **Audit log** of refunds, donations, manual registrations, cancellations, event changes and
settings changes.
## 1.9.5
- The site copes better with automated traffic probing for pages that don't exist.
## 1.9.4
- Behind-the-scenes server setting. Nothing changes for you.
## 1.9.3
- People registered at the door or by staff get their account activation link straight away.
- Fixed: the self-service kiosk's "Choose a password" didn't work.
## 1.9.2
- Contact-only events no longer get a daily summary email.
## 1.9.1
- Fixed: the browser tab and home page showed the default name instead of your church's name.
## 1.9.0
- Events have a **location**, with a "Directions" link and a map on the event and contact pages.
## 1.8.0
- **Contact-only events** (e.g. baptism) show a "Contact us" button instead of registration.
- **Invoices and receipts** as branded PDFs with registration and payment emails, with a "pay now"
link and QR code.
- Fixed: buying more tickets after early bird ended re-priced the tickets already bought.
## 1.7.0
- **Brand colours:** primary, secondary and accent colours across the whole site and emails, a
favicon, and a live preview. Colours can be suggested from your logo.
## 1.6.0
- Supervisors can unassign a donation that was applied to a registration.
- Faster payments and dashboards.
## 1.5.4
- The home page shows "My Dashboard" to people who are signed in.
## 1.5.3
- Fixed: help popups could be taller than a phone screen.
## 1.5.2
- Dashboard tiles and charts fit better on phones.
## 1.5.1
- Fixed: scheduled WhatsApp messages were sent as emails.
## 1.5.0
- **Site redesign:** a new look across the whole site, new dashboards with trends and top events, and
a "Need help?" button with a guide on every page.
- A separate Contact page, and recent sign-ins on Profile & Security.
## 1.4.2
- Fixes to how refunds and donations are counted in reports and cashups.
## 1.4.1
- Reports fit better on phones.
## 1.4.0
- **Reports redesigned:** filters that apply to every report, charts, and styled PDF and Excel
exports, by email or WhatsApp too.
- Cashups show who recorded each payment, with per-person cash counts.
- A payment link placeholder for messages to attendees.
## 1.3.2
- The self-service kiosk is safer with returning visitors' details, and asks before changing them.
## 1.3.1
- The self-service kiosk can find a returning visitor's account.
## 1.3.0
- **Check-in at the door** by number of people, without scanning each QR code, and a confirmation
message to the ticket holder.
## 1.2.0
- "Make payment" goes straight to paying the full balance.
- Staff can record a payment straight after a manual registration.
## 1.1.0
- A payment history page in My account.
- Registration status shows as a coloured badge.
## 1.0.1
- Closed and past events are tidied away in My Events.
## 1.0.0
- The first release.
+1
View File
@@ -77,6 +77,7 @@ WAITLIST_JOB_ENABLED=true
PAYMENT_STATUS_JOB_ENABLED=true
# Volunteer rosters: reminders the day before a shift (every 15 minutes)
VOLUNTEER_JOBS_ENABLED=true
SERIES_JOBS_ENABLED=true
# Privacy (POPIA): daily 08:00 reminder to admins of data requests due within a week
PRIVACY_JOBS_ENABLED=true
# Privacy (POPIA): nightly 03:30 retention job (Admin → Privacy → Retention). Note: inactive
+20
View File
@@ -136,6 +136,7 @@ At login, if a WhatsApp code can't be sent, the code falls back to the account's
| GET | `/api/events/by-alias/:redirectUrl` | public | Get event by URL alias |
| POST | `/api/events` | supervisor+ | Create event |
| PUT | `/api/events/:id` | supervisor+ | Update event |
| POST | `/api/events/:id/copy-from` | supervisor+ | Copying an event: copy `fromEventId`'s allocation sets/groups (not kids rooms) and, for admins, planned costs into this new event (409 once it has registrations or groups) |
| DELETE | `/api/events/:id` | admin | Delete event |
| POST | `/api/events/:id/email-attendees` | supervisor+ | Bulk email attendees (`dryRun: true` to preview) |
| POST | `/api/events/:id/email-attendees/schedule` | supervisor+ | Schedule bulk email |
@@ -170,6 +171,10 @@ At login, if a WhatsApp code can't be sent, the code falls back to the account's
}
```
A copy made with "Copy" on the Events page is an ordinary `POST /api/events` from the event form,
with `clonedFromId` (the source event, noted in the `event_created` audit entry), followed by the
options, feedback form, sections and recipients as for a new event, then `POST /api/events/:id/copy-from`.
---
## Event Options & Variants
@@ -928,6 +933,21 @@ Allergies, medical and custody notes are stored encrypted. Staff routes are camp
Since 1.46.0 board rooms also carry `rostered: [{ userId, name, checkedIn }]` (volunteers on a serving
role linked to the room); checked-in ones are counted in `leaders`.
## Event series
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/series` | supervisor+ | Every series with `followers`, `eventCount` and its events (campus-scoped: their campuses' events only) |
| POST | `/api/series` | supervisor+ | Create: `{ name, slug?, description?, picture? }` (slug made from the name). 409 when the slug is taken |
| PUT | `/api/series/:id` | supervisor+ | Change any of those fields |
| DELETE | `/api/series/:id` | admin | Delete; events stay without a series, followers dropped |
| GET | `/api/series/by-slug/:slug` | public | `{ id, name, slug, description, picture, upcoming, past, following }` — public events only |
| POST / DELETE | `/api/series/:id/follow` | user | Follow / unfollow |
Events take `seriesId` (null = none) on create and update; `GET /api/events?series=<slug>` lists one
series; `GET /api/events/:id` includes `series` and `nextInSeries`. The `series` job emails
followers once per event (`Event.seriesAnnouncedAt`) when it is public and hasn't started.
## Volunteer rosters
Volunteer rosters (1.46.0, `src/utils/volunteers.js`). A **serving role** belongs to an event, or to
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.46.2",
"version": "1.47.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.46.2",
"version": "1.47.1",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.46.2",
"version": "1.47.1",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,59 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'series_created';
ALTER TYPE "AdminAuditAction" ADD VALUE 'series_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'series_deleted';
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "seriesAnnouncedAt" TIMESTAMP(3),
ADD COLUMN "seriesId" TEXT;
-- CreateTable
CREATE TABLE "EventSeries" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"picture" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventSeries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SeriesFollow" (
"id" TEXT NOT NULL,
"seriesId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SeriesFollow_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "EventSeries_slug_key" ON "EventSeries"("slug");
-- CreateIndex
CREATE INDEX "SeriesFollow_userId_idx" ON "SeriesFollow"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "SeriesFollow_seriesId_userId_key" ON "SeriesFollow"("seriesId", "userId");
-- CreateIndex
CREATE INDEX "Event_seriesId_idx" ON "Event"("seriesId");
-- AddForeignKey
ALTER TABLE "Event" ADD CONSTRAINT "Event_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "EventSeries"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeriesFollow" ADD CONSTRAINT "SeriesFollow_seriesId_fkey" FOREIGN KEY ("seriesId") REFERENCES "EventSeries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SeriesFollow" ADD CONSTRAINT "SeriesFollow_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+38
View File
@@ -162,6 +162,7 @@ model User {
volunteerSlots VolunteerSlot[] @relation("VolunteerSlotUser")
volunteersAssigned VolunteerSlot[] @relation("VolunteerSlotAssignedBy")
volunteersCheckedIn VolunteerSlot[] @relation("VolunteerSlotCheckedInBy")
seriesFollows SeriesFollow[]
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
@@ -319,10 +320,44 @@ model Event {
volunteerOptionId String?
volunteerCheckInToken String? @unique
// Event series (1.47.1): separate events grouped on one series page (Camp 2025, Camp 2026).
// seriesAnnouncedAt: when followers were emailed that this event is out (once, when it goes live).
seriesId String?
series EventSeries? @relation(fields: [seriesId], references: [id], onDelete: SetNull)
seriesAnnouncedAt DateTime?
@@index([createdById])
@@index([closedById])
@@index([reopenedById])
@@index([campusId])
@@index([seriesId])
}
/// Separate events grouped together, e.g. every year's camp or the monthly men's breakfast.
/// Not tied to a campus: a series can have events at several campuses.
model EventSeries {
id String @id @default(uuid())
name String
slug String @unique
description String?
picture String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events Event[]
followers SeriesFollow[]
}
/// Someone who wants to hear when a new event in the series is out (sent as news).
model SeriesFollow {
id String @id @default(uuid())
seriesId String
userId String
createdAt DateTime @default(now())
series EventSeries @relation(fields: [seriesId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([seriesId, userId])
@@index([userId])
}
model EventOption {
@@ -702,6 +737,9 @@ enum AdminAuditAction {
volunteer_assigned
volunteer_removed
volunteer_checked_in_manual
series_created
series_updated
series_deleted
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
+31 -3
View File
@@ -11,6 +11,9 @@ const sessionsUtil = require('../utils/sessions');
const { publicOptions } = require('../utils/volunteers');
const { campusesEnabled, campusForEvent, campusWhere, resolveCampusFilter, EVENT_CAMPUS_SELECT } = require('../utils/campuses');
const { scopeFor, canView, canManage, eventWhere: scopeEventWhere, withScope } = require('../utils/campusScope');
const seriesUtil = require('../utils/series');
const SERIES_SELECT = { select: { id: true, name: true, slug: true } };
/** A campus-scoped supervisor may only create or move events to one of their campuses. */
async function assertCampusAllowed(req, campusId) {
@@ -102,6 +105,20 @@ async function saveSchedule(tx, eventId, body) {
return changes;
}
// @desc Copy allocation groups (and, for admins, planned costs) from another event into a new copy
// @route POST /api/events/:id/copy-from
// @access Private/Supervisor
const copyEventSetupFrom = async (req, res) => {
try {
const fromEventId = req.body?.fromEventId;
if (!fromEventId) return res.status(400).json({ message: 'fromEventId is required' });
const copied = await require('../utils/eventClone').copyEventSetup(String(fromEventId), req.params.id, { withCosts: req.user?.role === 'admin' });
res.json(copied);
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// Helper to convert stored picture path/URL to an absolute, externally reachable URL based on the incoming request
function toAbsoluteUrl(req, url) {
if (!url) return url;
@@ -144,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(),
@@ -181,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') {
@@ -196,7 +215,7 @@ const createEvent = async (req, res) => {
action: 'event_created',
targetType: 'Event',
targetId: createdEvent.id,
metadata: { title: createdEvent.title },
metadata: { title: createdEvent.title, ...(req.body.clonedFromId ? { clonedFromId: String(req.body.clonedFromId) } : {}) },
ip: getClientIp(req),
});
@@ -349,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: {
@@ -358,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)
@@ -457,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' }
});
@@ -484,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.
@@ -639,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) {
@@ -728,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 }); }
}
@@ -770,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),
@@ -2156,6 +2183,7 @@ const getEventByAlias = async (req, res) => {
module.exports = {
createEvent,
copyEventSetupFrom,
getEvents,
getAllEvents,
getEventsAll,
+108
View File
@@ -0,0 +1,108 @@
const prisma = require('../config/db');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { cleanSeriesInput, publicEventWhere, splitEvents, follow, unfollow } = require('../utils/series');
const { scopeFor, canView } = require('../utils/campusScope');
const { EVENT_CAMPUS_SELECT } = require('../utils/campuses');
const fail = (res, error) => res.status(error.statusCode || (error.code === 'P2002' ? 409 : 400)).json({
message: error.code === 'P2002' ? 'Another series already uses that web address' : error.message,
});
const audit = (req, action, series, metadata = {}) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'EventSeries', targetId: series.id,
metadata: { name: series.name, ...metadata }, ip: getClientIp(req),
});
// @desc Every series, with its events (the ones this person may see) and number of followers
// @route GET /api/series
// @access Private/Supervisor
const listSeries = async (req, res) => {
try {
const scope = await scopeFor(req);
const rows = await prisma.eventSeries.findMany({
orderBy: { name: 'asc' },
include: {
events: { orderBy: { startDate: 'desc' }, select: { id: true, title: true, startDate: true, endDate: true, isActive: true, isHidden: true, campusId: true, campus: { select: EVENT_CAMPUS_SELECT } } },
_count: { select: { followers: true, events: true } },
},
});
// A series spans campuses; campus-scoped staff see the series, but only their campuses' events in it.
res.json(rows.map(({ _count, events, ...s }) => ({
...s,
followers: _count.followers,
eventCount: _count.events,
events: events.filter(e => canView(scope, e.campusId)),
})));
} catch (error) { fail(res, error); }
};
// @desc Create a series
// @route POST /api/series
// @access Private/Supervisor
const createSeries = async (req, res) => {
try {
const series = await prisma.eventSeries.create({ data: cleanSeriesInput(req.body) });
audit(req, 'series_created', series);
res.status(201).json(series);
} catch (error) { fail(res, error); }
};
// @desc Rename a series or change its page
// @route PUT /api/series/:id
// @access Private/Supervisor
const updateSeries = async (req, res) => {
try {
const current = await prisma.eventSeries.findUnique({ where: { id: req.params.id } });
if (!current) return res.status(404).json({ message: 'Series not found' });
const data = cleanSeriesInput(req.body, current);
const series = await prisma.eventSeries.update({ where: { id: current.id }, data });
audit(req, 'series_updated', series, { changedKeys: Object.keys(data) });
res.json(series);
} catch (error) { fail(res, error); }
};
// @desc Delete a series (its events stay, just without a series; followers are dropped)
// @route DELETE /api/series/:id
// @access Private/Admin
const deleteSeries = async (req, res) => {
try {
const current = await prisma.eventSeries.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true, followers: true } } } });
if (!current) return res.status(404).json({ message: 'Series not found' });
await prisma.eventSeries.delete({ where: { id: current.id } });
audit(req, 'series_deleted', current, { events: current._count.events, followers: current._count.followers });
res.json({ ok: true });
} catch (error) { fail(res, error); }
};
// @desc A series' public page: its details, upcoming and past public events, and whether the
// signed-in person follows it
// @route GET /api/series/by-slug/:slug
// @access Public
const getSeriesBySlug = async (req, res) => {
try {
const series = await prisma.eventSeries.findUnique({
where: { slug: String(req.params.slug) },
select: { id: true, name: true, slug: true, description: true, picture: true },
});
if (!series) return res.status(404).json({ message: 'Series not found' });
const events = await prisma.event.findMany({
where: { ...publicEventWhere(), seriesId: series.id },
select: { id: true, title: true, startDate: true, endDate: true, location: true, campus: { select: EVENT_CAMPUS_SELECT } },
});
const { upcoming, past } = splitEvents(events);
const following = req.user ? !!(await prisma.seriesFollow.findUnique({ where: { seriesId_userId: { seriesId: series.id, userId: req.user.id } } })) : false;
res.json({ ...series, upcoming, past, following });
} catch (error) { fail(res, error); }
};
// @route POST /api/series/:id/follow · DELETE /api/series/:id/follow
// @access Private
const followSeries = async (req, res) => {
try { await follow(req.params.id, req.user.id); res.json({ following: true }); } catch (error) { fail(res, error); }
};
const unfollowSeries = async (req, res) => {
try { await unfollow(req.params.id, req.user.id); res.json({ following: false }); } catch (error) { fail(res, error); }
};
module.exports = { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries };
+3
View File
@@ -13,6 +13,9 @@ const { licenceStatus } = require('./licence');
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\/?$/],
+16
View File
@@ -185,6 +185,7 @@ app.use('/api/hub', require('./routes/hubRoutes'));
app.use('/api/campuses', require('./routes/campusRoutes'));
app.use('/api/kids', require('./routes/kidsRoutes'));
app.use('/api/volunteers', require('./routes/volunteerRoutes'));
app.use('/api/series', require('./routes/seriesRoutes'));
app.use('/api/users', userRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/registrations', registrationRoutes);
@@ -584,6 +585,10 @@ app.get('/docs', async (req, res) => {
pathParams:{ ':id':'Event UUID' },
request:{ body:{ title:'Camp 2025 Updated', price:500 }},
responses:[{ status:200, desc:'Updated', body:{ id:'ev-uuid-...', title:'Camp 2025 Updated', price:500 }}]},
{ method:'POST', path:'/api/events/:id/copy-from', auth:'supervisor+', desc:'Copying an event (1.47.0): after the copy is created from the event form, copy the source event\'s allocation sets and groups (not the kids rooms) and, for admins, its planned costs (matched to options by name). Only into an event with no registrations or allocation sets yet. POST /api/events takes clonedFromId to note the source in the audit log.',
pathParams:{ ':id':'The new event' },
request:{ body:{ fromEventId:'ev-uuid-source' }},
responses:[{ status:200, desc:'Copied', body:{ sets:2, groups:9, costs:3 }}, { status:409, desc:'The event already has registrations or groups' }]},
{ method:'GET', path:'/api/events/:id/notify-recipients', auth:'supervisor+', desc:'Get which users receive registration/payment/daily-summary notifications for this event. Lightweight — skips the option/stock computation that GET /api/events/:id does.',
pathParams:{ ':id':'Event UUID' },
responses:[{ status:200, desc:'Success', body:[{ id:'user-uuid-1', name:'Jane Supervisor', email:'jane@example.com', role:'supervisor' }]}]},
@@ -993,6 +998,17 @@ app.get('/docs', async (req, res) => {
{ method:'POST', path:'/api/kids/events/:eventId/sync', auth:'staff+', desc:'Sync an offline desk\'s check-ins and check-outs (idempotent by clientId). Audited as kids_offline_sync' },
]},
{ title: 'Event series', base: '/api/series', endpoints: [
{ method:'GET', path:'/api/series', auth:'supervisor+', desc:'Every series with followers, eventCount and its events (campus-scoped staff see only their campuses\' events in each)' },
{ method:'POST', path:'/api/series', auth:'supervisor+', desc:'Create a series (slug made from the name when not given). Audited as series_created',
request:{ body:{ name:'Family Camp', slug:'family-camp', description:'Every year in March', picture:null } } },
{ method:'PUT', path:'/api/series/:id', auth:'supervisor+', desc:'Change a series. Audited as series_updated' },
{ method:'DELETE', path:'/api/series/:id', auth:'admin', desc:'Delete a series; its events stay without a series and followers are dropped. Audited as series_deleted' },
{ method:'GET', path:'/api/series/by-slug/:slug', auth:'public', desc:'The series page: details, upcoming and past public events, and following (signed in)' },
{ method:'POST', path:'/api/series/:id/follow', auth:'user', desc:'Follow a series: an email (News & announcements) when a new event in it goes live' },
{ method:'DELETE', path:'/api/series/:id/follow', auth:'user', desc:'Unfollow a series' },
]},
{ title: 'Volunteer rosters', base: '/api/volunteers', endpoints: [
{ method:'GET', path:'/api/volunteers/me', auth:'user', desc:'My shifts (upcoming and the last week), cover requests I could take, and events looking for volunteers' },
{ method:'GET', path:'/api/volunteers/events/:eventId', auth:'user', desc:'An event\'s serving roles: shift, how many are needed and signed up, whether I\'m in it, open cover requests' },
+4
View File
@@ -49,6 +49,10 @@ const JOBS = [
name: 'volunteers', label: 'Volunteer reminders the day before a shift', cron: '*/15 * * * *', env: 'VOLUNTEER_JOBS_ENABLED',
run: () => require('../utils/volunteers').runReminderJob(),
},
{
name: 'series', label: 'Event series: tell followers when a new event goes live', cron: '*/15 * * * *', env: 'SERIES_JOBS_ENABLED',
run: () => require('../utils/series').runAnnounceJob(),
},
{
name: 'consent', label: 'Parental consent reminders and medical-detail deletion', cron: '0 9 * * *', env: 'CONSENT_JOBS_ENABLED',
run: async () => {
+5 -1
View File
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const {
createEvent,
copyEventSetupFrom,
getEvents,
getAllEvents,
getEventsAll,
@@ -29,7 +30,7 @@ const {
getEventByAlias
} = require('../controllers/eventController');
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
const { scopeEvent, scopeList } = require('../utils/campusScope');
const { scopeEvent, scopeBody, scopeList } = require('../utils/campusScope');
// Public routes
router.get('/', getEvents);
@@ -55,6 +56,9 @@ router.route('/:id')
.put(protect, supervisor, scopeEvent({ need: 'manage' }), updateEvent)
.delete(protect, admin, deleteEvent);
// Clone: copy allocation groups (and costs, for admins) from the source event into a new copy.
router.post('/:id/copy-from', protect, supervisor, scopeEvent({ need: 'manage' }), scopeBody([['fromEventId', 'event']]), copyEventSetupFrom);
// Notification recipients: who gets registration/payment/daily-summary emails for this event
router.route('/:id/notify-recipients')
.get(protect, supervisor, scopeEvent(), getEventNotifyRecipients)
+19
View File
@@ -0,0 +1,19 @@
const express = require('express');
const router = express.Router();
const { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries } = require('../controllers/seriesController');
const { protect, admin, supervisor, optionalAuth } = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
const SPANS = campusIndependent('a series spans campuses; adding an event to one is checked on the event');
router.get('/by-slug/:slug', optionalAuth, getSeriesBySlug);
router.post('/:id/follow', protect, followSeries);
router.delete('/:id/follow', protect, unfollowSeries);
// The list filters each series' events by the person's campuses itself.
router.get('/', protect, supervisor, SPANS, listSeries);
router.post('/', protect, supervisor, SPANS, createSeries);
router.put('/:id', protect, supervisor, SPANS, updateSeries);
router.delete('/:id', protect, admin, deleteSeries);
module.exports = router;
+93
View File
@@ -0,0 +1,93 @@
/**
* Event cloning (1.47.0). The copy itself is made by the event form in the browser — it opens
* "New event" filled in from the source event (details, sessions, options, variants, early-bird
* tiers, form, feedback form, sections, notification recipients), so everything can be changed
* before saving. This file copies the parts that form doesn't hold, once the new event exists:
* allocation sets with their groups, and (admins only) the planned costs.
*
* Never copied: registrations and everything people did, coupons, attachments, bundles, the kids
* check-in setup (kids ticket and rooms) and volunteer roles.
*/
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const COST_FIELDS = ['label', 'costType', 'amount', 'paidFromMethod', 'notes'];
/**
* Costs for the new event. A cost tied to a ticket option moves to the new event's option with
* the same name; when there's none it stays on the event as a whole.
* @param {object[]} costs the source event's EventCost rows (with `eventOption.name`)
* @param {object[]} newOptions the new event's options ({ id, name })
*/
function costCopies(costs, newOptions) {
const byName = new Map(newOptions.map(o => [String(o.name).trim().toLowerCase(), o.id]));
return costs.map(c => {
const data = {};
for (const f of COST_FIELDS) data[f] = c[f] ?? null;
const name = c.eventOption?.name ? String(c.eventOption.name).trim().toLowerCase() : null;
data.eventOptionId = (name && byName.get(name)) || null;
return data;
});
}
/** The source's allocation sets worth copying: all but the kids rooms. */
function setsToCopy(sets, kidsRoomSetId) {
return sets.filter(s => s.id !== kidsRoomSetId);
}
function cloneError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/**
* Copies allocation sets and groups (and costs when `withCosts`) from `fromId` to the just-made
* event `toId`. Refused once the new event has registrations or allocation sets, so it can only
* fill a fresh copy.
* @returns {Promise<{ sets: number, groups: number, costs: number }>}
*/
async function copyEventSetup(fromId, toId, { withCosts = false } = {}) {
if (fromId === toId) throw cloneError("An event can't be copied onto itself");
const [from, to] = await Promise.all([
prisma.event.findUnique({
where: { id: fromId },
select: {
id: true, kidsRoomSetId: true,
allocationSets: { orderBy: { order: 'asc' }, include: { groups: { orderBy: { order: 'asc' } } } },
costs: { orderBy: { createdAt: 'asc' }, include: { eventOption: { select: { name: true } } } },
},
}),
prisma.event.findUnique({
where: { id: toId },
select: { id: true, eventOptions: { select: { id: true, name: true } }, _count: { select: { registrations: true, allocationSets: true } } },
}),
]);
if (!from || !to) throw cloneError('Event not found', 404);
if (to._count.registrations > 0 || to._count.allocationSets > 0) {
throw cloneError('Setup can only be copied into a new event (this one already has registrations or groups)', 409);
}
const sets = setsToCopy(from.allocationSets, from.kidsRoomSetId);
const costs = withCosts ? costCopies(from.costs, to.eventOptions) : [];
let groups = 0;
await prisma.$transaction(async tx => {
for (const s of sets) {
const set = await tx.allocationSet.create({ data: { id: uuidv4(), eventId: toId, name: s.name, showOnTicket: s.showOnTicket, order: s.order } });
for (const g of s.groups) {
await tx.allocationGroup.create({
data: {
id: uuidv4(), setId: set.id, name: g.name, capacity: g.capacity, gender: g.gender,
leaderUserId: g.leaderUserId, order: g.order, minAge: g.minAge, maxAge: g.maxAge, ratio: g.ratio,
},
});
groups++;
}
}
for (const c of costs) await tx.eventCost.create({ data: { id: uuidv4(), eventId: toId, ...c } });
});
return { sets: sets.length, groups, costs: costs.length };
}
module.exports = { copyEventSetup, costCopies, setsToCopy };
+10 -1
View File
@@ -463,6 +463,9 @@ async function anonymiseUser(userId, db = prisma) {
// Volunteer rosters: shifts still to come are given up; past ones stay for hours served.
const shiftsGivenUp = await require('./volunteers').forgetVolunteer(tx, userId);
// Event series they followed.
await tx.seriesFollow.deleteMany({ where: { userId } });
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
});
@@ -485,7 +488,7 @@ async function exportUserData(userId, db = prisma) {
});
if (!user) return null;
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices, family, volunteering] = await Promise.all([
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices, family, volunteering, seriesFollowed] = await Promise.all([
db.registration.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
@@ -511,6 +514,7 @@ async function exportUserData(userId, db = prisma) {
db.noticeAcceptance.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { version: true, source: true, createdAt: true } }),
require('./kids').exportFamily(userId, db),
require('./volunteers').exportVolunteering(userId, db),
require('./series').exportFollows(userId, db),
]);
return {
@@ -554,6 +558,7 @@ async function exportUserData(userId, db = prisma) {
dataRequests: requests,
kidsFamily: family,
volunteering,
seriesFollowed,
};
}
@@ -614,6 +619,10 @@ async function dataExportPdf(data) {
heading('Serving (volunteer rosters)');
for (const s of data.volunteering) para(`${fmt(s.from)} — ${s.role}, ${s.event}${s.checkedIn ? ' (checked in)' : ''}`);
}
if (data.seriesFollowed?.length) {
heading('Event series you follow');
for (const f of data.seriesFollowed) para(`${f.series} — since ${fmt(f.since)}`);
}
if (data.feedback.length) {
heading('Feedback you gave');
for (const f of data.feedback) {
+163
View File
@@ -0,0 +1,163 @@
/**
* Event series (1.47.1): separate events grouped on one series page — every year's camp, the
* monthly men's breakfast. A series isn't tied to a campus, so it can have events at several.
* Each event page links to the next public event in its series, and people can follow a series:
* when a new event in it goes live they get one email, sent as News & announcements (so only to
* people who opted in to news, with an unsubscribe link).
*/
const prisma = require('../config/db');
const { slugify } = require('./campuses');
function seriesError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/** Events anyone can see: active, not hidden and live. */
function publicEventWhere(now = new Date()) {
return { isActive: true, isHidden: false, goLiveAt: { lte: now } };
}
/** Name, slug, description and picture from a create/update body (`current` on update). */
function cleanSeriesInput(body = {}, current = null) {
const data = {};
if (body.name !== undefined || !current) {
const name = String(body.name || '').trim();
if (!name) throw seriesError('Give the series a name');
if (name.length > 120) throw seriesError('The name is too long');
data.name = name;
}
if (body.slug !== undefined || !current) {
const slug = slugify(body.slug || data.name || current?.name);
if (!slug) throw seriesError('The web address needs letters or numbers');
data.slug = slug;
}
if (body.description !== undefined) data.description = String(body.description || '').trim() || null;
if (body.picture !== undefined) data.picture = String(body.picture || '').trim() || null;
return data;
}
/** The series to save on an event from a body's `seriesId`: undefined = leave as is, null = none. */
async function seriesForEvent(seriesId, db = prisma) {
if (seriesId === undefined) return undefined;
if (!seriesId) return null;
const found = await db.eventSeries.findUnique({ where: { id: String(seriesId) }, select: { id: true } });
if (!found) throw seriesError('That series no longer exists');
return found.id;
}
const NEXT_SELECT = { id: true, title: true, startDate: true, endDate: true, location: true, redirectUrl: true };
/**
* The next public event in the same series after `event` (starting later, not over yet), or null.
*/
async function nextInSeries(event, db = prisma, now = new Date()) {
if (!event?.seriesId) return null;
return db.event.findFirst({
where: { ...publicEventWhere(now), seriesId: event.seriesId, id: { not: event.id }, startDate: { gt: event.startDate }, endDate: { gte: now } },
orderBy: { startDate: 'asc' },
select: NEXT_SELECT,
});
}
/**
* Splits a series' public events into upcoming (not over yet, soonest first) and past (latest
* first). Pure, for the series page.
*/
function splitEvents(events, now = new Date()) {
const upcoming = events.filter(e => new Date(e.endDate) >= now).sort((a, b) => new Date(a.startDate) - new Date(b.startDate));
const past = events.filter(e => new Date(e.endDate) < now).sort((a, b) => new Date(b.startDate) - new Date(a.startDate));
return { upcoming, past };
}
// ─── Following ───────────────────────────────────────────────────────────────
async function follow(seriesId, userId) {
const series = await prisma.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true } });
if (!series) throw seriesError('Series not found', 404);
await prisma.seriesFollow.upsert({ where: { seriesId_userId: { seriesId, userId } }, create: { seriesId, userId }, update: {} });
}
async function unfollow(seriesId, userId) {
await prisma.seriesFollow.deleteMany({ where: { seriesId, userId } });
}
function frontendUrl() {
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
}
/**
* The `series` job: for each event in a series that is now public and hasn't started, email the
* series' followers once. The event is claimed first (seriesAnnouncedAt), so a slow run or two
* workers never send twice. An event made public after it started is never announced.
*/
async function runAnnounceJob(now = new Date()) {
const due = await prisma.event.findMany({
where: { ...publicEventWhere(now), seriesId: { not: null }, seriesAnnouncedAt: null, startDate: { gt: now } },
select: { id: true, title: true, startDate: true, location: true, redirectUrl: true, series: { select: { id: true, name: true, slug: true } } },
take: 50,
});
let announced = 0;
let sent = 0;
let skipped = 0;
for (const ev of due) {
const claimed = await prisma.event.updateMany({ where: { id: ev.id, seriesAnnouncedAt: null }, data: { seriesAnnouncedAt: now } });
if (!claimed.count) continue;
announced++;
const follows = await prisma.seriesFollow.findMany({
where: { seriesId: ev.series.id, user: { isActive: true } },
select: { user: { select: { id: true, name: true, email: true } } },
});
const { filterRecipients, bulkEmailFooter } = require('./privacy');
const { recipients, skipped: notNews } = await filterRecipients(
follows.filter(f => f.user.email).map(f => ({ email: f.user.email, name: f.user.name, userId: f.user.id })),
{ channel: 'email', kind: 'news' },
);
skipped += notNews;
const { sendMail } = require('./email');
for (const r of recipients) {
const footer = bulkEmailFooter({ kind: 'news', recipient: r });
const msg = announcementEmail({ name: r.name, event: ev, series: ev.series });
try {
await sendMail({ to: r.email, subject: msg.subject, html: msg.html + footer.html, text: msg.text + footer.text, headers: footer.headers });
sent++;
} catch (e) {
try { console.warn('[series] failed to email', r.email, e?.message || e); } catch {}
}
}
}
return { announced, sent, skipped };
}
function announcementEmail({ name, event, series }) {
const { emailWrapper, ctaButton } = require('./email');
const esc = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const when = new Date(event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const url = `${frontendUrl()}/events/${event.id}`;
const seriesUrl = `${frontendUrl()}/series/${series.slug}`;
const subject = `New in ${series.name}: ${event.title}`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">${esc(event.title)}</p>
<p style="color:#64748b;font-size:14px;margin:0 0 24px 0">New in ${esc(series.name)}</p>
<p style="margin:0 0 16px 0;color:#374151">Hi <strong>${esc(name || 'there')}</strong>,</p>
<p style="margin:0 0 16px 0;color:#374151">You follow <strong>${esc(series.name)}</strong>, and the next one is out: <strong>${esc(event.title)}</strong> on ${esc(when)}${event.location ? `, ${esc(event.location)}` : ''}.</p>
${ctaButton('See the event', url)}
<p style="margin:16px 0 0 0;font-size:13px;color:#64748b">To stop hearing about this series, unfollow it on <a href="${seriesUrl}" style="color:#64748b">its page</a>.</p>`;
const text = `Hi ${name || 'there'},\n\nYou follow ${series.name}, and the next one is out: ${event.title} on ${when}${event.location ? `, ${event.location}` : ''}.\n\nSee the event: ${url}\n\nTo stop hearing about this series, unfollow it on its page: ${seriesUrl}`;
return { subject, text, html: emailWrapper(body, { preheader: subject }) };
}
// ─── Privacy ─────────────────────────────────────────────────────────────────
/** Series someone follows, for "Download my data". */
async function exportFollows(userId, db = prisma) {
const rows = await db.seriesFollow.findMany({ where: { userId }, orderBy: { createdAt: 'asc' }, select: { createdAt: true, series: { select: { name: true } } } });
return rows.map(r => ({ series: r.series.name, since: r.createdAt }));
}
module.exports = {
publicEventWhere, cleanSeriesInput, seriesForEvent, nextInSeries, splitEvents,
follow, unfollow, runAnnounceJob, announcementEmail, exportFollows, seriesError,
};
+31
View File
@@ -0,0 +1,31 @@
jest.mock('../src/config/db', () => ({}));
const { costCopies, setsToCopy } = require('../src/utils/eventClone');
const { isBlocked } = require('../src/hub/readOnly');
describe('event cloning', () => {
test('costs move to the new option with the same name, else to the whole event', () => {
const costs = [
{ id: 'c1', label: 'Venue', costType: 'fixed', amount: 5000, paidFromMethod: null, notes: null, eventOptionId: null, eventOption: null },
{ id: 'c2', label: 'Meals', costType: 'per_person', amount: 120, paidFromMethod: 'cash', notes: 'x', eventOptionId: 'old-a', eventOption: { name: 'Adult ' } },
{ id: 'c3', label: 'T-shirts', costType: 'per_person', amount: 80, paidFromMethod: null, notes: null, eventOptionId: 'old-b', eventOption: { name: 'Leader' } },
];
const out = costCopies(costs, [{ id: 'new-a', name: 'adult' }, { id: 'new-c', name: 'Child' }]);
expect(out).toEqual([
{ label: 'Venue', costType: 'fixed', amount: 5000, paidFromMethod: null, notes: null, eventOptionId: null },
{ label: 'Meals', costType: 'per_person', amount: 120, paidFromMethod: 'cash', notes: 'x', eventOptionId: 'new-a' },
{ label: 'T-shirts', costType: 'per_person', amount: 80, paidFromMethod: null, notes: null, eventOptionId: null },
]);
// Nothing of the source rows' identity is carried over.
for (const c of out) expect(c.id).toBeUndefined();
});
test('kids rooms are not copied', () => {
const sets = [{ id: 'rooms' }, { id: 'kids' }, { id: 'buses' }];
expect(setsToCopy(sets, 'kids').map(s => s.id)).toEqual(['rooms', 'buses']);
expect(setsToCopy(sets, null)).toHaveLength(3);
});
test('copying setup is blocked in licence read-only mode', () => {
expect(isBlocked('POST', '/api/events/abc/copy-from')).toBe(true);
});
});
+92
View File
@@ -0,0 +1,92 @@
const mockDb = {
event: { findMany: jest.fn(), updateMany: jest.fn() },
seriesFollow: { findMany: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
const mockFilter = jest.fn();
jest.mock('../src/utils/privacy', () => ({
filterRecipients: (...a) => mockFilter(...a),
bulkEmailFooter: () => ({ html: '<p>unsubscribe</p>', text: '\nunsubscribe', headers: { 'List-Unsubscribe': '<x>' } }),
}));
const mockSend = jest.fn();
jest.mock('../src/utils/email', () => ({
sendMail: (...a) => mockSend(...a),
emailWrapper: body => `<html>${body}</html>`,
ctaButton: (label, url) => `<a href="${url}">${label}</a>`,
}));
const series = require('../src/utils/series');
const { isBlocked } = require('../src/hub/readOnly');
describe('event series', () => {
test('a new series needs a name; the web address comes from it', () => {
expect(() => series.cleanSeriesInput({ name: ' ' })).toThrow('name');
expect(series.cleanSeriesInput({ name: "Men's Breakfast", description: ' Monthly ' })).toEqual({ name: "Men's Breakfast", slug: 'men-s-breakfast', description: 'Monthly' });
expect(series.cleanSeriesInput({ name: 'Camp', slug: 'Family Camp!' }).slug).toBe('family-camp');
});
test('changing a series only touches what was sent', () => {
expect(series.cleanSeriesInput({ description: '' }, { name: 'Camp' })).toEqual({ description: null });
});
test('upcoming soonest first, past latest first', () => {
const now = new Date('2026-06-01T00:00:00Z');
const ev = (id, start, end) => ({ id, startDate: new Date(start), endDate: new Date(end) });
const { upcoming, past } = series.splitEvents([
ev('2024', '2024-03-01', '2024-03-04'), ev('2027', '2027-03-01', '2027-03-04'),
ev('2025', '2025-03-01', '2025-03-04'), ev('2026', '2026-05-30', '2026-06-02'),
], now);
expect(upcoming.map(e => e.id)).toEqual(['2026', '2027']);
expect(past.map(e => e.id)).toEqual(['2025', '2024']);
});
test('creating and changing series is blocked in licence read-only mode; following is not', () => {
expect(isBlocked('POST', '/api/series')).toBe(true);
expect(isBlocked('PUT', '/api/series/abc')).toBe(true);
expect(isBlocked('POST', '/api/series/abc/follow')).toBe(false);
});
describe('announcing a new event to followers', () => {
const now = new Date('2026-06-01T08:00:00Z');
const event = { id: 'e1', title: 'Camp 2027', startDate: new Date('2027-03-01T08:00:00Z'), location: 'The farm', series: { id: 's1', name: 'Family Camp', slug: 'family-camp' } };
beforeEach(() => {
jest.clearAllMocks();
mockDb.event.findMany.mockResolvedValue([event]);
mockDb.seriesFollow.findMany.mockResolvedValue([
{ user: { id: 'u1', name: 'Thandi', email: 'thandi@test.local' } },
{ user: { id: 'u2', name: 'No News', email: 'nonews@test.local' } },
]);
// Only Thandi opted in to news.
mockFilter.mockImplementation(async list => ({ recipients: list.filter(r => r.userId === 'u1'), skipped: list.length - 1 }));
mockSend.mockResolvedValue();
});
test('only public events in a series that have not started and were not announced yet', async () => {
mockDb.event.updateMany.mockResolvedValue({ count: 1 });
await series.runAnnounceJob(now);
const where = mockDb.event.findMany.mock.calls[0][0].where;
expect(where).toMatchObject({ isActive: true, isHidden: false, goLiveAt: { lte: now }, seriesId: { not: null }, seriesAnnouncedAt: null, startDate: { gt: now } });
});
test('emails followers who want news, as news', async () => {
mockDb.event.updateMany.mockResolvedValue({ count: 1 });
const out = await series.runAnnounceJob(now);
expect(out).toEqual({ announced: 1, sent: 1, skipped: 1 });
expect(mockFilter.mock.calls[0][1]).toEqual({ channel: 'email', kind: 'news' });
const mail = mockSend.mock.calls[0][0];
expect(mail.to).toBe('thandi@test.local');
expect(mail.subject).toBe('New in Family Camp: Camp 2027');
expect(mail.html).toContain('/events/e1');
expect(mail.html).toContain('unsubscribe');
expect(mail.headers['List-Unsubscribe']).toBeTruthy();
});
test('an event another run already claimed is not sent again', async () => {
mockDb.event.updateMany.mockResolvedValue({ count: 0 });
const out = await series.runAnnounceJob(now);
expect(out).toEqual({ announced: 0, sent: 0, skipped: 0 });
expect(mockSend).not.toHaveBeenCalled();
});
});
});
+37 -6
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-28, after **1.46.2** (and Hub 1.4.0). The plan itself is in
Last updated: 2026-10-28, after **1.47.1** (and Hub 1.4.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -14,6 +14,8 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.47.1 | **Event series** (§6 part 2): Event series page, Series box on events, public `/series/<slug>`, "Next in this series", follow (News & announcements email from the `series` job when a new event goes live). Migration `20261028140000_event_series` |
| 1.47.0 | **Copy an event** (§6 part 1): "Copy" opens the event editor filled in from the source (campus, sessions, options, tiers, sections, form, recipients, feedback, certificates); "New start" moves every date; allocation groups and (admins) costs copied by `POST /api/events/:id/copy-from`. No registrations, coupons, attachments, kids check-in or volunteer roles |
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
| 1.46.2 | Licence page: what's new (from WHATS-NEW.md via the Hub), withdraw update request, "Not now", urgent updates, provider notices banner (Hub 1.4.0: releases from git tags, Notices page) |
@@ -47,7 +49,8 @@ encrypted settings unreadable or break existing installs.
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. **1.47.0 — Event series & cloning** (§6), then **1.48.0 — outbound webhooks** (§8).
1. The rest of §6, one patch per part: **1.47.2 year-on-year reports** (compare a series' events),
**1.47.3 season pass** (a bundle across a series' events) — then **1.48.0 — outbound webhooks** (§8).
## Working on this repo
@@ -60,8 +63,8 @@ a test server (both done 2026-10-28).
version per feature.
- **Before tagging:** run `npx eslint src` in `frontend` over the **whole** app (the deploy runs `next build`, which fails on any lint error, e.g. an unescaped ' or " in JSX text). If a dev server is running, a full check build needs its own folder: `NEXT_DIST_DIR=.next-buildcheck npx next build` (then delete it and `git checkout frontend/tsconfig.json`).
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language. CHANGELOG.md is the
full log; WHATS-NEW.md is what churches see before updating (the Hub reads it from the newest
tag every 15 minutes and sets the latest version from the tags — nothing to do in the Hub).
full log; WHATS-NEW.md is what churches see before updating (the Hub reads it from main every
15 minutes, for tagged versions only and sets the latest version from the tags — nothing to do in the Hub).
- **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md,
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and
in-app help in `frontend/src/content/help/` (register new pages in `registry.ts`).
@@ -197,8 +200,7 @@ a test server (both done 2026-10-28).
site's heartbeat reports a newer version. Joshua then runs `scripts/deploy.sh` on the server.
- Local testing: run the Hub (`../events-hub`, ports 5200/3200), create a licence there, and start
Cross Events with `HUB_URL=http://localhost:5200` and `HUB_PUBLIC_KEY` = the key on Hub → Settings.
- `scripts/install.sh` — fresh Ubuntu server to a running site (written and syntax-checked; not yet
run on a real server).
- `scripts/install.sh` — fresh Ubuntu server to a running site (tried on a test server 2026-10-28).
## Where campuses live (1.43.0)
@@ -282,6 +284,35 @@ a test server (both done 2026-10-28).
- e2e: `volunteers.spec.ts` on the seeded hidden "E2E Serving Sunday" (role, ticket hidden publicly,
sign-up, cover request, staff take over, QR check-in, roster).
## Where copying an event lives (1.47.0)
- The copy is made in the browser: `EventModal` in `app/dashboard/supervisor/events/page.tsx` takes
`source` (create mode, `cloning`), fills its state from it (`cloneOptions` leaves out the kids and
volunteer tickets and all ids; cancelled sessions dropped; sessions keyed by the source's ids so
late joining still points right; sections loaded and mapped onto the copied options; feedback
questions without the sent/answered state), and `moveAllDates` shifts every date for "New start".
It saves through the normal create path, so **a new field on the event form is copied
automatically** if it's initialised from `base`.
- What the form doesn't hold is copied by the server afterwards: `utils/eventClone.js`
(`copyEventSetup`: allocation sets/groups except `kidsRoomSetId`, costs for admins only since
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
+19 -9
View File
@@ -34,7 +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).
**Next: 1.47.0 Event series and cloning.**
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) shipped.
**Next: 1.47.2 Year-on-year reports.**
**Repositories:**
@@ -460,15 +461,23 @@ Plan who serves where, for an event or each session. (Shipped in 1.46.0 — deci
## 6. Event series and cloning
- **Clone event:** copy an event with its options, variants, early-bird tiers,
forms, consent text, feedback form, cost template, allocation groups and
sessions (dates shifted to the new start). No registrations.
- **Series:** group separate events (Camp 2025, Camp 2026; monthly Men's
Four releases, one per part (2026-10-28): **1.47.0** clone, **1.47.1** series, **1.47.2**
year-on-year reports, **1.47.3** season pass.
- **Clone event (1.47.0 ✅):** "Copy" opens the **New event popup filled in** from the source, so
everything can be changed before saving: options, variants, early-bird tiers, form, consent
text, feedback form, sections, notification recipients, certificates, reminders, allocation
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
Breakfast) on one series page, with "Next in this series" and a series
follow/notify option.
- **Year-on-year reports:** 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:** a bundle across events in a series (extends event bundles).
- **Season pass (1.47.3):** a bundle across events in a series (extends event bundles).
---
@@ -953,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.48.0 | Event series & cloning, Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 9 | 1.47.0 ✅ 1.47.1 ✅ – 1.47.3, 1.48.0 | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
@@ -1008,6 +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. Following a series is **News & announcements**; copies keep the source's certificate and reminder settings (2026-10-28) |
| — | POPIA: WhatsApp STOP | In 1.38.0, via WAWP's incoming `message` webhook with a secret in the URL (WAWP doesn't sign webhooks) (2026-10-23) |
## Still open
+65
View File
@@ -0,0 +1,65 @@
import { test, expect, loginAs, api } from "./fixtures";
// Event series (1.47.1): a supervisor makes a series, events join it, the public series page
// lists them, an event page links to the next one, a user follows it, and the series job tells
// followers once when a new event goes live.
type Created = { id: string; title: string };
type Series = { id: string; slug: string; name: string };
type JobRun = { queued: boolean; output: { announced: number; sent: number; skipped: number } };
const day = 24 * 60 * 60 * 1000;
const event = (title: string, startsInDays: number, seriesId: string) => {
const start = new Date(Date.now() + startsInDays * day);
return { title, description: "A breakfast for the tests.", startDate: start.toISOString(), endDate: new Date(start.getTime() + 2 * 60 * 60 * 1000).toISOString(), price: 0, location: "1 Test Street, Pretoria", seriesId };
};
test.describe.serial("event series", () => {
let series: Series;
let past: Created;
let next: Created;
test("a supervisor makes a series", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/series");
await page.getByRole("button", { name: "+ New series" }).click();
await page.getByLabel("Name").fill("E2E Breakfast");
await page.getByLabel("Description").fill("Men's breakfast, once a month.");
await page.getByRole("button", { name: "Create series" }).click();
await expect(page.getByRole("status")).toContainText("E2E Breakfast");
await expect(page.getByTestId("series-row").filter({ hasText: "E2E Breakfast" })).toContainText("/series/e2e-breakfast");
const all = await api<Series[]>(page, "supervisor", "GET", "/api/series");
series = all.find((s) => s.name === "E2E Breakfast")!;
past = await api<Created>(page, "supervisor", "POST", "/api/events", event("E2E Breakfast (last month)", -30, series.id));
next = await api<Created>(page, "supervisor", "POST", "/api/events", event("E2E Breakfast (next month)", 30, series.id));
// Both are live already; announce them now so only the event made after following counts below.
await api<JobRun>(page, "admin", "POST", "/api/admin/jobs/series/run");
});
test("the series page lists what's coming up and what's past", async ({ page }) => {
await page.goto(`/series/${series.slug}`);
await expect(page.getByRole("heading", { name: "E2E Breakfast", exact: true })).toBeVisible();
await expect(page.getByText("E2E Breakfast (next month)")).toBeVisible();
await expect(page.getByTestId("series-past")).toContainText("E2E Breakfast (last month)");
});
test("an event page links to its series and the next event in it", async ({ page }) => {
await page.goto(`/events/${past.id}`);
await expect(page.getByTestId("event-series")).toContainText("Part of E2E Breakfast");
await page.getByTestId("next-in-series").click();
await expect(page).toHaveURL(new RegExp(`/events/${next.id}`));
});
test("a user follows the series and hears once about a new event", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/series/${series.slug}`);
await page.getByRole("button", { name: "Follow this series" }).click();
await expect(page.getByRole("button", { name: "Unfollow" })).toBeVisible();
await api<Created>(page, "supervisor", "POST", "/api/events", event("E2E Breakfast (in two months)", 60, series.id));
const first = await api<JobRun>(page, "admin", "POST", "/api/admin/jobs/series/run");
expect(first.output.announced).toBe(1);
const again = await api<JobRun>(page, "admin", "POST", "/api/admin/jobs/series/run");
expect(again.output.announced).toBe(0);
});
});
+42
View File
@@ -32,6 +32,48 @@ test.describe("supervisor tools", () => {
expect(created?.sessions?.filter((s) => !s.cancelled)).toHaveLength(5);
});
test("copy an event: the popup is filled in and every date moves with the new start", async ({ page }) => {
type Full = EventRow & { eventOptions: { name: string; price: number }[]; sessions: { startsAt: string; label?: string | null }[] };
const source = await api<Full>(page, "supervisor", "GET", `/api/events/${seed().events.course}`);
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/events");
await page.locator("li", { hasText: "E2E Alpha Course" }).first().getByRole("button", { name: "Copy" }).click();
await expect(page.getByRole("heading", { name: "Copy Event" })).toBeVisible();
await expect(page.getByPlaceholder("Event title")).toHaveValue("E2E Alpha Course");
await page.getByPlaceholder("Event title").fill("E2E Alpha Course (next term)");
// A week later: the start box holds the first session, so move it by seven days.
const start = page.getByTestId("copy-panel").locator('input[type="datetime-local"]');
const current = await start.inputValue();
const later = new Date(new Date(current).getTime() + 7 * 24 * 60 * 60 * 1000);
const pad = (n: number) => String(n).padStart(2, "0");
await start.fill(`${later.getFullYear()}-${pad(later.getMonth() + 1)}-${pad(later.getDate())}T${pad(later.getHours())}:${pad(later.getMinutes())}`);
const create = page.getByRole("button", { name: "Create Event" });
for (let i = 0; i < 15 && !(await create.isVisible()); i++) {
await page.getByRole("button", { name: "Next →" }).click();
}
await create.click();
await expect(page.getByRole("heading", { name: "Copy Event" })).toBeHidden();
const all = await api<EventRow[]>(page, "supervisor", "GET", "/api/events/all?includePast=true");
const row = all.find((e) => e.title === "E2E Alpha Course (next term)");
expect(row).toBeTruthy();
const copy = await api<Full>(page, "supervisor", "GET", `/api/events/${row!.id}`);
expect(copy.scheduleType).toBe("sessions");
const active = (s: Full) => s.sessions.filter((x) => !(x as { cancelled?: boolean }).cancelled);
expect(active(copy)).toHaveLength(active(source).length);
const week = 7 * 24 * 60 * 60 * 1000;
// The form works in whole minutes (the seeded times have seconds).
const minute = (iso: string) => Math.floor(new Date(iso).getTime() / 60000) * 60000;
active(copy).forEach((s, i) => {
expect(minute(s.startsAt) - minute(active(source)[i].startsAt)).toBe(week);
expect(s.label ?? null).toBe(active(source)[i].label ?? null);
});
expect(copy.eventOptions.map((o) => [o.name, o.price]).sort()).toEqual(source.eventOptions.map((o) => [o.name, o.price]).sort());
});
test("self-service kiosk: a visitor registers for a free event", async ({ page }) => {
await page.goto("/self-service");
await page.getByPlaceholder("supervisor@example.com").fill(seed().users.supervisor.email);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.46.2",
"version": "1.47.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.46.2",
"version": "1.47.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.46.2",
"version": "1.47.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -117,6 +117,9 @@ const ACTION_LABELS: Record<string, string> = {
volunteer_assigned: "Volunteer put on a roster",
volunteer_removed: "Volunteer taken off a roster",
volunteer_checked_in_manual: "Volunteer checked in by hand",
series_created: "Event series created",
series_updated: "Event series changed",
series_deleted: "Event series deleted",
};
interface AuditLogEntry {
+2 -1
View File
@@ -9,7 +9,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck, Inbox,
Baby, HandHeart,
Baby, HandHeart, Layers,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -26,6 +26,7 @@ const REPORTS_URL = "/dashboard/supervisor/reports";
const QUICK_ACTIONS = [
{ href: "/dashboard/admin/users", label: "Manage users", description: "Create, edit, change roles and passwords", icon: Users },
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
{ href: "/dashboard/supervisor/series", label: "Event series", description: "Group events like every year's camp on one page", icon: Layers },
{ href: "/dashboard/admin/registrations", label: "Manage registrations", description: "Cancel, update status, and search registrations", icon: ClipboardList },
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
@@ -10,10 +10,10 @@ import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useMyCampuses } from "@/hooks/useCampuses";
import { CampusBadge, CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
import { Calendar } from "lucide-react";
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,
@@ -870,14 +872,49 @@ const blankOptions = (): OptionDraft[] => [
interface EventModalProps {
mode: "create" | "edit";
event?: Event | null;
// Create mode: start from a copy of this event (cloning).
source?: Event | null;
onClose: () => void;
onSuccess: () => void;
}
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
/** A source event's ticket options as drafts for a copy — no ids, and without the staff-only
* kids and volunteer tickets (kids check-in and rosters aren't copied). `sourceIds` lines up
* with `drafts`, to map the source's sections onto the copy. */
function cloneOptions(source: Event): { drafts: OptionDraft[]; sourceIds: string[] } {
const staffOnly = new Set([source.kidsOptionId, source.volunteerOptionId].filter(Boolean));
const kept = (source.eventOptions || []).filter(o => !staffOnly.has(o.id));
const tier = (t: { deadline: string; price: number; stockLimit?: number | null }): Tier => ({ deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") });
return {
sourceIds: kept.map(o => o.id),
drafts: kept.map(o => ({
name: o.name,
price: String(o.price ?? ""),
isMainTicket: !!o.isMainTicket,
stockLimit: String(o.stockLimit ?? "0"),
staffPrice: o.staffPrice !== null && o.staffPrice !== undefined ? String(o.staffPrice) : "",
sessionMode: o.sessionMode || "all",
earlyBirdTiers: (o.earlyBirdTiers || []).filter(t => !t.variantId).map(tier),
variants: (o.variants || []).map(v => ({
name: v.name,
price: v.price !== null && v.price !== undefined ? String(v.price) : "",
stockLimit: String(v.stockLimit ?? "0"),
staffPrice: v.staffPrice !== null && v.staffPrice !== undefined ? String(v.staffPrice) : "",
earlyBirdTiers: (o.earlyBirdTiers || []).filter(t => t.variantId === v.id).map(tier),
})),
})),
};
}
const withoutIds = (fields: FieldDef[]): FieldDef[] => fields.map(f => ({ ...f, id: undefined }));
function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalProps) {
const { token } = useAuth();
const { settings } = useSiteSettings();
const campusInfo = useMyCampuses();
// Cloning fills "New event" from the source; everything can still be changed before saving.
const cloning = mode === "create" && !!source;
const base = mode === "edit" ? ev : source;
// Campus-scoped: events for all campuses (and other campuses') are set up by admins and
// supervisors for every campus — the server refuses changes, so the editor says so up front.
const campusReadOnly = mode === "edit" && campusInfo.scoped && !campusInfo.campuses.some(c => c.id === ev?.campusId);
@@ -888,26 +925,28 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// ── event draft ──
// New events default their location to the organisation's address (settings.org_address);
// editing an existing event always reflects its own saved location instead.
const [draft, setDraft] = useState<EventDraft>(() => ev ? {
title: ev.title || "", description: ev.description || "",
startDate: toLocalDT(ev.startDate), endDate: toLocalDT(ev.endDate),
registrationDeadline: toLocalDT(ev.registrationDeadline), goLiveAt: toLocalDT(ev.goLiveAt),
price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "",
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
requiresRegistration: ev.requiresRegistration !== false,
allowNamedAttendees: !!ev.allowNamedAttendees,
requiresParentalConsent: !!ev.requiresParentalConsent, consentText: ev.consentText || "",
consentCollectMedical: !!ev.consentCollectMedical, consentCollectEmergency: !!ev.consentCollectEmergency, consentCollectPhoto: !!ev.consentCollectPhoto,
waitlistEnabled: !!ev.waitlistEnabled, waitlistOfferHours: String(ev.waitlistOfferHours ?? 24), waitlistPartialOffers: ev.waitlistPartialOffers !== false,
allowTicketTransfer: ev.allowTicketTransfer !== false,
collectGender: !!ev.collectGender,
allowSponsorships: !!ev.allowSponsorships, sponsorshipGoalSpots: ev.sponsorshipGoalSpots ? String(ev.sponsorshipGoalSpots) : "", showSponsorshipProgress: ev.showSponsorshipProgress !== false,
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
location: ev.location || "",
campusId: ev.campusId || "",
scheduleType: ev.scheduleType === "sessions" ? "sessions" : "single",
attendanceMode: ev.attendanceMode === "pick" ? "pick" : "whole",
// editing an existing event always reflects its own saved location instead. A copy keeps the
// source's, but not its short link (those are unique) or its go-live time.
const [draft, setDraft] = useState<EventDraft>(() => base ? {
title: base.title || "", description: base.description || "",
startDate: toLocalDT(base.startDate), endDate: toLocalDT(base.endDate),
registrationDeadline: toLocalDT(base.registrationDeadline), goLiveAt: cloning ? "" : toLocalDT(base.goLiveAt),
price: String(base.price ?? ""), picture: base.picture || "", redirectUrl: cloning ? "" : base.redirectUrl || "",
isActive: cloning || base.isActive !== false, isHidden: !!base.isHidden, requiresAuth: base.requiresAuth !== false,
requiresRegistration: base.requiresRegistration !== false,
allowNamedAttendees: !!base.allowNamedAttendees,
requiresParentalConsent: !!base.requiresParentalConsent, consentText: base.consentText || "",
consentCollectMedical: !!base.consentCollectMedical, consentCollectEmergency: !!base.consentCollectEmergency, consentCollectPhoto: !!base.consentCollectPhoto,
waitlistEnabled: !!base.waitlistEnabled, waitlistOfferHours: String(base.waitlistOfferHours ?? 24), waitlistPartialOffers: base.waitlistPartialOffers !== false,
allowTicketTransfer: base.allowTicketTransfer !== false,
collectGender: !!base.collectGender,
allowSponsorships: !!base.allowSponsorships, sponsorshipGoalSpots: base.sponsorshipGoalSpots ? String(base.sponsorshipGoalSpots) : "", showSponsorshipProgress: base.showSponsorshipProgress !== false,
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 || ""));
// A campus-scoped person's new event starts at their (first) campus — they can't make
@@ -922,19 +961,30 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
});
}, [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 ──
const [sessions, setSessions] = useState<KeyedSession[]>(() => (ev?.sessions || []).map(s => keyed(toSessionDraft(s))));
// 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.
const [sessions, setSessions] = useState<KeyedSession[]>(() => cloning
? (base?.sessions || []).filter(s => !(s.cancelled || s.cancelledAt)).map(s => ({ ...toSessionDraft(s), id: undefined, hasCheckIns: false, key: s.id }))
: (base?.sessions || []).map(s => keyed(toSessionDraft(s))));
const [attendance, setAttendance] = useState<AttendanceDraft>(() => ({
lateJoinEnabled: !!ev?.lateJoinEnabled,
lateJoinUntilKey: ev?.lateJoinUntilSessionId || "",
latePricing: ev?.latePricing || "full",
lateFixedPrice: ev?.lateFixedPrice !== null && ev?.lateFixedPrice !== undefined ? String(ev.lateFixedPrice) : "",
checkInWindow: ev?.checkInWindow || "whole_day",
remindersEnabled: !!ev?.sessionReminders,
reminderOffsets: ev?.sessionReminders?.offsets || [180],
reminderChannels: ev?.sessionReminders?.channels || ["email", "whatsapp"],
certificatesEnabled: !!ev?.certificatesEnabled,
certificate: { ...defaultCertificate(), ...(ev?.certificateConfig || {}) },
lateJoinEnabled: !!base?.lateJoinEnabled,
lateJoinUntilKey: base?.lateJoinUntilSessionId || "",
latePricing: base?.latePricing || "full",
lateFixedPrice: base?.lateFixedPrice !== null && base?.lateFixedPrice !== undefined ? String(base.lateFixedPrice) : "",
checkInWindow: base?.checkInWindow || "whole_day",
remindersEnabled: !!base?.sessionReminders,
reminderOffsets: base?.sessionReminders?.offsets || [180],
reminderChannels: base?.sessionReminders?.channels || ["email", "whatsapp"],
certificatesEnabled: !!base?.certificatesEnabled,
certificate: { ...defaultCertificate(), ...(base?.certificateConfig || {}) },
}));
const updAttendance = (patch: Partial<AttendanceDraft>) => setAttendance(a => ({ ...a, ...patch }));
const multi = draft.scheduleType === "sessions";
@@ -950,7 +1000,12 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const [changedNotice, setChangedNotice] = useState<{ eventId: string; moved: number; cancelled: number } | null>(null);
// ── options (with per-variant tiers) ──
const [cloned] = useState(() => (cloning && source ? cloneOptions(source) : null));
const [options, setOptions] = useState<OptionDraft[]>(() => {
if (cloned) {
if (!cloned.drafts.length) return blankOptions();
return cloned.drafts.some(o => o.isMainTicket) ? cloned.drafts : cloned.drafts.map((o, i) => ({ ...o, isMainTicket: i === 0 }));
}
if (!ev?.eventOptions?.length) return blankOptions();
return ev.eventOptions.map((o) => {
const allTiers = o.earlyBirdTiers || [];
@@ -982,15 +1037,32 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// ── sections (staged for create mode) ──
const [sectionDrafts, setSectionDrafts] = useState<SectionDraft[]>([]);
// A copy takes the source's sections onto the copied options. A section whose options were all
// left out (the kids or volunteer ticket) is dropped rather than widened to "all options".
useEffect(() => {
if (!cloned || !source?.id || !token) return;
apiFetch<Section[]>(`/api/sections?eventId=${source.id}`, { authToken: token }).then(list => {
const drafts: SectionDraft[] = [];
for (const sec of list || []) {
const ids = (sec.allowedOptions || []).map(o => o.eventOptionId);
const optionIndices = ids.map(id => cloned.sourceIds.indexOf(id)).filter(i => i >= 0);
if (ids.length && !optionIndices.length) continue;
drafts.push({ name: sec.name, optionIndices });
}
setSectionDrafts(drafts);
}).catch(() => {});
}, [cloned, source?.id, token]);
// ── attachments (staged for create mode; uploaded after event is created) ──
const [stagedFiles, setStagedFiles] = useState<File[]>([]);
// ── notify recipients (staged for create mode; saved after event is created) ──
const [notifyRecipientDrafts, setNotifyRecipientDrafts] = useState<NotifyUser[]>([]);
const [notifyRecipientDrafts, setNotifyRecipientDrafts] = useState<NotifyUser[]>(() => (cloning ? source?.notifyRecipients || [] : []));
// ── form ──
const [formDef, setFormDef] = useState<EventFormDef>({ isRequired: false, fields: [] });
const [formDef, setFormDef] = useState<EventFormDef>(() => (cloning && source?.form
? { isRequired: !!source.form.isRequired, fields: withoutIds(toFieldDefs(source.form.fields)) }
: { isRequired: false, fields: [] }));
const formLoadedRef = useRef(false);
useEffect(() => {
if (mode !== "edit" || !ev?.id || formLoadedRef.current) return;
@@ -1005,16 +1077,26 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const [fbDef, setFbDef] = useState<EventFormDef>({ isRequired: false, fields: [] });
const [fbSettings, setFbSettings] = useState({ sendAt: "", audience: "checked_in", anonymous: false, intro: "" });
const [fbMeta, setFbMeta] = useState<{ sentAt: string | null; invited: number; responses: number } | null>(null);
// How far a copy's dates have been moved so far, for the source's feedback date if it arrives
// after the move.
const shiftedRef = useRef(0);
const feedbackFrom = mode === "edit" ? ev?.id : cloning ? source?.id : undefined;
useEffect(() => {
if (mode !== "edit" || !ev?.id || !token) return;
apiFetch<FeedbackFormResponse | null>(`/api/feedback/event/${ev.id}`, { authToken: token }).then(f => {
if (!feedbackFrom || !token) return;
apiFetch<FeedbackFormResponse | null>(`/api/feedback/event/${feedbackFrom}`, { authToken: token }).then(f => {
if (!f) return;
setFbEnabled(true);
if (cloning) {
// The questions and settings only — not whether it was sent or who answered.
setFbDef({ isRequired: false, fields: withoutIds(toFieldDefs(f.fields)) });
setFbSettings({ sendAt: f.sendAt ? toLocalDT(new Date(new Date(f.sendAt).getTime() + shiftedRef.current)) : "", audience: f.audience || "checked_in", anonymous: !!f.anonymous, intro: f.intro || "" });
return;
}
setFbDef({ isRequired: false, fields: toFieldDefs(f.fields) });
setFbSettings({ sendAt: toLocalDT(f.sendAt), audience: f.audience || "checked_in", anonymous: !!f.anonymous, intro: f.intro || "" });
setFbMeta({ sentAt: f.sentAt, invited: f.invited, responses: f.responses });
}).catch(() => {});
}, [mode, ev?.id, token]);
}, [feedbackFrom, cloning, token]);
const enableFeedback = (on: boolean) => {
setFbEnabled(on);
if (on && fbDef.fields.length === 0) {
@@ -1050,9 +1132,24 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const upd = (patch: Partial<EventDraft>) => setDraft(d => ({ ...d, ...patch }));
// ── copy: move every date by the same amount (sessions, deadline, early birds, feedback) ──
const copyStart = multi ? scheduleBounds?.start || "" : draft.startDate;
const moveAllDates = (newStart: string) => {
const ms = newStart && copyStart ? new Date(newStart).getTime() - new Date(copyStart).getTime() : NaN;
if (!ms || isNaN(ms)) return;
shiftedRef.current += ms;
const sh = (v: string) => (v ? toLocalDT(new Date(new Date(v).getTime() + ms)) : v);
const shTiers = (tiers: Tier[]) => tiers.map(t => ({ ...t, deadline: sh(t.deadline) }));
setDraft(d => ({ ...d, startDate: sh(d.startDate), endDate: sh(d.endDate), registrationDeadline: sh(d.registrationDeadline), goLiveAt: sh(d.goLiveAt) }));
setSessions(list => list.map(s => ({ ...s, startsAt: sh(s.startsAt), endsAt: sh(s.endsAt) })));
setOptions(list => list.map(o => ({ ...o, earlyBirdTiers: shTiers(o.earlyBirdTiers), variants: o.variants.map(v => ({ ...v, earlyBirdTiers: shTiers(v.earlyBirdTiers) })) })));
setFbSettings(s => ({ ...s, sendAt: sh(s.sendAt) }));
};
// ── auto-fill first option's price from the event base price (create mode only, until the
// user directly edits that option's price — then it becomes independent) ──
const [mainPriceTouched, setMainPriceTouched] = useState(false);
// user directly edits that option's price — then it becomes independent). A copy keeps its
// own option prices. ──
const [mainPriceTouched, setMainPriceTouched] = useState(cloning);
useEffect(() => {
if (mode !== "create" || mainPriceTouched) return;
setOptions(prev => {
@@ -1307,6 +1404,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
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;
@@ -1323,10 +1421,15 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
if (formDef.isRequired || formDef.fields.length > 0) {
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) };
}
if (cloning) body.clonedFromId = source!.id;
const created = await apiFetch<Event>("/api/events", { method: "POST", authToken: token || undefined, body });
if (created?.id) {
await saveOptions(created.id);
await saveFeedback(created.id);
// Groups (and planned costs, for admins) aren't in this form; the server copies them.
if (cloning) {
await apiFetch(`/api/events/${created.id}/copy-from`, { method: "POST", authToken: token || undefined, body: { fromEventId: source!.id } });
}
// Fetch event to get final option IDs for sections
const fullEvent = await apiFetch<Event>(`/api/events/${created.id}`).catch(() => null);
const createdOptions = fullEvent?.eventOptions || [];
@@ -1399,14 +1502,14 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl">
{/* Header */}
<div className="flex items-center justify-between px-5 pt-5 pb-3 border-b">
<h2 className="text-lg font-semibold">{mode === "create" ? "New Event" : "Edit Event"}</h2>
<h2 className="text-lg font-semibold">{cloning ? "Copy Event" : mode === "create" ? "New Event" : "Edit Event"}</h2>
<button type="button" className="text-gray-400 hover:text-gray-700 text-xl leading-none" onClick={onClose}>×</button>
</div>
{/* Step bar */}
<div className="flex items-center gap-1 px-5 py-3 border-b overflow-x-auto">
{STEPS.map((s, i) => {
const clickable = mode === "edit";
const clickable = mode === "edit" || cloning;
return (
<button key={i} type="button"
onClick={clickable ? () => { setStep(i as StepIdx); if (i === 1) setPricingSubstep(0); } : undefined}
@@ -1462,6 +1565,17 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
{/* ── Step 0: Basic Details ── */}
{step === 0 && (
<div className="space-y-3">
{cloning && (
<div className="p-3 border rounded bg-brand-50 border-brand-200 text-sm space-y-2" data-testid="copy-panel">
<p className="text-gray-700">
A copy of <span className="font-medium">{source?.title}</span> — check each step before saving. Registrations, coupons,
attachments, kids check-in and volunteer roles aren&apos;t copied.
</p>
<DTInput label="New start" value={copyStart} onChange={moveAllDates}
hint={`Moves every date with it: the end${multi ? ", the sessions" : ""}, registration deadline, early-bird deadlines and feedback date.`} />
{copyStart && new Date(copyStart) < new Date() && <p className="text-xs text-amber-700">This copy still starts in the past.</p>}
</div>
)}
<div>
<label className="block text-xs text-gray-600 mb-1">Title <span className="text-red-500">*</span></label>
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.title} onChange={e => upd({ title: e.target.value })} placeholder="Event title" />
@@ -1519,11 +1633,19 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
{campusInfo.scoped ? <option value="" disabled>Choose your campus…</option> : <option value="">All campuses</option>}
{campusInfo.campuses.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
{/* An archived campus stays on events that already have it. */}
{ev?.campus && !campusInfo.campuses.some(c => c.id === ev.campus!.id) && <option value={ev.campus.id}>{ev.campus.name} (archived)</option>}
{base?.campus && !campusInfo.campuses.some(c => c.id === base.campus!.id) && <option value={base.campus.id}>{base.campus.name} (archived)</option>}
</select>
<p className="text-[10px] text-gray-400 mt-0.5">&quot;All campuses&quot; events show at every campus — for church-wide conferences and camps.</p>
</div>
)}
<div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="event-series">Series</label>
<select id="event-series" className="w-full border rounded px-3 py-2 text-sm bg-white" value={draft.seriesId} onChange={e => upd({ seriesId: e.target.value })}>
<option value="">Not in a series</option>
{seriesList.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<p className="text-[10px] text-gray-400 mt-0.5">Groups separate events, like every year&apos;s camp, on one page with &quot;Next in this series&quot;. <a href="/dashboard/supervisor/series" target="_blank" className="underline">Manage series</a></p>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Location</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.location} onChange={e => upd({ location: e.target.value })} placeholder="e.g. 123 Church St, City" />
@@ -1882,7 +2004,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// ─── event card ───────────────────────────────────────────────────────────────
function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
function EventCard({ ev, onEdit, onCopy }: { ev: Event; onEdit: () => void; onCopy: () => void }) {
const isPast = ev.endDate && new Date(ev.endDate) < new Date();
const isInactive = ev.isActive === false;
const isClosed = ev.cashupStatus === "closed";
@@ -1896,13 +2018,20 @@ function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
{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()}` : ""}</>}
{ev.price != null ? <span className="ml-2">R{Number(ev.price).toFixed(0)}</span> : ""}
</div>
</div>
<span className="text-xs text-brand-600 shrink-0 mt-0.5">Edit →</span>
<div className="flex items-center gap-3 shrink-0 mt-0.5">
<button type="button" title="Make a new event from this one" className="inline-flex items-center gap-1 text-xs text-gray-600 hover:text-brand-700"
onClick={e => { e.stopPropagation(); onCopy(); }}>
<Copy className="w-3.5 h-3.5" /> Copy
</button>
<span className="text-xs text-brand-600">Edit →</span>
</div>
</li>
);
}
@@ -1970,6 +2099,19 @@ export default function ManageEventsPage() {
} catch { setEditingEvent(ev); }
setModalMode("edit");
};
// A copy needs the whole source event (options, tiers, form, recipients).
const [copyFrom, setCopyFrom] = useState<Event | null>(null);
const openCopy = async (ev: Event) => {
try {
const full = await apiFetch<Event>(`/api/events/${ev.id}`, { authToken: token || undefined });
setEditingEvent(null);
setCopyFrom(full);
setModalMode("create");
} catch (e) {
setError(errorMessage(e) || "Couldn't load the event to copy");
}
};
const closeModal = () => { setModalMode(null); setEditingEvent(null); setCopyFrom(null); };
return (
<div className="max-w-4xl mx-auto w-full p-6">
@@ -2010,13 +2152,13 @@ export default function ManageEventsPage() {
<div className="border rounded-xl bg-white shadow-sm p-4">
{filtered.length === 0
? <p className="text-sm text-gray-500">{events.length === 0 ? 'No events yet. Click "+ Add Event" to create one.' : "No events match the current filters."}</p>
: <ul className="space-y-2">{filtered.map(ev => <EventCard key={ev.id} ev={ev} onEdit={() => openEdit(ev)} />)}</ul>
: <ul className="space-y-2">{filtered.map(ev => <EventCard key={ev.id} ev={ev} onEdit={() => openEdit(ev)} onCopy={() => openCopy(ev)} />)}</ul>
}
</div>
{modalMode && (
<EventModal mode={modalMode} event={editingEvent}
onClose={() => { setModalMode(null); setEditingEvent(null); }}
<EventModal mode={modalMode} event={editingEvent} source={copyFrom}
onClose={closeModal}
onSuccess={loadEvents} />
)}
</div>
@@ -9,7 +9,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck,
Baby, HandHeart,
Baby, HandHeart, Layers,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -26,6 +26,7 @@ const REPORTS_URL = "/dashboard/supervisor/reports";
const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
{ href: "/dashboard/supervisor/series", label: "Event series", description: "Group events like every year's camp on one page", icon: Layers },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
{ href: "/dashboard/staff/kids", label: "Kids check-in", description: "Check children in and out with pickup codes", icon: Baby },
@@ -0,0 +1,190 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Layers, ExternalLink, Users } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { CampusBadge } from "@/components/shared/CampusFilter";
import type { EventSeries } from "@/types";
type Draft = { id?: string; name: string; slug: string; description: string; picture: string };
const blank: Draft = { name: "", slug: "", description: "", picture: "" };
function SeriesForm({ draft, onChange, onSave, onCancel, saving }: { draft: Draft; onChange: (d: Draft) => void; onSave: () => void; onCancel: () => void; saving: boolean }) {
const upd = (patch: Partial<Draft>) => onChange({ ...draft, ...patch });
return (
<div className="border rounded-xl p-4 bg-gray-50 space-y-3" data-testid="series-form">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="series-name">Name</label>
<input id="series-name" className="w-full border rounded px-3 py-2 text-sm" value={draft.name} onChange={e => upd({ name: e.target.value })} placeholder="e.g. Family Camp" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="series-slug">Web address</label>
<div className="flex items-center text-sm">
<span className="text-gray-400 mr-1">/series/</span>
<input id="series-slug" className="w-full border rounded px-3 py-2 text-sm" value={draft.slug} onChange={e => upd({ slug: e.target.value })} placeholder="made from the name" />
</div>
</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="series-description">Description</label>
<textarea id="series-description" rows={3} className="w-full border rounded px-3 py-2 text-sm" value={draft.description} onChange={e => upd({ description: e.target.value })} placeholder="Shown at the top of the series page" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1" htmlFor="series-picture">Picture (web address, optional)</label>
<input id="series-picture" className="w-full border rounded px-3 py-2 text-sm" value={draft.picture} onChange={e => upd({ picture: e.target.value })} placeholder="https://…" />
</div>
<div className="flex gap-2">
<button type="button" onClick={onSave} disabled={saving || !draft.name.trim()} className="px-4 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
{saving ? "Saving…" : draft.id ? "Save series" : "Create series"}
</button>
<button type="button" onClick={onCancel} className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50">Cancel</button>
</div>
</div>
);
}
export default function SeriesPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const isAdmin = user?.role === "admin";
const canView = user?.role === "admin" || user?.role === "supervisor";
useEffect(() => {
if (!loading && !user) router.replace("/login");
}, [user, loading, router]);
const [list, setList] = useState<EventSeries[]>([]);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
if (!token) return;
try {
setList(await apiFetch<EventSeries[]>("/api/series", { authToken: token }));
} catch (e) {
setError(errorMessage(e) || "Couldn't load the series");
}
}, [token]);
useEffect(() => { load(); }, [load]);
const save = async () => {
if (!draft) return;
setSaving(true); setError(null); setNotice(null);
try {
const body = { name: draft.name, slug: draft.slug || undefined, description: draft.description, picture: draft.picture };
await apiFetch(draft.id ? `/api/series/${draft.id}` : "/api/series", { method: draft.id ? "PUT" : "POST", authToken: token || undefined, body });
setNotice(draft.id ? "Series saved." : `"${draft.name.trim()}" created — choose it in the event editor to add events.`);
setDraft(null);
await load();
} catch (e) {
setError(errorMessage(e) || "Couldn't save the series");
} finally {
setSaving(false);
}
};
const remove = async (s: EventSeries) => {
if (!window.confirm(`Delete "${s.name}"? Its ${s.eventCount} event${s.eventCount === 1 ? "" : "s"} stay, just without a series, and ${s.followers} follower${s.followers === 1 ? "" : "s"} won't hear about new ones.`)) return;
setError(null); setNotice(null);
try {
await apiFetch(`/api/series/${s.id}`, { method: "DELETE", authToken: token || undefined });
setNotice(`"${s.name}" deleted.`);
await load();
} catch (e) {
setError(errorMessage(e) || "Couldn't delete the series");
}
};
const now = Date.now();
return (
<div className="max-w-4xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Layers className="w-5 h-5 text-brand-600" />
</div>
<div>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Event series</h1>
<p className="text-sm text-gray-500 mt-0.5">Group events like every year&apos;s camp on one page</p>
</div>
</div>
{!draft && (
<button type="button" className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 shadow-sm font-medium" onClick={() => { setDraft({ ...blank }); setNotice(null); }}>+ New series</button>
)}
</div>
{!canView && !loading && <div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor or admin access.</div>}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{notice && <div className="p-3 mb-3 border rounded bg-green-50 text-green-800 text-sm" role="status">{notice}</div>}
{draft && !draft.id && (
<div className="mb-4"><SeriesForm draft={draft} onChange={setDraft} onSave={save} onCancel={() => setDraft(null)} saving={saving} /></div>
)}
{list.length === 0 ? (
<p className="text-sm text-gray-500 border rounded-xl bg-white p-4">No series yet. A series groups separate events — Camp 2025, Camp 2026 — on one page, with &quot;Next in this series&quot; and followers.</p>
) : (
<ul className="space-y-3">
{list.map(s => (
<li key={s.id} className="border rounded-xl bg-white shadow-sm p-4" data-testid="series-row">
{draft?.id === s.id ? (
<SeriesForm draft={draft} onChange={setDraft} onSave={save} onCancel={() => setDraft(null)} saving={saving} />
) : (
<>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="min-w-0">
<div className="font-medium text-gray-900">{s.name}</div>
<div className="text-xs text-gray-500 mt-0.5 flex items-center gap-3 flex-wrap">
<Link href={`/series/${s.slug}`} target="_blank" className="inline-flex items-center gap-1 text-brand-700 hover:underline">/series/{s.slug} <ExternalLink className="w-3 h-3" /></Link>
<span className="inline-flex items-center gap-1"><Users className="w-3 h-3" /> {s.followers} follower{s.followers === 1 ? "" : "s"}</span>
</div>
{s.description && <p className="text-sm text-gray-600 mt-1 whitespace-pre-line">{s.description}</p>}
</div>
<div className="flex gap-2 shrink-0">
<button type="button" className="text-xs px-3 py-1.5 rounded border bg-white hover:bg-gray-50"
onClick={() => { setDraft({ id: s.id, name: s.name, slug: s.slug, description: s.description || "", picture: s.picture || "" }); setNotice(null); }}>Edit</button>
{isAdmin && <button type="button" className="text-xs px-3 py-1.5 rounded bg-red-50 text-red-700 border border-red-200" onClick={() => remove(s)}>Delete</button>}
</div>
</div>
<div className="mt-3">
{s.events.length === 0 ? (
<p className="text-xs text-gray-500">No events yet — choose this series in the event editor (Basic details).</p>
) : (
<ul className="divide-y border rounded-lg">
{s.events.map(e => (
<li key={e.id} className="px-3 py-2 flex items-center justify-between gap-2 text-sm flex-wrap">
<span className="flex items-center gap-2 min-w-0">
<span className="truncate">{e.title}</span>
{!e.isActive && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Inactive</span>}
{e.isHidden && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Hidden</span>}
{new Date(e.endDate).getTime() < now && <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Past</span>}
<CampusBadge campus={e.campus} />
</span>
<span className="text-xs text-gray-500">{new Date(e.startDate).toLocaleDateString()}</span>
</li>
))}
</ul>
)}
{s.eventCount > s.events.length && (
<p className="text-[11px] text-gray-400 mt-1">{s.eventCount - s.events.length} more at other campuses.</p>
)}
</div>
</>
)}
</li>
))}
</ul>
)}
</div>
);
}
+6 -1
View File
@@ -55,13 +55,16 @@ type Event = {
sessions?: EventSession[];
registrationClosesAt?: string;
lateJoin?: LateJoinInfo | null;
series?: EventSeriesRef | null;
nextInSeries?: NextInSeries | null;
};
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { formatDateTimeRange } from "@/lib/date";
import { BundleOffers } from "@/components/shared/BundleDeals";
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
import type { EventSeriesRef, EventSession, LateJoinInfo, NextInSeries, PublicBundle } from "@/types";
import { SeriesLinks } from "@/components/events/SeriesLinks";
import { isPickOption, latePrice, registrationClosesAt, scheduleSummary } from "@/lib/sessions";
import { SessionSchedule } from "@/components/events/SessionSchedule";
import { appUrl, appName } from "@/lib/siteConfig";
@@ -222,6 +225,8 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
<SeriesLinks series={event.series} next={event.nextInSeries} />
{event.scheduleType === "sessions" && (event.sessions || []).length > 0 && (
<SessionSchedule sessions={event.sessions || []} defaultLocation={event.location} />
)}
+104
View File
@@ -0,0 +1,104 @@
import { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { EventCard } from "@/components/events/EventCard";
import { ApiImage } from "@/components/shared/ApiImage";
import { CampusBadge } from "@/components/shared/CampusFilter";
import { FollowSeriesButton } from "@/components/events/FollowSeriesButton";
import { apiFetch, apiFetchListForPrerender, ApiError } from "@/lib/api";
import { formatDateTimeRange } from "@/lib/date";
import { ArrowLeft, CalendarX, Layers } from "lucide-react";
import type { Event as FullEvent } from "@/types";
export const revalidate = 60;
type Event = React.ComponentProps<typeof EventCard>["event"] & { cashupStatus?: string; nextSession?: { startsAt: string } | null };
type PastEvent = { id: string; title: string; startDate: string; endDate: string; campus?: FullEvent["campus"] };
type Series = { id: string; name: string; slug: string; description: string | null; picture: string | null; past: PastEvent[] };
async function loadSeries(slug: string): Promise<Series | null> {
try {
return await apiFetch<Series>(`/api/series/by-slug/${encodeURIComponent(slug)}`, { nextOptions: { next: { revalidate } } });
} catch (e) {
if (e instanceof ApiError && e.status === 404) return null;
throw e;
}
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
const series = await loadSeries(slug);
if (!series) return {};
return {
title: series.name,
description: series.description || `Every ${series.name} event, past and upcoming.`,
alternates: { canonical: `/series/${series.slug}` },
};
}
/** A series' page: what it is, the upcoming events in it (any campus), and past ones. */
export default async function SeriesPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const series = await loadSeries(slug);
if (!series) notFound();
const events = await apiFetchListForPrerender<Event>(`/api/events?series=${encodeURIComponent(series.slug)}`, { nextOptions: { next: { revalidate } } });
const now = Date.now();
const upcoming = (events || [])
.filter(e => e.cashupStatus !== "closed" && new Date(e.endDate || e.startDate).getTime() > now)
.sort((a, b) => new Date(a.nextSession?.startsAt || a.startDate).getTime() - new Date(b.nextSession?.startsAt || b.startDate).getTime());
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 py-10 px-4 max-w-7xl mx-auto w-full">
<Link href="/events" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4">
<ArrowLeft className="w-4 h-4" /> All events
</Link>
<div className="border rounded-2xl p-6 mb-8 bg-white shadow-sm flex flex-col sm:flex-row gap-6">
{series.picture && <ApiImage src={series.picture} alt="" className="w-full sm:w-56 aspect-video object-cover rounded-xl shrink-0" />}
<div className="min-w-0 flex-1">
<p className="text-xs uppercase tracking-wide text-brand-700 flex items-center gap-1"><Layers className="w-3.5 h-3.5" /> Series</p>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mt-1">{series.name}</h1>
{series.description && <p className="text-gray-700 mt-2 whitespace-pre-line">{series.description}</p>}
<div className="mt-4">
<FollowSeriesButton slug={series.slug} />
</div>
</div>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Coming up</h2>
{upcoming.length === 0 ? (
<div className="text-center py-12 border rounded-xl bg-gray-50 mb-10">
<CalendarX className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-gray-500 text-sm">Nothing planned yet — follow the series to hear when the next one is out.</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-3 sm:grid-cols-2 mb-10">
{upcoming.map(event => <EventCard key={event.id} event={event} />)}
</div>
)}
{series.past.length > 0 && (
<>
<h2 className="text-xl font-semibold text-gray-900 mb-3">Past events</h2>
<ul className="border rounded-xl bg-white divide-y" data-testid="series-past">
{series.past.map(e => (
<li key={e.id} className="px-4 py-3 flex flex-wrap items-center justify-between gap-2">
<span className="font-medium text-gray-800">{e.title}</span>
<span className="text-sm text-gray-500 flex items-center gap-2">
<CampusBadge campus={e.campus} className="text-[10px]" />
{formatDateTimeRange(e.startDate, e.endDate)}
</span>
</li>
))}
</ul>
</>
)}
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,61 @@
"use client";
import { useEffect, useState } from "react";
import { Bell, BellOff } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
/** Follow / unfollow a series. Signed out, it links to the login page and back. */
export function FollowSeriesButton({ slug }: { slug: string }) {
const { user, token, loading } = useAuth();
const [following, setFollowing] = useState<boolean | null>(null);
const [seriesId, setSeriesId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// The page itself is cached for everyone, so whether this person follows (and the series as it
// is now) is asked separately.
useEffect(() => {
if (!token) return;
apiFetch<{ id: string; following: boolean }>(`/api/series/by-slug/${encodeURIComponent(slug)}`, { authToken: token })
.then(s => { setSeriesId(s.id); setFollowing(!!s.following); })
.catch(() => setFollowing(null));
}, [slug, token]);
if (loading) return null;
if (!user) {
return (
<a href={`/login?redirect=/series/${slug}`} className="inline-flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50">
<Bell className="w-4 h-4" /> Log in to follow
</a>
);
}
const toggle = async () => {
setBusy(true); setError(null);
try {
const res = await apiFetch<{ following: boolean }>(`/api/series/${seriesId}/follow`, { method: following ? "DELETE" : "POST", authToken: token || undefined });
setFollowing(res.following);
} catch (e) {
setError(errorMessage(e) || "Couldn't change that");
} finally {
setBusy(false);
}
};
return (
<div>
<button type="button" onClick={toggle} disabled={busy || following === null || !seriesId}
className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg font-medium disabled:opacity-50 ${following ? "border bg-white text-gray-700 hover:bg-gray-50" : "bg-brand-600 text-white hover:bg-brand-700"}`}>
{following ? <><BellOff className="w-4 h-4" /> Unfollow</> : <><Bell className="w-4 h-4" /> Follow this series</>}
</button>
<p className="text-xs text-gray-500 mt-1.5" role="status">
{following
? "You'll get an email when a new event in this series is out (if you get news and announcements from us)."
: "Get an email when the next one is out."}
</p>
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
</div>
);
}
@@ -0,0 +1,27 @@
import Link from "next/link";
import { ArrowRight, Layers } from "lucide-react";
import { formatDateTimeRange } from "@/lib/date";
import type { EventSeriesRef, NextInSeries } from "@/types";
/** On an event page: the series it belongs to, and the next event in that series. */
export function SeriesLinks({ series, next }: { series?: EventSeriesRef | null; next?: NextInSeries | null }) {
if (!series) return null;
return (
<div className="border rounded-xl p-4 bg-white shadow-sm space-y-2" data-testid="event-series">
<p className="text-sm text-gray-600 flex items-center gap-1.5">
<Layers className="w-4 h-4 shrink-0 text-brand-600" />
Part of <Link href={`/series/${series.slug}`} className="font-medium text-brand-700 hover:underline">{series.name}</Link>
</p>
{next && (
<Link href={`/events/${next.id}`} className="flex items-center justify-between gap-3 rounded-lg bg-brand-50 px-3 py-2 hover:bg-brand-100" data-testid="next-in-series">
<span className="min-w-0">
<span className="block text-[11px] uppercase tracking-wide text-brand-700">Next in this series</span>
<span className="block text-sm font-medium text-gray-900 truncate">{next.title}</span>
<span className="block text-xs text-gray-500">{formatDateTimeRange(next.startDate, next.endDate)}</span>
</span>
<ArrowRight className="w-4 h-4 shrink-0 text-brand-700" />
</Link>
)}
</div>
);
}
+6
View File
@@ -36,6 +36,7 @@ import { staffTicketScanningHelpContent } from "./staff-ticket-scanning";
import { userFormsHelpContent } from "./user-forms";
import { kidsDeskHelpContent, kidsRoomsHelpContent, kidsSetupHelpContent, userKidsHelpContent } from "./kids";
import { staffVolunteersHelpContent, userServingHelpContent, volunteerRostersHelpContent } from "./volunteers";
import { seriesHelpContent } from "./series";
type HelpRegistryEntry = {
/** Unique, stable identifier for this guide. */
@@ -182,6 +183,11 @@ const REGISTRY: HelpRegistryEntry[] = [
matches: pathname => pathname === "/dashboard/user/kids",
content: userKidsHelpContent,
},
{
slug: "supervisor-series",
matches: pathname => pathname === "/dashboard/supervisor/series",
content: seriesHelpContent,
},
{
slug: "supervisor-volunteers",
matches: pathname => pathname === "/dashboard/supervisor/volunteers",
+40
View File
@@ -0,0 +1,40 @@
import React from "react";
import { Layers, Calendar, Bell, MapPin, Copy } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for /dashboard/supervisor/series. */
export const seriesHelpContent: HelpContent = {
title: "Event series",
subtitle: "Group separate events — every year's camp, the monthly breakfast — on one page.",
tabs: [
{
key: "overview",
label: "Overview",
icon: Layers,
content: (
<div className="space-y-4">
<p>A series groups events that each have their own registrations, like Camp 2025 and Camp 2026. It isn&apos;t the same as a multi-session event, which is one event on several dates with one ticket.</p>
<div className="space-y-4">
<GuideItem icon={Layers} title="Make a series" tone="brand">
&quot;+ New series&quot;: a name, the web address of its page (<code>/series/…</code>), and optionally a description and a picture.
</GuideItem>
<GuideItem icon={Calendar} title="Put events in it" tone="green">
In the event editor (Basic details) choose the <strong>Series</strong>. Copying an event keeps its series, so next year&apos;s copy joins it straight away.
</GuideItem>
<GuideItem icon={Copy} title="What people see" tone="violet">
The series page lists what&apos;s coming up and past events. Each event in a series links to its series and to the next event in it (&quot;Next in this series&quot;). Hidden, inactive and not-yet-live events are left out.
</GuideItem>
<GuideItem icon={Bell} title="Following" tone="amber">
People with an account can follow a series on its page. When a new event in the series goes live (and before it starts) followers get one email. It counts as <strong>News &amp; announcements</strong>, so only people who chose to get news receive it, with an unsubscribe link.
</GuideItem>
<GuideItem icon={MapPin} title="Campuses" tone="gray">
A series can have events at different campuses. Supervisors limited to some campuses see the series, but only their campuses&apos; events in it.
</GuideItem>
</div>
<p className="text-xs text-gray-500">Deleting a series (admins) keeps its events — they just aren&apos;t in a series any more — and stops it for its followers.</p>
</div>
),
},
],
};
@@ -1,5 +1,5 @@
import React from "react";
import { Calendar, ListChecks, Layers, FileText, Eye, Bell, Paperclip, Lock, MessageSquareHeart, CalendarDays, ClipboardCheck } from "lucide-react";
import { Calendar, ListChecks, Layers, FileText, Eye, Bell, Paperclip, Lock, MessageSquareHeart, CalendarDays, ClipboardCheck, Copy } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -16,6 +16,9 @@ export const supervisorEventsHelpContent: HelpContent = {
<div className="space-y-4">
<p>Search or toggle Include past / Show inactive (admin only) / Show closed to find an event, then tap &quot;+ Add Event&quot; or click a card to edit.</p>
<div className="space-y-4">
<GuideItem icon={Copy} title="Copy an event" tone="brand">
&quot;Copy&quot; on an event card opens the editor filled in from that event: details, campus, sessions, ticket options, variants and early birds, sections, the form, notifications, feedback questions, certificates and reminders. Set the <strong>New start</strong> at the top and every date moves with it — the end, the sessions, the registration deadline, early-bird deadlines and the feedback date. Check each step, change what&apos;s different this time, and save. Groups (rooms, buses, teams) are copied too, and for admins the planned costs. Registrations, coupons, attachments, kids check-in and volunteer roles are not.
</GuideItem>
<GuideItem icon={Lock} title="Closed events" tone="rose">
An event that&apos;s been cashed up shows a banner and can&apos;t be edited — it must be reopened from Cashup first.
</GuideItem>
@@ -65,7 +68,7 @@ export const supervisorEventsHelpContent: HelpContent = {
For multi-session events: late joining (until which session, and a full, pro-rata or fixed late price for main tickets — pro-rata rounds to the nearest R10), the check-in window (the whole day of a session, or only from 2 hours before it until it ends), and reminders before each session. For any event: completion certificates — four designs, your wording and signature, and who qualifies. &quot;Preview certificate&quot; shows a sample.
</GuideItem>
</div>
<p className="text-xs text-gray-500">When creating a new event, later steps unlock as you complete the ones before them; editing an existing event lets you jump to any step freely.</p>
<p className="text-xs text-gray-500">When creating a new event, later steps unlock as you complete the ones before them; editing an existing event, or a copy, lets you jump to any step freely.</p>
</div>
),
},
+20
View File
@@ -178,11 +178,31 @@ 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;
volunteerOptionId?: string | null;
// Set by GET /api/events while an early-bird tier is still running.
earlyBirdEndsAt?: string | null;
isSoldOut?: boolean;
};
export type EventSeriesRef = { id: string; name: string; slug: string };
export type NextInSeries = { id: string; title: string; startDate: string; endDate: string; location?: string | null };
/** GET /api/series (supervisors): a series with its events and number of followers. */
export type EventSeries = EventSeriesRef & {
description: string | null;
picture: string | null;
followers: number;
eventCount: number;
events: { id: string; title: string; startDate: string; endDate: string; isActive: boolean; isHidden: boolean; campus?: Event["campus"] }[];
};
export type EventSession = {
id: string;
eventId?: string;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.46.2",
"version": "1.47.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.46.2",
"version": "1.47.1",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.46.2",
"version": "1.47.1",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",