Compare commits

..
Author SHA1 Message Date
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
joshua a7261db191 Merge branch 'chore/licence-updates' 2026-10-28 09:13:30 +02:00
joshuaandClaude Opus 5.5 6045d1b05e WHATS-NEW.md for churches; docs, changelog (1.46.2)
Client-facing release notes the Hub reads from git with the version tags, so
the latest version and its notes reach installs without touching the Hub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:13:30 +02:00
joshuaandClaude Opus 5.5 5df04f5a4a Licence page: what's new, withdraw request, Not now, provider notices
- Site settings -> Licence shows the release notes for every newer version from
  the Hub; urgent (security/serious) updates are marked and can't be put off.
- Withdraw an update request (the provider is told through the Hub).
- "Not now" on the banner or Licence page hides update reminders until the
  next version.
- Notices from the provider show as a dashboard banner for site admins and
  supervisors, in their time window, and on the Licence page.
- New audit actions (migration), API docs, help, protocol doc, tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:54:35 +02:00
joshua 35805f0c79 Merge branch 'chore/remove-ci' 2026-10-28 08:15:45 +02:00
36 changed files with 1439 additions and 117 deletions
+37
View File
@@ -7,6 +7,43 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [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
- **What's new before you update** — Site settings → Licence shows the release notes for every
version newer than the site, so you can decide whether to ask for the update. Updates that fix a
security problem or a serious bug are marked, and show as a red banner.
- **Withdraw request** — changed your mind after pressing Request update? Withdraw it on the Licence
page and your provider is told not to update the site.
- **Not now** — on the update banner or the Licence page: hides the "update available" reminder until
the next version comes out ("Remind me again" turns it back on). Urgent updates can't be put off.
- **Notices from your provider** — planned maintenance and other messages from your provider show as
a banner at the top of the dashboard for site admins and supervisors, at the times given. Close
one with ×; current notices are also listed on the Licence page.
- **`WHATS-NEW.md`** — the release notes churches see, in plain language. The Hub reads it from git
with the version tags, so a release needs its section there before tagging.
Needs Cross Events Hub 1.4.0 for the notes, withdrawing and notices; with an older Hub the site
works as before.
## [1.46.1] - 2026-10-28
### Removed
+15 -2
View File
@@ -90,6 +90,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
@@ -774,8 +782,13 @@ can't start, the same jobs run on in-process timers.
never switches it off: past the end date there's a grace period (banner for site admins only),
then — or when suspended, revoked or not licensed — the site is read-only (events can't be
created or edited; no new registrations or payments; tickets still scan, data can be exported).
The site checks in 30 seconds after starting and after a licence key is connected. Admins see when an update is available and press **Request update**,
which tells the provider through the Hub (the site never updates itself).
The site checks in 30 seconds after starting and after a licence key is connected. Admins see when an update is available, with
**what's new** in each version, and press **Request update**, which tells the provider through the Hub (the site never updates
itself). A request can be **withdrawn**. **Not now** hides the update banner until the next version; urgent updates (security or
serious bug fixes) can't be put off and show a red banner.
- **Notices from the provider** — maintenance and other messages the provider posts in the Hub show as a banner on the dashboard
for site admins and supervisors, in their time window; each can be closed (per browser) and stays listed on Site settings →
Licence. The provider can email them to the church too.
See `docs/hub-protocol.md`.
- **`/health`** — status, version, database, background jobs and last backup.
- **PM2** (`ecosystem.config.js`) runs backend and frontend, restarting either
+481
View File
@@ -0,0 +1,481 @@
# What's new in Cross Events
What each version changes for the church, in plain language. Churches see these notes on Site
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 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.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
brings, so you can decide whether you want it.
- **Changed your mind?** Withdraw an update request, and your provider is told not to update the site.
- **Not now:** hide the "update available" reminder until the next version comes out. Updates that
fix a security problem or a serious bug are marked in red and can't be put off.
- **Notices from your provider**, such as planned maintenance, now show as a banner for site admins
and supervisors.
## 1.46.1
- Behind-the-scenes maintenance. Nothing changes for you.
## 1.46.0
- **Volunteer rosters:** set up serving roles (Welcome, Parking, Kids, Sound…) per event or session.
Anyone with an account can sign up under My account → Serving, or supervisors can assign people.
- Reminders by WhatsApp or email the day before a shift.
- **"Can't make it" swaps:** a volunteer asks for cover, and the first to accept takes the place.
- Volunteers check in by scanning the event's QR code; kids-room volunteers count as leaders on duty.
- Roster sheet with phone numbers and check-in times, and hours served per person.
- Fixed: the free kids check-in ticket was offered on the public event page.
## 1.45.1
- The built-in privacy notice and terms of use are brought up to date for kids check-in, sessions,
certificates and campuses. If you published your own privacy notice, review it under Admin →
Privacy.
## 1.45.0
- **Kids check-in:** a secure check-in and pickup for children's ministry, run from a staff desk, with
no label printers needed.
- Parents keep their children's details (allergies, medical and custody notes) and authorised adults
under My account → Kids.
- 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.
+9 -1
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
@@ -844,10 +849,13 @@ The connection to the Cross Events Hub (`src/hub/`, protocol in `docs/hub-protoc
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | public | `{ status, version, db, jobs, lastBackupAt }` — 503 when the database is down |
| GET | `/api/hub/status` | admin | `{ state: active\|grace\|suspended\|revoked\|unlicensed, readOnly, daysLeft, endsAt, graceEndsAt, licence, connected, keyConfigured, hubUrl, lastHeartbeatAt, lastError, version, latestVersion, updateAvailable }` |
| GET | `/api/hub/status` | admin | `{ state: active\|grace\|suspended\|revoked\|unlicensed, readOnly, daysLeft, endsAt, graceEndsAt, licence, connected, keyConfigured, hubUrl, lastHeartbeatAt, lastError, version, latestVersion, updateAvailable, urgentUpdate, updateSnoozed, releases: [{ version, releasedAt, urgent, notes }], updateRequest, notices }` — `releases` is what's new in each version newer than this one (from the Hub) |
| POST | `/api/hub/connect` | admin (incl. the `/setup` token) | `{ licenceKey }` — registers with the Hub; stores the install id, encrypted secret and licence token. Hub errors pass through with their `code` (`LICENCE_NOT_FOUND`, `LICENCE_IN_USE`, `LICENCE_REVOKED`, `HUB_UNREACHABLE`, …). Audited as `hub_connected` |
| POST | `/api/hub/heartbeat` | admin | Check in now; returns the status |
| POST | `/api/hub/update-request` | admin | Ask the Hub for this site to be updated (protocol §3a); the provider is told, nothing runs on the site. Returns the status with `updateRequest: { requestedAt, version }` until the site runs that version. `409 UP_TO_DATE`; audited as `hub_update_requested` |
| POST | `/api/hub/update-request/cancel` | admin | Withdraw the update request; the Hub tells the provider. Returns the status. Audited as `hub_update_request_withdrawn` |
| POST | `/api/hub/update-snooze` | admin | `{ snooze }` — "Not now": `updateSnoozed` is true (no banner) until a newer version than the one put off is out. `400` when there's no update or it's urgent (security/serious fix). `{ snooze: false }` turns reminders back on. Audited as `hub_update_snoozed` / `hub_update_unsnoozed` |
| GET | `/api/hub/notices` | supervisor | The provider's notices showing now: `[{ id, level: info\|maintenance\|warning, title, message, startsAt, endsAt, updatedAt }]`, from the last heartbeat |
**Read-only mode** (licence suspended, revoked, or past its grace period): `403 { code: "LICENCE_READ_ONLY" }` on `POST /api/events`, event options and variants, `POST /api/registrations` (and `/manual`), `PUT /api/registrations/:id/options`, `POST /api/payments`, `/payments/checkout` (`/send`), `/payments/yoco-checkout` (`/send`), `/payments/pay/:id`, `POST /api/waitlist`, `POST /api/coupons`, `POST /api/bundles/event/:id`. Everything else — scanning, exports, refunds, cancellations, privacy requests, settings — keeps working, and payment webhooks for payments already started are still processed.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.46.1",
"version": "1.47.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.46.1",
"version": "1.47.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.46.1",
"version": "1.47.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,11 @@
-- 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 'hub_update_request_withdrawn';
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_update_snoozed';
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_update_unsnoozed';
+3
View File
@@ -682,6 +682,9 @@ enum AdminAuditAction {
breach_updated
hub_connected
hub_update_requested
hub_update_request_withdrawn
hub_update_snoozed
hub_update_unsnoozed
campuses_toggled
campus_created
campus_updated
+16 -1
View File
@@ -102,6 +102,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;
@@ -196,7 +210,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),
});
@@ -2156,6 +2170,7 @@ const getEventByAlias = async (req, res) => {
module.exports = {
createEvent,
copyEventSetupFrom,
getEvents,
getAllEvents,
getEventsAll,
+89 -2
View File
@@ -19,9 +19,47 @@ function pendingUpdateRequest(version, requestedAt, requestedVersion) {
return { requestedAt, version: requestedVersion };
}
const str = (v, max) => String(v ?? '').slice(0, max);
/** What's new in each version newer than this one (from the Hub), newest first. */
function newerReleases(releases, version) {
return (releases || [])
.filter(r => r && /^\d+\.\d+\.\d+$/.test(String(r.version)) && compareVersions(r.version, version) > 0)
.sort((a, b) => compareVersions(b.version, a.version))
.map(r => ({ version: r.version, releasedAt: r.releasedAt || null, urgent: !!r.urgent, notes: str(r.notes, 20000) }));
}
/**
* "Not now" holds until a newer version than the one put off comes out. Urgent updates (security or
* serious bug fixes) can't be put off.
*/
function updateSnoozed({ latestVersion, snoozedVersion, urgent }) {
return !!(latestVersion && snoozedVersion && !urgent && compareVersions(snoozedVersion, latestVersion) >= 0);
}
const NOTICE_LEVELS = new Set(['info', 'maintenance', 'warning']);
/** The provider's notices showing now: started (or no start) and not ended. */
function activeNotices(notices, now = new Date()) {
return (notices || [])
.filter(n => n && n.id && (!n.startsAt || new Date(n.startsAt) <= now) && (!n.endsAt || new Date(n.endsAt) > now))
.map(n => ({
id: str(n.id, 64),
level: NOTICE_LEVELS.has(n.level) ? n.level : 'info',
title: str(n.title, 120),
message: str(n.message, 2000),
startsAt: n.startsAt || null,
endsAt: n.endsAt || null,
updatedAt: n.updatedAt || null,
}));
}
/** What the admin pages show: licence state, Hub connection, version and update. */
async function statusPayload({ fresh = false } = {}) {
const s = await licenceStatus({ fresh });
const releases = newerReleases(s.releases, APP_VERSION);
const updateAvailable = !!(s.latestVersion && compareVersions(APP_VERSION, s.latestVersion) < 0);
const urgentUpdate = updateAvailable && releases.some(r => r.urgent);
return {
state: s.state,
readOnly: s.readOnly,
@@ -45,8 +83,12 @@ async function statusPayload({ fresh = false } = {}) {
lastError: s.lastError,
version: APP_VERSION,
latestVersion: s.latestVersion,
updateAvailable: !!(s.latestVersion && compareVersions(APP_VERSION, s.latestVersion) < 0),
updateAvailable,
urgentUpdate,
updateSnoozed: updateAvailable && updateSnoozed({ latestVersion: s.latestVersion, snoozedVersion: s.updateSnoozedVersion, urgent: urgentUpdate }),
releases,
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion),
notices: activeNotices(s.notices),
};
}
@@ -107,4 +149,49 @@ const requestUpdate = async (req, res) => {
}
};
module.exports = { getStatus, connect, heartbeatNow, requestUpdate, statusPayload, compareVersions, pendingUpdateRequest };
// @desc Withdraw the update request (the provider is told not to update after all)
// @route POST /api/hub/update-request/cancel
// @access Private/Admin
const cancelUpdateRequest = async (req, res) => {
try {
await hub.cancelUpdateRequest();
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_request_withdrawn', targetType: 'hub', metadata: { version: APP_VERSION }, ip: getClientIp(req) });
res.json(await statusPayload({ fresh: true }));
} catch (e) {
const status = e.statusCode && e.statusCode < 500 && e.code !== 'INSTALL_NOT_FOUND' ? e.statusCode : 502;
res.status(status).json({ message: e.message || safeErrorMessage(e), code: e.code });
}
};
// @desc "Not now": no update reminders until a newer version is out (snooze: false turns them back on)
// @route POST /api/hub/update-snooze body: { snooze }
// @access Private/Admin
const snoozeUpdate = async (req, res) => {
try {
const snooze = req.body?.snooze !== false;
const current = await statusPayload({ fresh: true });
if (snooze) {
if (!current.updateAvailable) { res.status(400); throw new Error('There is no update to put off.'); }
if (current.urgentUpdate) { res.status(400); throw new Error('This update fixes a security or serious problem, so it can\'t be put off.'); }
}
await hub.snoozeUpdate(snooze ? current.latestVersion : null);
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: snooze ? 'hub_update_snoozed' : 'hub_update_unsnoozed', targetType: 'hub', metadata: { version: current.latestVersion }, ip: getClientIp(req) });
res.json(await statusPayload({ fresh: true }));
} catch (e) {
res.status(res.statusCode === 200 ? 500 : res.statusCode).json({ message: safeErrorMessage(e) });
}
};
// @desc The provider's notices showing now (banner for site admins and supervisors)
// @route GET /api/hub/notices
// @access Private/Supervisor
const getNotices = async (req, res) => {
try {
const s = await licenceStatus();
res.json(activeNotices(s.notices));
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
module.exports = { getStatus, connect, heartbeatNow, requestUpdate, cancelUpdateRequest, snoozeUpdate, getNotices, statusPayload, compareVersions, pendingUpdateRequest, newerReleases, updateSnoozed, activeNotices };
+33 -5
View File
@@ -1,10 +1,12 @@
/**
* Talking to the Cross Events Hub (docs/hub-protocol.md): register with a licence key, then a
* signed heartbeat every 15 minutes, and a signed update request when an admin asks for one.
* Install → Hub only. Stored in AppSetting:
* signed heartbeat every 15 minutes, and a signed update request (or withdrawal) when an admin
* asks. Install → Hub only. Stored in AppSetting:
* hub_install_id, hub_install_secret (encrypted), hub_licence (signed token),
* hub_last_heartbeat_at, hub_last_error, hub_latest_version,
* hub_update_requested_at, hub_update_requested_version.
* hub_releases, hub_notices (JSON from the last heartbeat: what's new, provider's notices),
* hub_update_requested_at, hub_update_requested_version,
* hub_update_snoozed_version ("Not now" for that version; set here, not by the Hub).
* These keys are kept out of the generic settings endpoints (settingsController).
*/
@@ -16,7 +18,7 @@ const report = require('./report');
const PROTOCOL_VERSION = 1;
const TIMEOUT_MS = 15000;
const HUB_KEYS = ['hub_install_id', 'hub_install_secret', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version'];
const HUB_KEYS = ['hub_install_id', 'hub_install_secret', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version', 'hub_releases', 'hub_notices', 'hub_update_snoozed_version'];
function hubUrl() {
return String(process.env.HUB_URL || 'https://events-hub.crosscode.co.za').replace(/\/$/, '');
@@ -38,6 +40,14 @@ async function save(values) {
clearLicenceCache();
}
/** What's new and the provider's notices from a register/heartbeat reply (a Hub before 1.4.0 sends neither). */
function fromHub(data) {
return {
hub_releases: Array.isArray(data.releases) ? JSON.stringify(data.releases.slice(0, 20)) : null,
hub_notices: Array.isArray(data.notices) ? JSON.stringify(data.notices.slice(0, 20)) : null,
};
}
async function read(keys) {
const rows = await prisma.appSetting.findMany({ where: { key: { in: keys } } });
return Object.fromEntries(rows.map(r => [r.key, r.value]));
@@ -101,6 +111,7 @@ async function connect(licenceKey, adminContact) {
hub_last_heartbeat_at: new Date().toISOString(),
hub_last_error: null,
hub_latest_version: data.latestVersion || null,
...fromHub(data),
});
return claims;
}
@@ -119,6 +130,7 @@ async function heartbeat() {
hub_last_heartbeat_at: new Date().toISOString(),
hub_last_error: null,
hub_latest_version: data.latestVersion || null,
...fromHub(data),
});
return { ok: true, status: claims?.status || null };
} catch (e) {
@@ -149,6 +161,22 @@ async function requestUpdate() {
return data;
}
/** Withdraw the update request (§3a): the provider is told not to update after all. */
async function cancelUpdateRequest() {
try {
await signedPost('/api/installs/update-request/cancel', { protocolVersion: PROTOCOL_VERSION });
} catch (e) {
if (e.code === 'NOT_FOUND') throw new HubError('The Hub can\'t withdraw update requests yet. Please contact your provider.', 'NOT_SUPPORTED', 502);
throw e;
}
await save({ hub_update_requested_at: null, hub_update_requested_version: null });
}
/** "Not now": no update reminders until a newer version than `version` is out. Null turns them back on. */
async function snoozeUpdate(version) {
await save({ hub_update_snoozed_version: version || null });
}
/**
* A heartbeat soon (on startup, and after connecting), so a licence change on the Hub shows
* without waiting for the next 15-minute run. Never throws; skipped when the job is switched off.
@@ -164,4 +192,4 @@ function heartbeatSoon(delayMs = 30 * 1000) {
soonTimer.unref?.();
}
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate };
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon, requestUpdate, cancelUpdateRequest, snoozeUpdate };
+13 -1
View File
@@ -77,10 +77,19 @@ function checkDisabled() {
let cache = null;
function parseList(json) {
try {
const list = JSON.parse(json || '[]');
return Array.isArray(list) ? list : [];
} catch {
return [];
}
}
/** This install's licence: claims, state and Hub connection details. Cached for 30 s. */
async function licenceStatus({ fresh = false } = {}) {
if (!fresh && cache && Date.now() - cache.at < CACHE_MS) return cache.value;
const rows = await prisma.appSetting.findMany({ where: { key: { in: ['hub_install_id', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version'] } } });
const rows = await prisma.appSetting.findMany({ where: { key: { in: ['hub_install_id', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version', 'hub_update_requested_at', 'hub_update_requested_version', 'hub_releases', 'hub_notices', 'hub_update_snoozed_version'] } } });
const s = Object.fromEntries(rows.map(r => [r.key, r.value]));
const claims = verifyToken(s.hub_licence);
const value = {
@@ -95,6 +104,9 @@ async function licenceStatus({ fresh = false } = {}) {
latestVersion: s.hub_latest_version || null,
updateRequestedAt: s.hub_update_requested_at || null,
updateRequestedVersion: s.hub_update_requested_version || null,
updateSnoozedVersion: s.hub_update_snoozed_version || null,
releases: parseList(s.hub_releases),
notices: parseList(s.hub_notices),
};
cache = { at: Date.now(), value };
return value;
+1
View File
@@ -13,6 +13,7 @@ const { licenceStatus } = require('./licence');
const BLOCKED = [
['POST', /^\/api\/events\/?$/],
['PUT', /^\/api\/events\/[^/]+\/?$/],
['POST', /^\/api\/events\/[^/]+\/copy-from\/?$/],
['POST', /^\/api\/events\/[^/]+\/options\/?$/],
['PUT', /^\/api\/events\/options\/[^/]+\/?$/],
['POST', /^\/api\/events\/options\/[^/]+\/variants\/?$/],
+11
View File
@@ -584,6 +584,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' }]}]},
@@ -926,6 +930,13 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Status' }, { status:502, desc:'Hub unreachable' }]},
{ method:'POST', path:'/api/hub/update-request', auth:'admin', desc:'Ask the Hub for this site to be updated to the latest version (the provider is told; the site never updates itself)',
responses:[{ status:200, desc:'Status, with updateRequest', body:{ updateAvailable:true, updateRequest:{ requestedAt:'2026-10-27T09:00:00.000Z', version:'1.42.0' } }}, { status:409, desc:'Already on the latest version', body:{ code:'UP_TO_DATE', message:'This site already runs the latest version.' }}, { status:502, desc:'Hub unreachable' }]},
{ method:'POST', path:'/api/hub/update-request/cancel', auth:'admin', desc:'Withdraw the update request (the provider is told not to update after all)',
responses:[{ status:200, desc:'Status, updateRequest null' }, { status:502, desc:'Hub unreachable, or a Hub too old to withdraw requests (NOT_SUPPORTED)' }]},
{ method:'POST', path:'/api/hub/update-snooze', auth:'admin', desc:'"Not now": no update reminders until a newer version is out. { snooze: false } turns them back on',
request:{ body:{ snooze:true } },
responses:[{ status:200, desc:'Status', body:{ updateAvailable:true, updateSnoozed:true } }, { status:400, desc:'No update, or an urgent one (can\'t be put off)' }]},
{ method:'GET', path:'/api/hub/notices', auth:'supervisor', desc:'The provider\'s notices showing now (the dashboard banner for site admins and supervisors)',
responses:[{ status:200, desc:'Success', body:[{ id:'…', level:'maintenance', title:'Planned maintenance', message:'The site will be down for 30 minutes.', startsAt:'2026-11-01T16:00:00.000Z', endsAt:'2026-11-01T16:30:00.000Z' }] }]},
]},
{ title: 'Campuses', base: '/api/campuses', endpoints: [
+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)
+7 -2
View File
@@ -1,7 +1,8 @@
const express = require('express');
const router = express.Router();
const { getStatus, connect, heartbeatNow, requestUpdate } = require('../controllers/hubController');
const { protect, admin } = require('../middleware/authMiddleware');
const { getStatus, connect, heartbeatNow, requestUpdate, cancelUpdateRequest, snoozeUpdate, getNotices } = require('../controllers/hubController');
const { protect, admin, supervisor } = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
// Licence and the Cross Events Hub (docs/hub-protocol.md). Admins only; the setup wizard's
// admin token counts, so the licence step can run during /setup.
@@ -9,5 +10,9 @@ router.get('/status', protect, admin, getStatus);
router.post('/connect', protect, admin, connect);
router.post('/heartbeat', protect, admin, heartbeatNow);
router.post('/update-request', protect, admin, requestUpdate);
router.post('/update-request/cancel', protect, admin, cancelUpdateRequest);
router.post('/update-snooze', protect, admin, snoozeUpdate);
// The provider's notices: site admins and supervisors see them as a banner.
router.get('/notices', protect, supervisor, campusIndependent('notices from the provider are for the whole site'), getNotices);
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 };
+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);
});
});
+48 -1
View File
@@ -4,7 +4,7 @@ const crypto = require('crypto');
const { verifyToken, stateFor } = require('../src/hub/licence');
const { incomeBand } = require('../src/hub/report');
const { isBlocked } = require('../src/hub/readOnly');
const { compareVersions, pendingUpdateRequest } = require('../src/controllers/hubController');
const { compareVersions, pendingUpdateRequest, newerReleases, updateSnoozed, activeNotices } = require('../src/controllers/hubController');
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
function sign(payload, key = privateKey) {
@@ -100,6 +100,8 @@ describe('read-only mode', () => {
['POST', '/api/payments/refund'],
['POST', '/api/privacy/me/requests'],
['POST', '/api/hub/connect'],
['POST', '/api/hub/update-request/cancel'],
['POST', '/api/hub/update-snooze'],
['POST', '/api/users/login'],
['POST', '/api/reports/pdf'],
])('%s %s keeps working', (m, p) => expect(isBlocked(m, p)).toBe(false));
@@ -151,3 +153,48 @@ test('an update request shows until the site runs the version asked for', () =>
expect(pendingUpdateRequest('1.43.0', at, '1.42.0')).toBeNull();
expect(pendingUpdateRequest('1.41.2', null, null)).toBeNull();
});
describe("what's new", () => {
const releases = [
{ version: '1.46.0', notes: 'Older', urgent: false, releasedAt: '2026-10-20T10:00:00.000Z' },
{ version: '1.48.0', notes: '- Webhooks', urgent: false, releasedAt: '2026-10-30T10:00:00.000Z' },
{ version: '1.47.1', notes: '- Security fix', urgent: true, releasedAt: '2026-10-29T10:00:00.000Z' },
{ version: 'bogus', notes: 'x' },
];
test('only versions newer than this site, newest first', () => {
expect(newerReleases(releases, '1.47.0').map(r => r.version)).toEqual(['1.48.0', '1.47.1']);
expect(newerReleases(releases, '1.48.0')).toEqual([]);
expect(newerReleases(null, '1.0.0')).toEqual([]);
});
test('"Not now" holds until a newer version than the one put off', () => {
expect(updateSnoozed({ latestVersion: '1.47.0', snoozedVersion: '1.47.0', urgent: false })).toBe(true);
expect(updateSnoozed({ latestVersion: '1.48.0', snoozedVersion: '1.47.0', urgent: false })).toBe(false);
expect(updateSnoozed({ latestVersion: '1.47.0', snoozedVersion: null, urgent: false })).toBe(false);
});
test('urgent updates can\'t be put off', () => {
expect(updateSnoozed({ latestVersion: '1.47.1', snoozedVersion: '1.47.1', urgent: true })).toBe(false);
});
});
describe("the provider's notices", () => {
const NOW_N = new Date('2026-11-01T18:30:00Z');
const n = (id, extra = {}) => ({ id, level: 'info', title: 'T', message: 'M', startsAt: null, endsAt: null, ...extra });
test('show between their start and end', () => {
const notices = [
n('now'),
n('window', { startsAt: '2026-11-01T18:00:00Z', endsAt: '2026-11-01T20:00:00Z' }),
n('later', { startsAt: '2026-11-02T18:00:00Z' }),
n('ended', { endsAt: '2026-11-01T18:00:00Z' }),
];
expect(activeNotices(notices, NOW_N).map(x => x.id)).toEqual(['now', 'window']);
});
test('an unknown level shows as information', () => {
expect(activeNotices([n('x', { level: 'party' })], NOW_N)[0].level).toBe('info');
expect(activeNotices([n('x', { level: 'maintenance' })], NOW_N)[0].level).toBe('maintenance');
});
});
+26 -6
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-28, after **1.46.1** (and Hub 1.1.0). The plan itself is in
Last updated: 2026-10-28, after **1.47.0** (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,8 +14,10 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 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) |
| 1.45.1 | Privacy notice (built-in text) and terms of use brought up to date with kids check-in, sessions, certificates, campuses, the Hub heartbeat and where data is stored. Installs with a published notice must publish a new version themselves |
| 1.45.0 | **Kids check-in** (§4), fully digital: families/children/authorised adults kept between events (My account → Kids), staff desk with pickup codes and a "write on the sticker" panel, rooms with age ranges and ratios, room screen (on duty, call parent, alerts), check-out by code (wrong code alerts, override with reason), offline desk with reserved codes, attendance through a free kids ticket; 6-month retention rule for children |
| 1.44.0 | **Campus-scoped staff** (§12 part 2): Admin → Users "Limit to campuses"; `utils/campusScope.js` on every staff/supervisor route (and owner-or-staff routes), lists filtered, scanner names the other campus, church-wide events view/scan/door-only for scoped staff; route test `tests/campusScopeRoutes.test.js` |
@@ -46,17 +48,22 @@ 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. **Event series** (§6), one patch per part: **1.47.1 series** (can span campuses), **1.47.2
year-on-year reports**, **1.47.3 season pass** — then **1.48.0 — outbound webhooks** (§8).
## Working on this repo
- **Release routine:** branch `feat/<name>` → commit → bump with
`npm version X.Y.Z --no-git-tag-version` in `.`, `backend` and `frontend` → add the
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → `git merge --no-ff`
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → write the church-facing
`## X.Y.Z` section in **WHATS-NEW.md** (plain language; `— urgent` in the heading for a security
or serious fix) → `git merge --no-ff`
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
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:** 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 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`).
@@ -192,8 +199,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)
@@ -277,6 +283,20 @@ 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`.
## Known gaps / follow-ups
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
+17 -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** shipped.
**Next: 1.47.1 Event series.**
**Repositories:**
@@ -460,15 +461,21 @@ 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.**
- **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 +960,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.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 +1015,7 @@ feature.
| — | Wallet passes | **Removed from scope** (2026-10-28); everything after volunteer rosters moves up by one |
| — | Kids check-in (1.45.0) | One release. **Fully digital** — no label printers: pickup code on the parent's phone, a "write on the sticker" panel for blank stickers. Staff desk only (no kiosk). Anyone with the pickup code can collect; families list authorised adults without accounts. Child profiles deleted after 6 months without a check-in (2026-10-28) |
| — | Volunteer rosters (1.46.0) | Anyone with an account can volunteer. Roles are per event (or per session) — rosters cover one event/session and never repeat. A swap needs no approval: it happens once someone accepts, and the event's notify recipients are emailed. Volunteers check in by scanning a QR code or are checked in by staff. The free volunteer ticket is off by default and chosen per event. A role linked to a kids room counts its checked-in volunteers as leaders on duty for the room's ratio (2026-10-28) |
| — | Event series and cloning (1.47.x) | One release per part: 1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass. A clone opens the New event popup filled in from the source, so everything is set there before saving. Of the newer features a clone copies only the campus (not kids check-in or volunteer roles); coupons are never copied. A series can span several campuses (2026-10-28) |
| — | 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
+41 -2
View File
@@ -55,7 +55,9 @@ POST /api/installs/register
"installSecret": "…", // shown once; the install stores it encrypted
"licence": "<licence token>",
"heartbeatIntervalMinutes": 15,
"latestVersion": "1.41.0"
"latestVersion": "1.41.0",
"releases": [ … ], // as in the heartbeat reply (§3); Hub 1.4.0+
"notices": [ … ]
}
```
@@ -101,9 +103,30 @@ X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
"income": { "last30d": "R5 001–R20 000", "allTime": "over R100 000" }
}
}
→ 200 { "licence": "<fresh licence token>", "latestVersion": "1.42.0", "serverTime": "…" }
→ 200 {
"licence": "<fresh licence token>",
"latestVersion": "1.42.0",
"releases": [ // Hub 1.4.0+: newer than the version sent, newest first, at most 20
{ "version": "1.42.0", "releasedAt": "2026-10-27T10:00:00.000Z", "urgent": false, "notes": "- Event series\n- Fixed: …" }
],
"notices": [ // Hub 1.4.0+: the provider's banners for this install
{ "id": "…", "level": "maintenance", "title": "Planned maintenance", "message": "…",
"startsAt": "2026-11-01T16:00:00.000Z", "endsAt": "2026-11-01T16:30:00.000Z", "updatedAt": "…" }
],
"serverTime": "…"
}
```
- **`releases`** — what's new in each version, so the church can decide whether to ask for the
update. The Hub takes the versions from the Cross Events repository's `v*` tags and the notes from
its `WHATS-NEW.md`. `notes` is plain text; lines starting with `- ` are
a list. `urgent` marks security or serious bug fixes: the install shows those in red and doesn't let
admins put them off. The highest release is `latestVersion`.
- **`notices`** — banners Joshua posts on the Hub's Notices page (`level`: `info`, `maintenance` or
`warning`). Sent until `endsAt`, including ones that haven't started, so the install shows each one
from `startsAt` without waiting for a heartbeat. The install shows them to site admins and
supervisors. Notices can also be emailed; that happens on the Hub, not through the install.
- A Hub older than 1.4.0 sends neither field; the install then shows no notes and no notices.
- The signature covers the exact bytes sent. Requests more than **5 minutes** off the Hub's
clock are refused (`401 STALE_REQUEST`); a bad signature is `401 BAD_SIGNATURE`; an unknown or
released install is `404 INSTALL_NOT_FOUND` (the install should show "Reconnect to Hub").
@@ -132,6 +155,22 @@ X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
hour per IP). A Hub older than this section answers `404 NOT_FOUND`.
- No personal information: not who pressed the button (the Hub already has the admin contact).
### Withdrawing it
Sent when a site admin presses **Withdraw request**. Signed like the heartbeat.
```
POST /api/installs/update-request/cancel
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
{ "protocolVersion": 1 }
→ 200 { "cancelled": true } // the open request was resolved, and Joshua is emailed
→ 200 { "cancelled": false } // there was no open request
```
- The Hub resolves the `update_requested` alert and records `update_request_withdrawn` in the install's
history. Rate-limited with update requests. A Hub older than 1.4.0 answers `404 NOT_FOUND`.
- **"Not now"** (no reminders until the next version) stays on the install; the Hub isn't told.
## 4. Licence token
`hel1.<payload>.<signature>` — `payload` is base64url JSON, `signature` is the base64url
+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.1",
"version": "1.47.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.46.1",
"version": "1.47.0",
"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.1",
"version": "1.47.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -97,6 +97,9 @@ const ACTION_LABELS: Record<string, string> = {
breach_updated: "Security compromise updated",
hub_connected: "Connected to the Cross Events Hub",
hub_update_requested: "Asked the Hub for an update",
hub_update_request_withdrawn: "Withdrew the update request",
hub_update_snoozed: "Put off an update (Not now)",
hub_update_unsnoozed: "Turned update reminders back on",
campuses_toggled: "Campuses turned on/off",
campus_created: "Campus added",
campus_updated: "Campus changed",
+3
View File
@@ -8,6 +8,7 @@ import { Footer } from "@/components/layout/Footer";
import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar";
import { PrivacyPrompts } from "@/components/shared/PrivacyPrompts";
import { LicenceBanner } from "@/components/shared/LicenceBanner";
import { ProviderNotices } from "@/components/shared/ProviderNotices";
// The sidebar shows only on these exact routes — each role's dashboard root,
// plus Profile & Security and Site Settings (matching the redesign
@@ -89,6 +90,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<div className="h-screen flex flex-col overflow-hidden">
<Navbar />
<LicenceBanner />
<ProviderNotices />
<PrivacyPrompts />
<MobileSidebar />
<div className="flex-1 flex min-h-0">
@@ -106,6 +108,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<div className="min-h-screen flex flex-col">
<Navbar />
<LicenceBanner />
<ProviderNotices />
<PrivacyPrompts />
<main className="flex-1 p-6 bg-gray-50">{children}</main>
<Footer />
@@ -10,7 +10,7 @@ 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";
@@ -870,14 +870,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 +923,27 @@ 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 || "",
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
@@ -923,18 +959,22 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
}, [mode, campusInfo.scoped, campusInfo.campuses, settings.org_address]);
// ── 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 +990,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 +1027,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 +1067,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 +1122,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 => {
@@ -1323,10 +1410,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 +1491,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 +1554,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,7 +1622,7 @@ 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>
@@ -1882,7 +1985,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";
@@ -1902,7 +2005,13 @@ function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
{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 +2079,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 +2132,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>
+109 -16
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { KeyRound, RefreshCw, ShieldCheck, ShieldAlert, ArrowUpCircle } from "lucide-react";
import { KeyRound, RefreshCw, ShieldCheck, ShieldAlert, ArrowUpCircle, Undo2, BellOff, BellRing, Megaphone } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { errorMessage } from "@/lib/utils";
@@ -21,9 +21,39 @@ export type HubStatus = {
version: string;
latestVersion: string | null;
updateAvailable: boolean;
urgentUpdate: boolean;
updateSnoozed: boolean;
releases: HubRelease[];
updateRequest: { requestedAt: string; version: string } | null;
notices: HubNotice[];
};
export type HubRelease = { version: string; releasedAt: string | null; urgent: boolean; notes: string };
export type HubNotice = { id: string; level: "info" | "maintenance" | "warning"; title: string; message: string; startsAt: string | null; endsAt: string | null; updatedAt: string | null };
/** Release notes from the provider: lines starting with "- " are a list, the rest paragraphs. */
export function ReleaseNotes({ notes }: { notes: string }) {
const blocks: ({ list: false; text: string } | { list: true; items: string[] })[] = [];
for (const raw of notes.split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
const item = /^[-*•]\s+(.*)$/.exec(line);
const last = blocks[blocks.length - 1];
if (item) {
if (last?.list) last.items.push(item[1]);
else blocks.push({ list: true, items: [item[1]] });
} else blocks.push({ list: false, text: line });
}
if (!blocks.length) return <p className="text-gray-400 italic">No details given.</p>;
return (
<div className="space-y-1.5 text-gray-700">
{blocks.map((b, i) => (b.list
? <ul key={i} className="list-disc pl-5 space-y-0.5">{b.items.map((t, j) => <li key={j}>{t}</li>)}</ul>
: <p key={i}>{b.text}</p>))}
</div>
);
}
const STATE: Record<HubStatus["state"], { label: string; tone: string; text: string }> = {
active: { label: "Active", tone: "bg-green-50 text-green-800 border-green-200", text: "Everything works." },
grace: { label: "Needs renewing", tone: "bg-amber-50 text-amber-900 border-amber-200", text: "Everything still works for now — renew before the grace period ends." },
@@ -94,18 +124,25 @@ export function LicenceTab({ active }: { active: boolean }) {
}
};
const requestUpdate = async () => {
// Request, withdraw, "Not now" and "remind me again" all return the new status.
const updateAction = async (path: string, body: unknown, ok: string) => {
setBusy(true);
setMsg(null);
try {
setStatus(await apiFetch<HubStatus>("/api/hub/update-request", { method: "POST", authToken: token }));
setMsg({ type: "ok", text: "Update requested. Your provider has been told and will update the site." });
setStatus(await apiFetch<HubStatus>(path, { method: "POST", authToken: token, body }));
setMsg({ type: "ok", text: ok });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "The Hub could not be reached." });
} finally {
setBusy(false);
}
};
const requestUpdate = () => updateAction("/api/hub/update-request", undefined, "Update requested. Your provider has been told and will update the site.");
const withdrawRequest = () => {
if (!confirm("Withdraw the update request? Your provider is told not to update the site for now.")) return;
updateAction("/api/hub/update-request/cancel", undefined, "Update request withdrawn. Your provider has been told.");
};
const snooze = (on: boolean) => updateAction("/api/hub/update-snooze", { snooze: on }, on ? "Got it — no more reminders until the next version comes out." : "Update reminders are back on.");
if (!status) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
const st = STATE[status.state];
@@ -162,22 +199,78 @@ export function LicenceTab({ active }: { active: boolean }) {
<div className="border rounded-xl p-4 text-sm">
<div className="flex items-center gap-2 font-medium text-gray-900 mb-1"><ArrowUpCircle className="w-4 h-4" /> Version</div>
<p>This site runs Cross Events <strong>{status.version}</strong>.{status.latestVersion && !status.updateAvailable && " It's up to date."}</p>
{status.updateAvailable && (status.updateRequest ? (
<p className="mt-1 text-gray-600">
Version {status.latestVersion} is available. Update requested on {fmt(status.updateRequest.requestedAt)} — your provider will install it.
</p>
) : (
<div className="mt-2 flex flex-wrap items-center gap-3">
<p className="text-brand-700">Version {status.latestVersion} is available.</p>
{status.connected && (
<button onClick={requestUpdate} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
<ArrowUpCircle className="w-4 h-4" /> {busy ? "Requesting…" : "Request update"}
</button>
{status.updateAvailable && (
<div className="mt-2 space-y-3">
{status.urgentUpdate && (
<p className="flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-red-800">
<ShieldAlert className="w-4 h-4 mt-0.5 shrink-0" />
<span>This update fixes a security problem or a serious bug. Please ask for it soon.</span>
</p>
)}
{status.updateRequest ? (
<div className="flex flex-wrap items-center gap-3">
<p className="text-gray-600">Version {status.latestVersion} is available. Update requested on {fmt(status.updateRequest.requestedAt)} — your provider will install it.</p>
{status.connected && (
<button onClick={withdrawRequest} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
<Undo2 className="w-4 h-4" /> Withdraw request
</button>
)}
</div>
) : (
<div className="flex flex-wrap items-center gap-3">
<p className="text-brand-700">Version {status.latestVersion} is available.{status.updateSnoozed && <span className="text-gray-500"> You chose &quot;Not now&quot;, so there are no reminders until the next version.</span>}</p>
{status.connected && (
<button onClick={requestUpdate} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
<ArrowUpCircle className="w-4 h-4" /> {busy ? "Working…" : "Request update"}
</button>
)}
{!status.urgentUpdate && (status.updateSnoozed ? (
<button onClick={() => snooze(false)} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
<BellRing className="w-4 h-4" /> Remind me again
</button>
) : (
<button onClick={() => snooze(true)} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
<BellOff className="w-4 h-4" /> Not now
</button>
))}
</div>
)}
{status.releases.length > 0 && (
<div className="border-t pt-3">
<h3 className="font-medium text-gray-900 mb-2">What&apos;s new</h3>
<div className="space-y-4">
{status.releases.map(r => (
<div key={r.version}>
<div className="flex flex-wrap items-center gap-2 mb-1">
<span className="font-semibold">{r.version}</span>
{r.urgent && <span className="inline-flex items-center gap-1 rounded-full bg-red-50 border border-red-200 px-2 py-0.5 text-xs font-medium text-red-700"><ShieldAlert className="w-3 h-3" /> Security or urgent fix</span>}
{r.releasedAt && <span className="text-xs text-gray-500">{new Date(r.releasedAt).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" })}</span>}
</div>
<ReleaseNotes notes={r.notes} />
</div>
))}
</div>
</div>
)}
</div>
))}
)}
</div>
{status.notices.length > 0 && (
<div className="border rounded-xl p-4 text-sm">
<div className="flex items-center gap-2 font-medium text-gray-900 mb-2"><Megaphone className="w-4 h-4" /> From your provider</div>
<div className="space-y-3">
{status.notices.map(n => (
<div key={n.id}>
<p className="font-medium">{n.title}</p>
<p className="text-gray-700 whitespace-pre-line">{n.message}</p>
{n.endsAt && <p className="text-xs text-gray-500 mt-0.5">Until {fmt(n.endsAt)}</p>}
</div>
))}
</div>
</div>
)}
{msg && <div className={`p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
</div>
);
@@ -2,18 +2,20 @@
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { ShieldAlert, KeyRound, ArrowUpCircle } from "lucide-react";
import { ShieldAlert, KeyRound, ArrowUpCircle, BellOff } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import type { HubStatus } from "@/components/admin/LicenceTab";
/**
* Licence and update notices for **site admins only** (never supervisors, staff, users or the
* public site): grace period, suspended/revoked, not connected, update available.
* public site): grace period, suspended/revoked, not connected, update available (with "Not now").
* The provider's own notices are ProviderNotices.
*/
export function LicenceBanner() {
const { user, token } = useAuth();
const [status, setStatus] = useState<HubStatus | null>(null);
const [busy, setBusy] = useState(false);
const isAdmin = user?.role === "admin";
useEffect(() => {
@@ -50,12 +52,31 @@ export function LicenceBanner() {
</div>
);
}
// Once an update has been requested there's nothing more for the admin to do.
if (status.updateAvailable && !status.updateRequest) {
// Once an update has been requested there's nothing more for the admin to do. "Not now" hides it
// until the next version, except for urgent (security or serious bug) updates.
if (status.urgentUpdate && !status.updateRequest) {
return (
<div role="status" className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm px-4 py-2 flex items-center gap-2">
<ArrowUpCircle className="w-4 h-4 shrink-0" />
<span>Cross Events {status.latestVersion} is available (this site runs {status.version}). Request the update in {link}</span>
<div role="alert" className="bg-red-50 border-b border-red-200 text-red-800 text-sm px-4 py-2 flex items-center gap-2">
<ShieldAlert className="w-4 h-4 shrink-0" />
<span>Cross Events {status.latestVersion} fixes a security problem or a serious bug (this site runs {status.version}). Please request the update in {link}</span>
</div>
);
}
if (status.updateAvailable && !status.updateRequest && !status.updateSnoozed) {
const notNow = async () => {
setBusy(true);
try { setStatus(await apiFetch<HubStatus>("/api/hub/update-snooze", { method: "POST", authToken: token, body: { snooze: true } })); } catch { /* the Licence page shows why */ }
setBusy(false);
};
return (
<div role="status" className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm px-4 py-2 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="flex items-center gap-2">
<ArrowUpCircle className="w-4 h-4 shrink-0" />
<span>Cross Events {status.latestVersion} is available (this site runs {status.version}). See what&apos;s new and request it in {link}</span>
</span>
<button onClick={notNow} disabled={busy} className="ml-auto inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-brand-700 hover:bg-brand-100 disabled:opacity-50" title="Hide this until the next version comes out">
<BellOff className="w-3.5 h-3.5" /> Not now
</button>
</div>
);
}
@@ -0,0 +1,71 @@
"use client";
import React, { useEffect, useState } from "react";
import { Info, Wrench, AlertTriangle, X } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import type { HubNotice } from "@/components/admin/LicenceTab";
const DISMISSED_KEY = "cross_events_dismissed_notices";
const LEVEL = {
info: { icon: Info, tone: "bg-sky-50 border-sky-200 text-sky-900" },
maintenance: { icon: Wrench, tone: "bg-amber-50 border-amber-200 text-amber-900" },
warning: { icon: AlertTriangle, tone: "bg-red-50 border-red-200 text-red-800" },
} as const;
// A notice the provider edits shows again, so it's remembered by id and last change.
const noticeKey = (n: HubNotice) => `${n.id}:${n.updatedAt || ""}`;
function readDismissed(): string[] {
try { return JSON.parse(localStorage.getItem(DISMISSED_KEY) || "[]"); } catch { return []; }
}
/**
* Notices from the provider (sent by the Cross Events Hub): maintenance, news and so on. Shown to
* site admins and supervisors only. Each can be closed on this browser; it's still listed on Site
* settings → Licence.
*/
export function ProviderNotices() {
const { user, token } = useAuth();
const [notices, setNotices] = useState<HubNotice[]>([]);
const [dismissed, setDismissed] = useState<string[]>([]);
const canSee = user?.role === "admin" || user?.role === "supervisor";
useEffect(() => {
if (!canSee || !token) return;
setDismissed(readDismissed());
apiFetch<HubNotice[]>("/api/hub/notices", { authToken: token }).then(setNotices).catch(() => {});
}, [canSee, token]);
if (!canSee) return null;
const shown = notices.filter(n => !dismissed.includes(noticeKey(n)));
if (!shown.length) return null;
const dismiss = (n: HubNotice) => {
// Keep only notices that still exist, so the list doesn't grow forever.
const next = [...dismissed.filter(k => notices.some(x => noticeKey(x) === k)), noticeKey(n)];
setDismissed(next);
try { localStorage.setItem(DISMISSED_KEY, JSON.stringify(next)); } catch { /* private window: closed until reload */ }
};
return (
<>
{shown.map(n => {
const { icon: Icon, tone } = LEVEL[n.level] || LEVEL.info;
return (
<div key={n.id} role={n.level === "warning" ? "alert" : "status"} className={`border-b text-sm px-4 py-2 flex items-start gap-2 ${tone}`}>
<Icon className="w-4 h-4 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<span className="font-medium">{n.title}</span>
<span className="whitespace-pre-line"> — {n.message}</span>
</div>
<button onClick={() => dismiss(n)} className="shrink-0 rounded p-0.5 hover:bg-black/5" aria-label={`Close "${n.title}"`}>
<X className="w-4 h-4" />
</button>
</div>
);
})}
</>
);
}
+8 -2
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle, MapPinned, Archive, Users } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle, MapPinned, Archive, Users, BellOff, Megaphone } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -184,7 +184,13 @@ export const siteSettingsHelpContent: HelpContent = {
When a licence is suspended, revoked or past its grace period, nothing new can be added — no events, registrations or payments. Tickets for events people already paid for still scan, and all data can still be viewed and exported.
</GuideItem>
<GuideItem icon={ArrowUpCircle} title="Updates" tone="brand">
When a new version is out, press Request update. Your provider is told and updates the site for you — there&apos;s nothing to run yourself.
When a new version is out, the Version box shows what&apos;s new in it, so you can decide whether you want it. Press Request update and your provider updates the site for you — there&apos;s nothing to run yourself. Changed your mind? Withdraw request tells your provider not to.
</GuideItem>
<GuideItem icon={BellOff} title="Not now" tone="gray">
Not now (on the Licence page or the update banner) hides the reminder until the next version comes out. Updates that fix a security problem or a serious bug can&apos;t be put off.
</GuideItem>
<GuideItem icon={Megaphone} title="Notices from your provider" tone="brand">
Your provider can post notices, such as planned maintenance. Site admins and supervisors see them as a banner at the top of the dashboard; close one with ×. Current notices are also listed on this page.
</GuideItem>
</div>
</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>
),
},
+4
View File
@@ -178,6 +178,10 @@ export type Event = {
sessionReminders?: { offsets: number[]; channels: ("email" | "whatsapp")[] } | null;
certificatesEnabled?: boolean;
certificateConfig?: CertificateConfig | 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;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.46.1",
"version": "1.47.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.46.1",
"version": "1.47.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.46.1",
"version": "1.47.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",