Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13fd3a4cbb | ||
|
|
809b6a11d7 |
@@ -7,6 +7,26 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.15.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Parental consent for youth events. A new "Requires parental consent" event setting (event wizard → Visibility) has its own consent text and optional medical info, emergency contact and photo consent questions. It turns on named attendees automatically.
|
||||
- Registering then needs a name and date of birth for every main-ticket place. Anyone under 18 on the event's start date needs a parent or guardian's name, email and phone.
|
||||
- This applies on the registration page, manual registration, at the door, the kiosk, and when editing names in My Events.
|
||||
- The parent gets an email and a WhatsApp (always both) with a link to a new consent page (`/consent/<token>`). There they read the text, answer the optional questions, type their full name and confirm they're the parent or guardian, then give or decline.
|
||||
- The system records the time, IP, device, signed name, and the exact consent text and version shown.
|
||||
- Medical and emergency details are stored encrypted.
|
||||
- The registrant and the event's notification recipients are told the outcome.
|
||||
- Tickets are still issued, but the scanner blocks check-in until consent is given, showing why. Supervisors can override, and the override is recorded in the audit log. On a youth event, a ticket with no named attendee counts as missing consent.
|
||||
- A daily 09:00 job reminds unanswered requests after 2 days, then every 3 days until the event. It also deletes medical and emergency details a set number of days after the event (Site Settings → Legal, default 30). `CONSENT_JOBS_ENABLED=false` turns it off.
|
||||
- Supervisor → Parental consent page: consent status per attendee, resend one or all pending requests, re-request consent after the consent text changes, and a printable, confidential medical & emergency list. Every view of the list is recorded in the audit log.
|
||||
- New reports: "Parental consent status" and "Medical & emergency list".
|
||||
- My Events shows each attendee's consent status, with a "Resend consent" button.
|
||||
- Anonymising or closing an account also clears that person's attendees' parent, date-of-birth, medical and emergency details.
|
||||
- New endpoints under `/api/consent`. `GET /api/tickets/scan-preview/:qrCode` now returns `consentStatus`, and `POST /api/tickets/scan/:qrCode` accepts `overrideConsent`.
|
||||
- Requires the `add_parental_consent` database migration.
|
||||
|
||||
## [1.14.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
+13
-1
@@ -134,6 +134,13 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
added or changed later from My Events, and attendees can be sent a link to fill in
|
||||
their own form. Available on the registration page, manual registration, At the
|
||||
door and the kiosk.
|
||||
- **Parental consent (youth events)** — on events that require it, every attendee
|
||||
gives a date of birth; for anyone under 18, a parent or guardian gets an email and
|
||||
a WhatsApp with a consent link. They read the event's consent text, optionally give
|
||||
medical info, an emergency contact and photo consent, type their name to sign, and
|
||||
give or decline. Tickets are issued but can't be checked in until consent is given
|
||||
(supervisors can override, audited). Reminders go after 2 days, then every 3 days.
|
||||
Medical details are encrypted and deleted a set number of days after the event.
|
||||
- **Coupon codes** — percentage, fixed-amount or free (comp) codes, entered under
|
||||
"Have a code?" on the registration page, in My Events (for unpaid registrations),
|
||||
manual registration, At the door and the kiosk. Codes can be limited to events,
|
||||
@@ -285,6 +292,9 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
Reconcile (Yoco transactions), and Payment Link.
|
||||
- **Coupon codes** (`/dashboard/supervisor/coupons`) — create, edit and turn off
|
||||
discount codes, and see who used each one.
|
||||
- **Parental consent** (`/dashboard/supervisor/consent`) — consent status per
|
||||
attendee on youth events, resend (one or all pending), re-request after the
|
||||
consent text changes, and a printable medical & emergency list (audited).
|
||||
- **Email attendees / WhatsApp attendees** — bulk and scheduled messaging.
|
||||
- **Forms** — view, filter, edit and print form responses.
|
||||
- **Reports** — see [Reports](#reports).
|
||||
@@ -338,7 +348,7 @@ a viewer with its own filters, stat tiles and bar charts.
|
||||
| Category | Reports |
|
||||
|----------|---------|
|
||||
| Financial | Payments between dates · Revenue summary (by method) · Revenue detailed · Finance report (revenue & costs, after discounts) · Profit report · Discounts (coupon codes) |
|
||||
| Registration | Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) |
|
||||
| Registration | Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) · Parental consent status · Medical & emergency list |
|
||||
| Ticketing | Ticket usage summary |
|
||||
| Donations | Donations breakdown (used / unused per event) |
|
||||
| Cashup | Cashup reconciliation · Cashup audit trail |
|
||||
@@ -410,6 +420,7 @@ falling back to a general guide.
|
||||
|-----|----------|
|
||||
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h |
|
||||
| Daily event summary email | 07:00 daily |
|
||||
| Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) |
|
||||
| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) |
|
||||
| Temp file cleanup (files older than 7 days in `/temp`) | Daily |
|
||||
|
||||
@@ -442,6 +453,7 @@ falling back to a general guide.
|
||||
| `/contact`, `/legal/terms`, `/legal/privacy`, `/lockdown-rules` | Public | Info pages |
|
||||
| `/login`, `/register`, `/forgot-password`, `/reset-password`, `/activate-account` | Public | Accounts |
|
||||
| `/setup` | First run | Setup wizard |
|
||||
| `/consent/[token]` | Parent/guardian | Give or decline parental consent |
|
||||
| `/self-service` | Supervisor-unlocked | Kiosk |
|
||||
| `/set-banner` | Supervisor | Site banner |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
|
||||
|
||||
@@ -66,6 +66,7 @@ or Branding tab has been saved even once — the wizard always writes
|
||||
| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
|
||||
| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
|
||||
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
|
||||
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
|
||||
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
|
||||
|
||||
## WhatsApp
|
||||
|
||||
@@ -52,6 +52,8 @@ WAWP_INSTANCE_ID=your_wawp_instance_id
|
||||
DAILY_SUMMARY_ENABLED=true
|
||||
SCHEDULED_EMAILS_ENABLED=true
|
||||
SCHEDULED_EMAILS_INTERVAL_MS=30000
|
||||
# Daily 09:00 parental-consent job: reminders, and deleting medical details after events
|
||||
CONSENT_JOBS_ENABLED=true
|
||||
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
|
||||
# Retention count is set via Admin → Site Settings → Backups (default 14).
|
||||
BACKUP_ENABLED=true
|
||||
|
||||
@@ -19,6 +19,7 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Event Attachments](#event-attachments)
|
||||
- [Registrations](#registrations)
|
||||
- [Coupons](#coupons)
|
||||
- [Parental consent](#parental-consent)
|
||||
- [Payments](#payments)
|
||||
- [Tickets](#tickets)
|
||||
- [Broadcasts (Email)](#broadcasts-email)
|
||||
@@ -276,6 +277,31 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
|
||||
|
||||
---
|
||||
|
||||
## Parental consent
|
||||
|
||||
Youth events (`requiresParentalConsent`, plus `consentText`, `consentCollectMedical`, `consentCollectEmergency` and `consentCollectPhoto` on the event) need every main place named with a `dateOfBirth`. Attendees under 18 on the start date also need `parentName`, `parentEmail` and `parentPhone`. Changing `consentText` bumps `consentTextVersion`.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/consent/:token` | public | What the parent sees: attendee, event, consent text, fields to fill in |
|
||||
| POST | `/api/consent/:token` | public | `{ decision: "give"\|"decline", signedName, isGuardian: true, medical?, emergency?, photoConsent? }` |
|
||||
| GET | `/api/consent/events/:eventId` | staff+ | Consent status per attendee, with counts |
|
||||
| POST | `/api/consent/requests/:id/resend` | user+ | Re-send one request with a new link (registrant or staff) |
|
||||
| POST | `/api/consent/events/:eventId/resend-pending` | supervisor+ | Re-send every pending request |
|
||||
| POST | `/api/consent/events/:eventId/rerequest` | supervisor+ | New requests for consent given on older text, and minors with none |
|
||||
| GET | `/api/consent/events/:eventId/medical` | supervisor+ | Decrypted medical & emergency list (audited as `medical_list_viewed`) |
|
||||
|
||||
How requests work:
|
||||
- Requests go to the parent by both email and WhatsApp, whatever anyone's notification preference.
|
||||
- Links are random tokens; only an HMAC is stored. They expire when the event ends.
|
||||
- The answer stores the signed name, time, IP, device, and the exact text and version shown.
|
||||
- Medical and emergency details are AES-256-GCM encrypted. A daily job (09:00, `CONSENT_JOBS_ENABLED=false` to disable) sends reminders (day 2, then every 3 days) and deletes them `consent_medical_retention_days` (default 30) after the event.
|
||||
|
||||
Check-in rules:
|
||||
- `GET /api/tickets/scan-preview/:qrCode` returns `consentStatus` (`not_required`, `missing`, `pending`, `given` or `declined`).
|
||||
- `POST /api/tickets/scan/:qrCode` returns `403 { code: "CONSENT_REQUIRED" }` unless consent is given.
|
||||
- Supervisors can pass `overrideConsent: true`, which is audited as `consent_override`.
|
||||
|
||||
## Coupons
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ConsentStatus" AS ENUM ('pending', 'given', 'declined');
|
||||
|
||||
-- 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 'consent_override';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'consent_rerequested';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'medical_list_viewed';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Attendee" ADD COLUMN "parentEmail" TEXT,
|
||||
ADD COLUMN "parentName" TEXT,
|
||||
ADD COLUMN "parentPhone" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "consentCollectEmergency" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "consentCollectMedical" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "consentCollectPhoto" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "consentText" TEXT,
|
||||
ADD COLUMN "consentTextVersion" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN "requiresParentalConsent" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ConsentRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"attendeeId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"status" "ConsentStatus" NOT NULL DEFAULT 'pending',
|
||||
"textVersion" INTEGER NOT NULL,
|
||||
"consentText" TEXT NOT NULL,
|
||||
"parentName" TEXT,
|
||||
"parentEmail" TEXT,
|
||||
"parentPhone" TEXT,
|
||||
"signedName" TEXT,
|
||||
"ip" TEXT,
|
||||
"device" TEXT,
|
||||
"medical" TEXT,
|
||||
"emergency" TEXT,
|
||||
"photoConsent" BOOLEAN,
|
||||
"respondedAt" TIMESTAMP(3),
|
||||
"lastSentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"remindersSent" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"medicalPurgedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ConsentRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ConsentRequest_tokenHash_key" ON "ConsentRequest"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConsentRequest_attendeeId_createdAt_idx" ON "ConsentRequest"("attendeeId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConsentRequest_eventId_status_idx" ON "ConsentRequest"("eventId", "status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConsentRequest" ADD CONSTRAINT "ConsentRequest_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConsentRequest" ADD CONSTRAINT "ConsentRequest_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -110,37 +110,45 @@ model User {
|
||||
}
|
||||
|
||||
model Event {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
description String?
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
registrationDeadline DateTime?
|
||||
goLiveAt DateTime @default(now())
|
||||
price Float
|
||||
picture String?
|
||||
isActive Boolean @default(true)
|
||||
isHidden Boolean @default(false)
|
||||
requiresAuth Boolean @default(true)
|
||||
requiresRegistration Boolean @default(true)
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
description String?
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
registrationDeadline DateTime?
|
||||
goLiveAt DateTime @default(now())
|
||||
price Float
|
||||
picture String?
|
||||
isActive Boolean @default(true)
|
||||
isHidden Boolean @default(false)
|
||||
requiresAuth Boolean @default(true)
|
||||
requiresRegistration Boolean @default(true)
|
||||
// Lets a payer name each person on a group registration (one named ticket each).
|
||||
allowNamedAttendees Boolean @default(false)
|
||||
contactName String?
|
||||
contactPhone String?
|
||||
contactEmail String?
|
||||
location String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdById String?
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
redirectUrl String? @unique
|
||||
eventOptions EventOption[]
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
tickets Ticket[]
|
||||
attachments EventAttachment[]
|
||||
form EventForm?
|
||||
Section Section[]
|
||||
allowNamedAttendees Boolean @default(false)
|
||||
// Youth event: every attendee gives a date of birth, and a parent/guardian must give
|
||||
// consent for anyone under 18 before they can be checked in.
|
||||
requiresParentalConsent Boolean @default(false)
|
||||
consentText String?
|
||||
consentTextVersion Int @default(1)
|
||||
consentCollectMedical Boolean @default(false)
|
||||
consentCollectEmergency Boolean @default(false)
|
||||
consentCollectPhoto Boolean @default(false)
|
||||
contactName String?
|
||||
contactPhone String?
|
||||
contactEmail String?
|
||||
location String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdById String?
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
redirectUrl String? @unique
|
||||
eventOptions EventOption[]
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
tickets Ticket[]
|
||||
attachments EventAttachment[]
|
||||
form EventForm?
|
||||
Section Section[]
|
||||
|
||||
cashupStatus CashupStatus @default(open)
|
||||
cashupDraft Json?
|
||||
@@ -156,8 +164,9 @@ model Event {
|
||||
|
||||
// Users who should receive registration/payment/daily-summary notifications for this
|
||||
// event. Falls back to `createdBy` when empty (see backend/src/utils/notifications.js).
|
||||
notifyRecipients User[] @relation("EventNotifyRecipients")
|
||||
notifyRecipients User[] @relation("EventNotifyRecipients")
|
||||
coupons CouponEvent[]
|
||||
consentRequests ConsentRequest[]
|
||||
|
||||
@@index([createdById])
|
||||
@@index([closedById])
|
||||
@@ -438,6 +447,9 @@ enum AdminAuditAction {
|
||||
coupon_updated
|
||||
coupon_disabled
|
||||
coupon_applied_manual
|
||||
consent_override
|
||||
consent_rerequested
|
||||
medical_list_viewed
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -963,6 +975,9 @@ model Attendee {
|
||||
userId String? // existing account matched by email/phone
|
||||
dateOfBirth DateTime?
|
||||
gender String?
|
||||
parentName String?
|
||||
parentEmail String?
|
||||
parentPhone String?
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -971,8 +986,50 @@ model Attendee {
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
ticket Ticket?
|
||||
formResponses FormResponse[]
|
||||
consentRequests ConsentRequest[]
|
||||
|
||||
@@index([registrationId])
|
||||
@@index([registrationOptionId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ─── Parental consent ─────────────────────────────────────────────────────────
|
||||
|
||||
enum ConsentStatus {
|
||||
pending
|
||||
given
|
||||
declined
|
||||
}
|
||||
|
||||
// A request for a parent/guardian to consent for one under-18 attendee. Only the hash
|
||||
// of the link token is stored. A new request (e.g. after the consent text changes)
|
||||
// supersedes earlier ones for the same attendee; the latest one decides check-in.
|
||||
model ConsentRequest {
|
||||
id String @id @default(uuid())
|
||||
attendeeId String
|
||||
eventId String
|
||||
tokenHash String @unique
|
||||
status ConsentStatus @default(pending)
|
||||
textVersion Int
|
||||
consentText String // the exact text the parent was shown
|
||||
parentName String?
|
||||
parentEmail String?
|
||||
parentPhone String?
|
||||
signedName String?
|
||||
ip String?
|
||||
device String?
|
||||
medical String? // encrypted JSON (utils/encryption.js); purged after the event
|
||||
emergency String? // encrypted JSON; purged after the event
|
||||
photoConsent Boolean?
|
||||
respondedAt DateTime?
|
||||
lastSentAt DateTime @default(now())
|
||||
remindersSent Int @default(0)
|
||||
expiresAt DateTime
|
||||
medicalPurgedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([attendeeId, createdAt])
|
||||
@@index([eventId, status])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { describeUserAgent } = require('../utils/userAgent');
|
||||
const {
|
||||
isMinor, consentStatusFor, validateConsentAnswer, sealJson, openJson, latestRequestsFor,
|
||||
findRequestByToken, resendConsentRequest, createConsentRequest,
|
||||
} = require('../utils/consent');
|
||||
|
||||
const STAFF = ['admin', 'supervisor', 'staff'];
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
|
||||
// What the parent sees — never the registrant's contact details or other attendees.
|
||||
function publicView(request) {
|
||||
const ev = request.event;
|
||||
const latestForAttendee = request._isLatest !== false;
|
||||
return {
|
||||
status: request.status,
|
||||
superseded: !latestForAttendee,
|
||||
expired: new Date(request.expiresAt) < new Date(),
|
||||
respondedAt: request.respondedAt,
|
||||
attendeeName: request.attendee.name,
|
||||
parentName: request.parentName,
|
||||
consentText: request.consentText,
|
||||
textVersion: request.textVersion,
|
||||
event: { title: ev.title, startDate: ev.startDate, endDate: ev.endDate, location: ev.location },
|
||||
collect: { medical: ev.consentCollectMedical, emergency: ev.consentCollectEmergency, photo: ev.consentCollectPhoto },
|
||||
};
|
||||
}
|
||||
|
||||
// @desc Consent page data for a parent/guardian
|
||||
// @route GET /api/consent/:token
|
||||
// @access Public (the token is the credential)
|
||||
const getConsent = async (req, res) => {
|
||||
try {
|
||||
const request = await findRequestByToken(req.params.token);
|
||||
if (!request) { res.status(404); throw new Error('This consent link is not valid. It may have been replaced by a newer one — check your latest message.'); }
|
||||
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
|
||||
res.json(publicView({ ...request, _isLatest: latest?.id === request.id }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Give or decline consent
|
||||
// @route POST /api/consent/:token
|
||||
// @access Public (the token is the credential)
|
||||
const answerConsent = async (req, res) => {
|
||||
try {
|
||||
const request = await findRequestByToken(req.params.token);
|
||||
if (!request) { res.status(404); throw new Error('This consent link is not valid.'); }
|
||||
if (request.status !== 'pending') { res.status(409); throw new Error(`Consent has already been ${request.status}.`); }
|
||||
if (new Date(request.expiresAt) < new Date()) { res.status(410); throw new Error('This consent link has expired because the event has ended.'); }
|
||||
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
|
||||
if (latest?.id !== request.id) { res.status(409); throw new Error('A newer consent request replaced this one — please use the link in your latest message.'); }
|
||||
|
||||
let answer;
|
||||
try { answer = validateConsentAnswer(req.body || {}, request.event); } catch (e) { res.status(400); throw e; }
|
||||
|
||||
// Only flips pending → answered once, even if the form is submitted twice at once.
|
||||
const updated = await prisma.consentRequest.updateMany({
|
||||
where: { id: request.id, status: 'pending' },
|
||||
data: {
|
||||
status: answer.decision === 'give' ? 'given' : 'declined',
|
||||
signedName: answer.signedName,
|
||||
ip: getClientIp(req),
|
||||
device: describeUserAgent(req.headers['user-agent']),
|
||||
medical: sealJson(answer.medical),
|
||||
emergency: sealJson(answer.emergency),
|
||||
photoConsent: answer.photoConsent,
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) { res.status(409); throw new Error('Consent has already been answered.'); }
|
||||
|
||||
notifyConsentAnswered(request.id).catch(e => console.error('[consent] notify failed:', e?.message || e));
|
||||
res.json({ message: answer.decision === 'give' ? 'Thank you — consent has been given.' : 'Thank you — consent has been declined.', status: answer.decision === 'give' ? 'given' : 'declined' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// Tells the registrant and the event's notify recipients how a parent answered.
|
||||
async function notifyConsentAnswered(requestId) {
|
||||
const request = await prisma.consentRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
attendee: { include: { registration: { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } } } } },
|
||||
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||
},
|
||||
});
|
||||
if (!request) return;
|
||||
const { sendMail, emailWrapper } = require('../utils/email');
|
||||
const { shouldEmail, waText } = require('../utils/notify');
|
||||
const { buildEventNotifyRecipientList } = require('../utils/notifications');
|
||||
const given = request.status === 'given';
|
||||
const verb = given ? 'given' : 'declined';
|
||||
const who = request.attendee.name;
|
||||
const ev = request.event.title;
|
||||
const payer = request.attendee.registration.user;
|
||||
const line = given
|
||||
? `${request.signedName} gave parental consent for ${who} for ${ev}.`
|
||||
: `${request.signedName} declined parental consent for ${who} for ${ev}. ${who} can't be checked in — please contact the organisers about a refund.`;
|
||||
|
||||
const sends = [];
|
||||
const payerHasEmail = payer?.email && !payer.email.endsWith('@guest.local') && !payer.email.endsWith('@deleted.invalid');
|
||||
if (payerHasEmail && shouldEmail(payer) && payer.email !== request.parentEmail) {
|
||||
sends.push(sendMail({ to: payer.email, subject: `Consent ${verb} for ${who}`, html: emailWrapper(`<p>${line}</p>`), text: line }));
|
||||
}
|
||||
if (payer) sends.push(waText(payer, line));
|
||||
const staff = buildEventNotifyRecipientList(request.event);
|
||||
if (staff.length) {
|
||||
sends.push(sendMail({ to: staff.join(','), subject: `Consent ${verb}: ${who} — ${ev}`, html: emailWrapper(`<p>${line}</p>`), text: line }));
|
||||
}
|
||||
await Promise.allSettled(sends);
|
||||
}
|
||||
|
||||
/** One row per attendee on the event, with consent state — for the consent tracker. */
|
||||
async function consentRows(eventId) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
if (!event) return { event: null, rows: [] };
|
||||
const attendees = await prisma.attendee.findMany({
|
||||
where: { registration: { eventId, status: { not: 'cancelled' } } },
|
||||
include: { registration: { select: { id: true, status: true, user: { select: { id: true, name: true, email: true } } } } },
|
||||
orderBy: [{ registrationId: 'asc' }, { order: 'asc' }],
|
||||
});
|
||||
const latest = await latestRequestsFor(attendees.map(a => a.id));
|
||||
const rows = attendees.map(a => {
|
||||
const r = latest.get(a.id) || null;
|
||||
return {
|
||||
attendeeId: a.id,
|
||||
name: a.name,
|
||||
dateOfBirth: a.dateOfBirth,
|
||||
minor: isMinor(a.dateOfBirth, event.startDate),
|
||||
parentName: a.parentName, parentEmail: a.parentEmail, parentPhone: a.parentPhone,
|
||||
registrationId: a.registrationId,
|
||||
registrationStatus: a.registration.status,
|
||||
bookedBy: a.registration.user,
|
||||
status: consentStatusFor(a, event, r),
|
||||
request: r && {
|
||||
id: r.id, status: r.status, textVersion: r.textVersion, signedName: r.signedName, respondedAt: r.respondedAt,
|
||||
lastSentAt: r.lastSentAt, remindersSent: r.remindersSent, photoConsent: r.photoConsent,
|
||||
outdated: r.status === 'given' && r.textVersion < (event.consentTextVersion || 1),
|
||||
},
|
||||
};
|
||||
});
|
||||
return { event, rows };
|
||||
}
|
||||
|
||||
// @desc Consent status for every attendee on an event
|
||||
// @route GET /api/consent/events/:eventId
|
||||
// @access Private/Staff
|
||||
const listEventConsent = async (req, res) => {
|
||||
try {
|
||||
const { event, rows } = await consentRows(req.params.eventId);
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
const counts = rows.reduce((c, r) => { c[r.status] = (c[r.status] || 0) + 1; return c; }, {});
|
||||
res.json({
|
||||
event: { id: event.id, title: event.title, startDate: event.startDate, requiresParentalConsent: event.requiresParentalConsent, consentTextVersion: event.consentTextVersion },
|
||||
counts,
|
||||
rows,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Re-send one consent request (with a fresh link)
|
||||
// @route POST /api/consent/requests/:id/resend
|
||||
// @access Private (the registrant, or staff)
|
||||
const resendRequest = async (req, res) => {
|
||||
try {
|
||||
const request = await prisma.consentRequest.findUnique({ where: { id: req.params.id }, include: { attendee: { include: { registration: true } } } });
|
||||
if (!request) { res.status(404); throw new Error('Consent request not found'); }
|
||||
if (request.attendee.registration.userId !== req.user.id && !STAFF.includes(req.user.role)) { res.status(403); throw new Error('Not authorized'); }
|
||||
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
|
||||
if (latest?.id !== request.id) { res.status(400); throw new Error('A newer consent request has replaced this one.'); }
|
||||
const sent = await resendConsentRequest(request.id);
|
||||
res.json({ message: sent.length ? `Consent request re-sent by ${sent.join(' and ')}.` : 'The consent request could not be sent — check the parent\'s email and phone.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Re-send every consent request still pending on an event
|
||||
// @route POST /api/consent/events/:eventId/resend-pending
|
||||
// @access Private/Supervisor
|
||||
const resendPending = async (req, res) => {
|
||||
try {
|
||||
const { rows } = await consentRows(req.params.eventId);
|
||||
const pending = rows.filter(r => r.status === 'pending' && r.request);
|
||||
let sent = 0;
|
||||
for (const r of pending) {
|
||||
try { if ((await resendConsentRequest(r.request.id)).length) sent += 1; } catch {}
|
||||
}
|
||||
res.json({ message: `Re-sent ${sent} of ${pending.length} pending request(s).`, sent, pending: pending.length });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Ask again for consent where it was given on an older version of the text,
|
||||
// and for minors who never got a request (e.g. names added later)
|
||||
// @route POST /api/consent/events/:eventId/rerequest
|
||||
// @access Private/Supervisor
|
||||
const rerequestConsent = async (req, res) => {
|
||||
try {
|
||||
const { event, rows } = await consentRows(req.params.eventId);
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
if (!event.requiresParentalConsent) { res.status(400); throw new Error('This event does not need parental consent.'); }
|
||||
const targets = rows.filter(r => r.minor && r.registrationStatus !== 'cancelled' && (r.status === 'missing' || r.request?.outdated));
|
||||
let created = 0;
|
||||
for (const r of targets) {
|
||||
if (!r.parentEmail && !r.parentPhone) continue;
|
||||
const attendee = await prisma.attendee.findUnique({ where: { id: r.attendeeId } });
|
||||
await createConsentRequest(attendee, event);
|
||||
created += 1;
|
||||
}
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'consent_rerequested',
|
||||
targetType: 'Event', targetId: event.id, metadata: { requests: created, textVersion: event.consentTextVersion },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ message: `Sent ${created} new consent request(s).`, created });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Medical & emergency list for an event (decrypted) — every view is audited
|
||||
// @route GET /api/consent/events/:eventId/medical
|
||||
// @access Private/Supervisor
|
||||
const medicalList = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({ where: { id: req.params.eventId } });
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
const { rows } = await consentRows(event.id);
|
||||
const ids = rows.map(r => r.request?.id).filter(Boolean);
|
||||
const full = ids.length ? await prisma.consentRequest.findMany({ where: { id: { in: ids } } }) : [];
|
||||
const byId = new Map(full.map(r => [r.id, r]));
|
||||
const list = rows
|
||||
.filter(r => r.request && byId.get(r.request.id)?.status === 'given')
|
||||
.map(r => {
|
||||
const fr = byId.get(r.request.id);
|
||||
return {
|
||||
name: r.name, dateOfBirth: r.dateOfBirth,
|
||||
parentName: r.parentName, parentPhone: r.parentPhone, parentEmail: r.parentEmail,
|
||||
medical: openJson(fr.medical), emergency: openJson(fr.emergency), photoConsent: fr.photoConsent,
|
||||
purged: !!fr.medicalPurgedAt,
|
||||
};
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed',
|
||||
targetType: 'Event', targetId: event.id, metadata: { attendees: list.length },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ event: { id: event.id, title: event.title, startDate: event.startDate }, rows: list });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList };
|
||||
@@ -44,7 +44,7 @@ function toAbsoluteUrl(req, url) {
|
||||
// @access Private/Admin
|
||||
const createEvent = async (req, res) => {
|
||||
try {
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
id: uuidv4(),
|
||||
@@ -62,7 +62,13 @@ const createEvent = async (req, res) => {
|
||||
isHidden: isHidden === true || isHidden === 'true',
|
||||
requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true,
|
||||
requiresRegistration: requiresRegistration === false || requiresRegistration === 'false' ? false : true,
|
||||
allowNamedAttendees: allowNamedAttendees === true || allowNamedAttendees === 'true',
|
||||
// A youth event needs names (with dates of birth), so it always takes them.
|
||||
allowNamedAttendees: allowNamedAttendees === true || allowNamedAttendees === 'true' || requiresParentalConsent === true,
|
||||
requiresParentalConsent: requiresParentalConsent === true,
|
||||
consentText: consentText ? String(consentText) : null,
|
||||
consentCollectMedical: consentCollectMedical === true,
|
||||
consentCollectEmergency: consentCollectEmergency === true,
|
||||
consentCollectPhoto: consentCollectPhoto === true,
|
||||
contactName: contactName || null,
|
||||
contactPhone: contactPhone || null,
|
||||
contactEmail: contactEmail || null,
|
||||
@@ -538,7 +544,7 @@ const updateEvent = async (req, res) => {
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
title: title || event.title,
|
||||
@@ -553,7 +559,18 @@ const updateEvent = async (req, res) => {
|
||||
isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false),
|
||||
requiresAuth: requiresAuth !== undefined ? !(requiresAuth === false || requiresAuth === 'false') : (event.requiresAuth ?? true),
|
||||
requiresRegistration: requiresRegistration !== undefined ? !(requiresRegistration === false || requiresRegistration === 'false') : (event.requiresRegistration ?? true),
|
||||
allowNamedAttendees: allowNamedAttendees !== undefined ? (allowNamedAttendees === true || allowNamedAttendees === 'true') : event.allowNamedAttendees,
|
||||
allowNamedAttendees: (requiresParentalConsent ?? event.requiresParentalConsent) === true
|
||||
? true
|
||||
: (allowNamedAttendees !== undefined ? (allowNamedAttendees === true || allowNamedAttendees === 'true') : event.allowNamedAttendees),
|
||||
requiresParentalConsent: requiresParentalConsent !== undefined ? requiresParentalConsent === true : event.requiresParentalConsent,
|
||||
consentCollectMedical: consentCollectMedical !== undefined ? consentCollectMedical === true : event.consentCollectMedical,
|
||||
consentCollectEmergency: consentCollectEmergency !== undefined ? consentCollectEmergency === true : event.consentCollectEmergency,
|
||||
consentCollectPhoto: consentCollectPhoto !== undefined ? consentCollectPhoto === true : event.consentCollectPhoto,
|
||||
// Editing the consent text starts a new version; parents who already agreed keep
|
||||
// their record of the old one (supervisors can re-request consent).
|
||||
...(consentText !== undefined && String(consentText || '') !== String(event.consentText || '')
|
||||
? { consentText: consentText ? String(consentText) : null, consentTextVersion: (event.consentTextVersion || 1) + 1 }
|
||||
: {}),
|
||||
contactName: contactName !== undefined ? (contactName || null) : event.contactName,
|
||||
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
|
||||
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
|
||||
|
||||
@@ -10,11 +10,22 @@ const { getClientIp } = require('../utils/requestUtils');
|
||||
const { buildCouponLines, previewCoupon, applyCouponToRegistration, recalculateRegistrationDiscount, releaseRegistrationCoupons, describeCoupon } = require('../utils/coupons');
|
||||
const { lineKey, validateAttendees, createAttendees, namedPlacesByKey } = require('../utils/attendees');
|
||||
const { findAccountByContact } = require('../utils/accountMatch');
|
||||
const { ensureConsentRequests } = require('../utils/consent');
|
||||
|
||||
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
|
||||
// the background so a slow mail/WhatsApp send never holds up the registration.
|
||||
function queueConsentRequests(registrationId) {
|
||||
ensureConsentRequests(registrationId).catch(e => console.error('[consent] Failed to send requests:', e?.message || e));
|
||||
}
|
||||
|
||||
// Named attendees for API responses, with whether their ticket has been issued/used.
|
||||
const ATTENDEES_INCLUDE = {
|
||||
orderBy: { order: 'asc' },
|
||||
include: { ticket: { select: { id: true, isUsed: true } } },
|
||||
include: {
|
||||
ticket: { select: { id: true, isUsed: true } },
|
||||
// Latest consent request (youth events) — it decides check-in.
|
||||
consentRequests: { orderBy: { createdAt: 'desc' }, take: 1, select: { id: true, status: true, respondedAt: true, lastSentAt: true, textVersion: true } },
|
||||
},
|
||||
};
|
||||
|
||||
const TICKET_DELIVERY = ['payer', 'each'];
|
||||
@@ -399,7 +410,10 @@ const createRegistration = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (cleanAttendees.length > 0) await createAttendees(registration.id, cleanAttendees);
|
||||
if (cleanAttendees.length > 0) {
|
||||
await createAttendees(registration.id, cleanAttendees);
|
||||
queueConsentRequests(registration.id);
|
||||
}
|
||||
if (ticketDelivery) {
|
||||
await prisma.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
|
||||
}
|
||||
@@ -1241,7 +1255,10 @@ const createManualRegistration = async (req, res) => {
|
||||
|
||||
const registrationId = registration.id;
|
||||
|
||||
if (cleanAttendees.length > 0) await createAttendees(registrationId, cleanAttendees);
|
||||
if (cleanAttendees.length > 0) {
|
||||
await createAttendees(registrationId, cleanAttendees);
|
||||
queueConsentRequests(registrationId);
|
||||
}
|
||||
if (ticketDelivery) await prisma.registration.update({ where: { id: registrationId }, data: { ticketDelivery } });
|
||||
|
||||
if (couponCode && !couponError) {
|
||||
@@ -2171,6 +2188,7 @@ const updateAttendees = async (req, res) => {
|
||||
where: { id: a.id },
|
||||
data: {
|
||||
name: c.name, email: c.email, phone: c.phone,
|
||||
dateOfBirth: c.dateOfBirth, parentName: c.parentName, parentEmail: c.parentEmail, parentPhone: c.parentPhone,
|
||||
...(contactChanged ? { userId: match?.user?.id || null } : {}),
|
||||
},
|
||||
});
|
||||
@@ -2182,6 +2200,8 @@ const updateAttendees = async (req, res) => {
|
||||
if (ticketDelivery) await tx.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
|
||||
});
|
||||
|
||||
queueConsentRequests(registration.id);
|
||||
|
||||
// Paid registrations: re-sync tickets and send any new ones.
|
||||
if (registration.status === 'paid') {
|
||||
const created = await generateTicketsForRegistration(registration.id);
|
||||
|
||||
@@ -292,11 +292,14 @@ const getScanPreview = async (req, res) => {
|
||||
}
|
||||
|
||||
const r = rows[0];
|
||||
const { consentStatusForTickets } = require('../utils/consent');
|
||||
const consentStatus = (await consentStatusForTickets([r.id])).get(r.id) || 'not_required';
|
||||
res.json({
|
||||
id: r.id,
|
||||
quantity: Number(r.quantity),
|
||||
isUsed: r.isUsed,
|
||||
eventId: r.eventId,
|
||||
consentStatus,
|
||||
registrationOption: {
|
||||
id: r.registrationOptionId,
|
||||
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
|
||||
@@ -381,6 +384,25 @@ const scanTicket = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Youth events: no check-in without parental consent, unless a supervisor overrides.
|
||||
const { consentStatusForTickets } = require('../utils/consent');
|
||||
const consentStatus = (await consentStatusForTickets([ticket.id])).get(ticket.id) || 'not_required';
|
||||
if (!['not_required', 'given'].includes(consentStatus)) {
|
||||
const canOverride = ['admin', 'supervisor'].includes(req.user?.role);
|
||||
if (!(req.body?.overrideConsent === true && canOverride)) {
|
||||
const why = consentStatus === 'declined' ? 'Parental consent was declined' : consentStatus === 'pending' ? 'Parental consent is still pending' : 'Parental consent is missing';
|
||||
return res.status(403).json({ message: `${why} — this ticket can't be checked in.`, code: 'CONSENT_REQUIRED', consentStatus, canOverride, ticket });
|
||||
}
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'consent_override',
|
||||
targetType: 'Ticket', targetId: ticket.id,
|
||||
metadata: { consentStatus, eventId: ticket.eventId },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
// Determine how many to redeem this scan (defaults to all remaining)
|
||||
const requestedQty = parseInt(req.body?.qty || req.body?.quantity) || remaining;
|
||||
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
|
||||
|
||||
@@ -888,6 +888,7 @@ const anonymizeUser = async (req, res) => {
|
||||
}
|
||||
});
|
||||
await clearTrustedDevices(req.params.id);
|
||||
await require('../utils/consent').clearConsentDataForUser(req.params.id);
|
||||
|
||||
res.json({ message: 'User data deleted' });
|
||||
} catch (error) {
|
||||
@@ -1149,6 +1150,7 @@ const closeAccount = async (req, res) => {
|
||||
},
|
||||
});
|
||||
await clearTrustedDevices(req.user.id);
|
||||
await require('../utils/consent').clearConsentDataForUser(req.user.id);
|
||||
// Send closure confirmation to the real address (before it was wiped)
|
||||
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
|
||||
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
|
||||
|
||||
@@ -176,6 +176,7 @@ app.use('/api', costRoutes);
|
||||
app.use('/api/cashups', cashupRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/coupons', require('./routes/couponRoutes'));
|
||||
app.use('/api/consent', require('./routes/consentRoutes'));
|
||||
app.use('/api/backups', backupRoutes);
|
||||
|
||||
// Pre-warm the settings cache so synchronous helpers have DB values from startup
|
||||
@@ -632,6 +633,18 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
]},
|
||||
|
||||
{ title: 'Parental consent', base: '/api/consent', endpoints: [
|
||||
{ method:'GET', path:'/api/consent/:token', auth:'public', desc:'Consent page data for a parent/guardian (the link token is the credential; rate-limited)',
|
||||
responses:[{ status:200, desc:'Success', body:{ status:'pending', attendeeName:'Sam Smith', consentText:'I give permission…', event:{ title:'Youth Camp' }, collect:{ medical:true, emergency:true, photo:true } }}, { status:404, desc:'Unknown or replaced link', body:{ message:'This consent link is not valid.' }}]},
|
||||
{ method:'POST', path:'/api/consent/:token', auth:'public', desc:'Give or decline consent. Records name, time, IP, device and the consent text version; medical/emergency details are stored encrypted.',
|
||||
request:{ body:{ decision:'give', signedName:'Pat Smith', isGuardian:true, medical:{ allergies:'Peanuts' }, emergency:{ name:'Gran', phone:'0821234567' }, photoConsent:true }},
|
||||
responses:[{ status:200, desc:'Answered', body:{ status:'given' }}, { status:409, desc:'Already answered', body:{ message:'Consent has already been given.' }}]},
|
||||
{ method:'GET', path:'/api/consent/events/:eventId', auth:'staff+', desc:'Consent status for every attendee on an event' },
|
||||
{ method:'POST', path:'/api/consent/requests/:id/resend', auth:'user+', desc:'Re-send a pending request with a fresh link (registrant or staff)' },
|
||||
{ method:'POST', path:'/api/consent/events/:eventId/resend-pending', auth:'supervisor+', desc:'Re-send every pending request on an event' },
|
||||
{ method:'POST', path:'/api/consent/events/:eventId/rerequest', auth:'supervisor+', desc:'Ask again where consent was given on an older version of the text, or never requested' },
|
||||
{ method:'GET', path:'/api/consent/events/:eventId/medical', auth:'supervisor+', desc:'Decrypted medical & emergency list; every view is written to the admin audit log' },
|
||||
]},
|
||||
{ title: 'Coupons', base: '/api/coupons', endpoints: [
|
||||
{ method:'POST', path:'/api/coupons/validate', auth:'public', desc:'Check a code against a basket before registering; returns the discount. Login needed for restricted codes; staff can pass forUserId (or null for a walk-in).',
|
||||
request:{ body:{ code:'STAFF50', eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
|
||||
@@ -1295,6 +1308,37 @@ app.listen(PORT, () => {
|
||||
console.warn('[daily summaries] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Daily parental-consent job — reminders for unanswered requests, and purging
|
||||
// medical/emergency details a set number of days after each event.
|
||||
try {
|
||||
const enabled = String(process.env.CONSENT_JOBS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { sendConsentReminders, purgeConsentMedicalData } = require('./utils/consent');
|
||||
const runConsentJobs = async () => {
|
||||
try {
|
||||
const reminded = await sendConsentReminders(new Date());
|
||||
const purged = await purgeConsentMedicalData(new Date());
|
||||
if (reminded || purged) console.log(`[consent] Sent ${reminded} reminder(s), purged medical details on ${purged} request(s).`);
|
||||
} catch (e) {
|
||||
console.warn('[consent] Daily job failed:', e?.message || e);
|
||||
}
|
||||
};
|
||||
const scheduleConsent = () => {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(9, 0, 0, 0);
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
setTimeout(async () => { await runConsentJobs(); scheduleConsent(); }, next.getTime() - now.getTime());
|
||||
};
|
||||
scheduleConsent();
|
||||
console.log('[consent] Scheduler initialized (09:00 local time).');
|
||||
} else {
|
||||
console.log('[consent] Scheduler disabled by env CONSENT_JOBS_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[consent] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Daily temp-file cleanup — removes files older than 7 days from /temp
|
||||
try {
|
||||
const { cleanupTempFiles } = require('./utils/cleanupTemp');
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList } = require('../controllers/consentController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
// The token in the link is the parent's only credential, so keep guessing slow.
|
||||
const consentLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
|
||||
router.get('/events/:eventId', protect, staff, listEventConsent);
|
||||
router.post('/events/:eventId/resend-pending', protect, supervisor, resendPending);
|
||||
router.post('/events/:eventId/rerequest', protect, supervisor, rerequestConsent);
|
||||
router.get('/events/:eventId/medical', protect, supervisor, medicalList);
|
||||
router.post('/requests/:id/resend', protect, resendRequest);
|
||||
|
||||
// Public consent page
|
||||
router.get('/:token', consentLimiter, getConsent);
|
||||
router.post('/:token', consentLimiter, answerConsent);
|
||||
|
||||
module.exports = router;
|
||||
@@ -11,10 +11,16 @@
|
||||
* accounts, in which case nothing is linked.
|
||||
* - Registration.ticketDelivery = 'each' sends each attendee their own ticket by
|
||||
* email/WhatsApp; attendees with no contact details fall back to the payer.
|
||||
* - Youth events (requiresParentalConsent): every main place must be named with a date
|
||||
* of birth, and anyone under 18 on the start date needs a parent/guardian name, email
|
||||
* and phone (see utils/consent.js).
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { findAccountByContact } = require('./accountMatch');
|
||||
const { isMinor } = require('./consent');
|
||||
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
|
||||
const lineKey = (eventOptionId, variantId) => `${eventOptionId}::${variantId || ''}`;
|
||||
|
||||
@@ -34,27 +40,60 @@ function attendeeError(message) {
|
||||
* @param {object} [namedByKey] - places on those lines already named (and being kept)
|
||||
*/
|
||||
function validateAttendees(event, placesByKey, attendees, namedByKey = {}) {
|
||||
if (!attendees || attendees.length === 0) return [];
|
||||
if (!event.allowNamedAttendees) throw attendeeError('This event doesn\'t take attendee names.');
|
||||
if (!Array.isArray(attendees)) throw attendeeError('attendees must be a list');
|
||||
const consent = !!event.requiresParentalConsent;
|
||||
if ((!attendees || attendees.length === 0) && !consent) return [];
|
||||
if (!event.allowNamedAttendees && !consent) throw attendeeError('This event doesn\'t take attendee names.');
|
||||
if (!Array.isArray(attendees || [])) throw attendeeError('attendees must be a list');
|
||||
|
||||
const countByKey = {};
|
||||
return attendees.map((a, i) => {
|
||||
const cleaned = (attendees || []).map((a, i) => {
|
||||
const option = (event.eventOptions || []).find(o => o.id === a?.eventOptionId);
|
||||
if (!option) throw attendeeError(`Attendee ${i + 1}: unknown ticket option.`);
|
||||
if (!option.isMainTicket) throw attendeeError(`Attendee ${i + 1}: names can only be given for "${option.name}" main tickets.`);
|
||||
const name = String(a.name || '').trim();
|
||||
if (!name) throw attendeeError(`Attendee ${i + 1}: enter a name.`);
|
||||
const email = String(a.email || '').trim() || null;
|
||||
if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) throw attendeeError(`Attendee ${i + 1}: "${email}" isn't a valid email address.`);
|
||||
if (email && !EMAIL_RE.test(email)) throw attendeeError(`Attendee ${i + 1}: "${email}" isn't a valid email address.`);
|
||||
const phone = String(a.phone || '').trim() || null;
|
||||
const key = lineKey(a.eventOptionId, a.variantId);
|
||||
countByKey[key] = (countByKey[key] || 0) + 1;
|
||||
if (countByKey[key] + (namedByKey[key] || 0) > (placesByKey[key] || 0)) {
|
||||
throw attendeeError(`More names than "${option.name}" tickets.`);
|
||||
}
|
||||
return { eventOptionId: a.eventOptionId, variantId: a.variantId || null, name, email, phone };
|
||||
|
||||
// Date of birth (always kept if given) and, for youth events, parent details.
|
||||
let dateOfBirth = null;
|
||||
if (a.dateOfBirth) {
|
||||
dateOfBirth = new Date(a.dateOfBirth);
|
||||
if (isNaN(dateOfBirth.getTime()) || dateOfBirth > new Date()) throw attendeeError(`${name}: enter a valid date of birth.`);
|
||||
} else if (consent) {
|
||||
throw attendeeError(`${name}: enter a date of birth.`);
|
||||
}
|
||||
const parentName = String(a.parentName || '').trim() || null;
|
||||
const parentEmail = String(a.parentEmail || '').trim() || null;
|
||||
const parentPhone = String(a.parentPhone || '').trim() || null;
|
||||
if (consent && isMinor(dateOfBirth, event.startDate)) {
|
||||
if (!parentName || !parentEmail || !parentPhone) {
|
||||
throw attendeeError(`${name} is under 18, so a parent or guardian's name, email and phone are needed for consent.`);
|
||||
}
|
||||
if (!EMAIL_RE.test(parentEmail)) throw attendeeError(`${name}: "${parentEmail}" isn't a valid parent email address.`);
|
||||
}
|
||||
return { eventOptionId: a.eventOptionId, variantId: a.variantId || null, name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone };
|
||||
});
|
||||
|
||||
// Youth events need every main place named (with a date of birth).
|
||||
if (consent) {
|
||||
for (const o of event.eventOptions || []) {
|
||||
if (!o.isMainTicket) continue;
|
||||
for (const [key, places] of Object.entries(placesByKey)) {
|
||||
if (!key.startsWith(`${o.id}::`)) continue;
|
||||
if ((countByKey[key] || 0) + (namedByKey[key] || 0) < places) {
|
||||
throw attendeeError(`This is a youth event — add a name and date of birth for every "${o.name}" ticket.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,6 +186,10 @@ async function createAttendees(registrationId, attendees, db = prisma) {
|
||||
name: a.name,
|
||||
email: a.email,
|
||||
phone: a.phone,
|
||||
dateOfBirth: a.dateOfBirth || null,
|
||||
parentName: a.parentName || null,
|
||||
parentEmail: a.parentEmail || null,
|
||||
parentPhone: a.parentPhone || null,
|
||||
userId: match.user?.id || null,
|
||||
order,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Parental consent for youth events.
|
||||
*
|
||||
* Rules:
|
||||
* - On an event with requiresParentalConsent, every attendee gives a date of birth.
|
||||
* Anyone under 18 on the event's start date needs a parent/guardian (name, email,
|
||||
* phone), who is sent a consent request by email AND WhatsApp — regardless of anyone's
|
||||
* notification preference.
|
||||
* - A request's link token is random; only its HMAC is stored. Links expire when the
|
||||
* event ends and can be re-sent (which issues a new token).
|
||||
* - The parent's answer records the time, IP, device, typed name, and the exact consent
|
||||
* text and version they saw. Medical/emergency details are stored encrypted and purged
|
||||
* a set number of days after the event (setting consent_medical_retention_days).
|
||||
* - Tickets are issued as normal but can't be checked in until consent is given — the
|
||||
* latest request for the attendee decides. On a consent event, a ticket with no named
|
||||
* attendee is treated as missing consent. Supervisors can override (audited).
|
||||
* - Reminders go 2 days after the request, then every 3 days, until answered or the event.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const FIRST_REMINDER_AFTER = 2 * DAY;
|
||||
const REMINDER_EVERY = 3 * DAY;
|
||||
const DEFAULT_RETENTION_DAYS = 30;
|
||||
|
||||
function consentError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Whole years old on `onDate`. */
|
||||
function ageOn(dateOfBirth, onDate) {
|
||||
const dob = new Date(dateOfBirth);
|
||||
const on = new Date(onDate);
|
||||
let age = on.getUTCFullYear() - dob.getUTCFullYear();
|
||||
const beforeBirthday = on.getUTCMonth() < dob.getUTCMonth()
|
||||
|| (on.getUTCMonth() === dob.getUTCMonth() && on.getUTCDate() < dob.getUTCDate());
|
||||
if (beforeBirthday) age -= 1;
|
||||
return age;
|
||||
}
|
||||
|
||||
/** Under 18 on the event's start date. */
|
||||
function isMinor(dateOfBirth, eventStart) {
|
||||
if (!dateOfBirth) return false;
|
||||
return ageOn(dateOfBirth, eventStart) < 18;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consent state for one attendee: 'not_required' | 'missing' | 'pending' | 'given' | 'declined'.
|
||||
* `latest` is the attendee's most recent ConsentRequest (or null).
|
||||
*/
|
||||
function consentStatusFor(attendee, event, latest) {
|
||||
if (!event?.requiresParentalConsent) return 'not_required';
|
||||
if (!attendee) return 'missing'; // unnamed place on a consent event
|
||||
if (!isMinor(attendee.dateOfBirth, event.startDate)) return 'not_required';
|
||||
if (!latest) return 'missing';
|
||||
return latest.status;
|
||||
}
|
||||
|
||||
/** Whether a pending request is due a reminder now. */
|
||||
function reminderDue(request, now = new Date()) {
|
||||
if (!request || request.status !== 'pending') return false;
|
||||
const t = new Date(now).getTime();
|
||||
if (t >= new Date(request.expiresAt).getTime()) return false;
|
||||
const gap = request.remindersSent === 0 ? FIRST_REMINDER_AFTER : REMINDER_EVERY;
|
||||
return t - new Date(request.lastSentAt).getTime() >= gap;
|
||||
}
|
||||
|
||||
const str = (v, max = 500) => String(v ?? '').trim().slice(0, max);
|
||||
|
||||
/**
|
||||
* Checks a parent's answer on the consent page. Pure. Returns the cleaned answer.
|
||||
* @param {object} body - { decision: 'give'|'decline', signedName, isGuardian, medical, emergency, photoConsent }
|
||||
* @param {object} event - with consentCollectMedical / Emergency / Photo
|
||||
*/
|
||||
function validateConsentAnswer(body, event) {
|
||||
const decision = body?.decision;
|
||||
if (decision !== 'give' && decision !== 'decline') throw consentError('Choose to give or decline consent.');
|
||||
const signedName = str(body.signedName, 200);
|
||||
if (signedName.length < 2) throw consentError('Type your full name to sign.');
|
||||
if (body.isGuardian !== true) throw consentError('Confirm that you are the parent or legal guardian.');
|
||||
|
||||
const out = { decision, signedName, medical: null, emergency: null, photoConsent: null };
|
||||
if (decision === 'decline') return out;
|
||||
|
||||
if (event.consentCollectMedical) {
|
||||
const m = body.medical || {};
|
||||
out.medical = {
|
||||
allergies: str(m.allergies), medication: str(m.medication),
|
||||
conditions: str(m.conditions), medicalAid: str(m.medicalAid, 200),
|
||||
};
|
||||
}
|
||||
if (event.consentCollectEmergency) {
|
||||
const em = body.emergency || {};
|
||||
out.emergency = { name: str(em.name, 200), phone: str(em.phone, 40), relationship: str(em.relationship, 100) };
|
||||
if (!out.emergency.name || !out.emergency.phone) throw consentError('Add an emergency contact name and phone number.');
|
||||
}
|
||||
if (event.consentCollectPhoto) {
|
||||
if (body.photoConsent !== true && body.photoConsent !== false) throw consentError('Answer the photo consent question.');
|
||||
out.photoConsent = body.photoConsent;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Tokens ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`consent:${token}`).digest('hex');
|
||||
const newToken = () => crypto.randomBytes(24).toString('hex');
|
||||
|
||||
function consentLink(token) {
|
||||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
|
||||
return `${base}/consent/${token}`;
|
||||
}
|
||||
|
||||
// ─── Encryption of medical/emergency details ──────────────────────────────────
|
||||
|
||||
function sealJson(value) {
|
||||
if (!value) return null;
|
||||
const { encrypt } = require('./encryption');
|
||||
return encrypt(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function openJson(value) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const { decrypt } = require('./encryption');
|
||||
return JSON.parse(decrypt(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Database helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Latest request per attendee id. */
|
||||
async function latestRequestsFor(attendeeIds, db = prisma) {
|
||||
if (!attendeeIds.length) return new Map();
|
||||
const rows = await db.consentRequest.findMany({ where: { attendeeId: { in: attendeeIds } }, orderBy: { createdAt: 'desc' } });
|
||||
const out = new Map();
|
||||
for (const r of rows) if (!out.has(r.attendeeId)) out.set(r.attendeeId, r);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function sendConsentMessage(request, token, { reminder = false } = {}) {
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
|
||||
const { waTextAny } = require('./notify');
|
||||
const { isValidZAPhone } = require('./whatsapp');
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
|
||||
const event = request.event || await prisma.event.findUnique({ where: { id: request.eventId } });
|
||||
const attendee = request.attendee || await prisma.attendee.findUnique({ where: { id: request.attendeeId } });
|
||||
const link = consentLink(token);
|
||||
const when = event?.startDate ? new Date(event.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
|
||||
const subject = `${reminder ? 'Reminder: ' : ''}Consent needed for ${attendee.name} — ${event.title}`;
|
||||
const intro = `${attendee.name} has been registered for <strong>${event.title}</strong>${when ? ` on <strong>${when}</strong>` : ''}. As their parent or guardian, please review the details and give (or decline) consent.`;
|
||||
const sent = [];
|
||||
|
||||
if (request.parentEmail && request.parentEmail.includes('@')) {
|
||||
const html = emailWrapper(`
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">${reminder ? 'A reminder: ' : ''}Parental consent needed</p>
|
||||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${request.parentName || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${intro}</p>
|
||||
${ctaButton('Review and give consent', link)}
|
||||
${fallbackLink(link)}
|
||||
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">${attendee.name} can't be checked in at the event until consent is given.</p>
|
||||
`, { preheader: `Consent needed for ${attendee.name}` });
|
||||
try {
|
||||
await sendMail({ to: request.parentEmail, subject, html, text: `Hi ${request.parentName || 'there'},\n\n${intro.replace(/<[^>]+>/g, '')}\n\nReview and give consent: ${link}\n\n${orgName}` });
|
||||
sent.push('email');
|
||||
} catch (e) {
|
||||
console.warn('[consent] email failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
if (request.parentPhone && isValidZAPhone(request.parentPhone)) {
|
||||
await waTextAny({ name: request.parentName, phoneNumber: request.parentPhone },
|
||||
`${reminder ? '⏰ Reminder: ' : ''}Hi ${request.parentName || 'there'}, ${attendee.name} has been registered for *${event.title}*${when ? ` on ${when}` : ''}. Please give (or decline) parental consent here: ${link}`);
|
||||
sent.push('WhatsApp');
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates (and sends) a consent request for an attendee. Earlier requests stay as the
|
||||
* record of what was agreed before; this one now decides check-in.
|
||||
*/
|
||||
async function createConsentRequest(attendee, event) {
|
||||
const token = newToken();
|
||||
const request = await prisma.consentRequest.create({
|
||||
data: {
|
||||
attendeeId: attendee.id,
|
||||
eventId: event.id,
|
||||
tokenHash: hashToken(token),
|
||||
textVersion: event.consentTextVersion || 1,
|
||||
consentText: event.consentText || '',
|
||||
parentName: attendee.parentName,
|
||||
parentEmail: attendee.parentEmail,
|
||||
parentPhone: attendee.parentPhone,
|
||||
expiresAt: event.endDate,
|
||||
},
|
||||
});
|
||||
const sent = await sendConsentMessage({ ...request, event, attendee }, token);
|
||||
return { request, sent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure every under-18 attendee on a registration has a consent request — creates
|
||||
* and sends one for anyone who has none yet, or whose parent details have changed since.
|
||||
*/
|
||||
async function ensureConsentRequests(registrationId) {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { event: true, attendees: true } });
|
||||
if (!reg || !reg.event.requiresParentalConsent || reg.status === 'cancelled') return 0;
|
||||
const minors = reg.attendees.filter(a => isMinor(a.dateOfBirth, reg.event.startDate));
|
||||
const latest = await latestRequestsFor(minors.map(a => a.id));
|
||||
let created = 0;
|
||||
for (const a of minors) {
|
||||
const r = latest.get(a.id);
|
||||
const parentChanged = r && (r.parentEmail !== a.parentEmail || r.parentPhone !== a.parentPhone);
|
||||
if (!r || (parentChanged && r.status !== 'given')) {
|
||||
await createConsentRequest(a, reg.event);
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Re-sends a request with a fresh link (the old link stops working). */
|
||||
async function resendConsentRequest(requestId) {
|
||||
const request = await prisma.consentRequest.findUnique({ where: { id: requestId }, include: { event: true, attendee: true } });
|
||||
if (!request) throw consentError('Consent request not found', 404);
|
||||
if (request.status !== 'pending') throw consentError(`Consent has already been ${request.status}.`);
|
||||
if (new Date(request.expiresAt) < new Date()) throw consentError('This event has ended.');
|
||||
const token = newToken();
|
||||
await prisma.consentRequest.update({ where: { id: request.id }, data: { tokenHash: hashToken(token), lastSentAt: new Date() } });
|
||||
return sendConsentMessage(request, token, { reminder: true });
|
||||
}
|
||||
|
||||
async function findRequestByToken(token) {
|
||||
if (!token || typeof token !== 'string' || token.length > 100) return null;
|
||||
return prisma.consentRequest.findUnique({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
include: { event: true, attendee: { include: { registration: { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } } } } } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Consent state for each ticket id — for the scan gate and staff screens.
|
||||
* @returns {Promise<Map<string, string>>} ticketId → consent status
|
||||
*/
|
||||
async function consentStatusForTickets(ticketIds) {
|
||||
const tickets = await prisma.ticket.findMany({ where: { id: { in: ticketIds } }, include: { event: true, attendee: true } });
|
||||
const latest = await latestRequestsFor(tickets.map(t => t.attendeeId).filter(Boolean));
|
||||
const out = new Map();
|
||||
for (const t of tickets) {
|
||||
out.set(t.id, consentStatusFor(t.attendee, t.event, t.attendeeId ? latest.get(t.attendeeId) : null));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Daily job: reminders for unanswered requests. */
|
||||
async function sendConsentReminders(now = new Date()) {
|
||||
const pending = await prisma.consentRequest.findMany({
|
||||
where: { status: 'pending', expiresAt: { gt: now } },
|
||||
include: { event: true, attendee: true },
|
||||
});
|
||||
// Only the latest request per attendee is still live.
|
||||
const latest = await latestRequestsFor([...new Set(pending.map(r => r.attendeeId))]);
|
||||
let sent = 0;
|
||||
for (const r of pending) {
|
||||
if (latest.get(r.attendeeId)?.id !== r.id || !reminderDue(r, now)) continue;
|
||||
if (new Date(r.event.startDate) <= now) continue; // the event has started
|
||||
const token = newToken();
|
||||
await prisma.consentRequest.update({ where: { id: r.id }, data: { tokenHash: hashToken(token), lastSentAt: now, remindersSent: { increment: 1 } } });
|
||||
await sendConsentMessage(r, token, { reminder: true }).catch(e => console.warn('[consent] reminder failed:', e?.message || e));
|
||||
sent += 1;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** Daily job: delete medical/emergency details a set number of days after the event. */
|
||||
async function purgeConsentMedicalData(now = new Date()) {
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
const days = Math.max(0, parseInt(getSettingSync('consent_medical_retention_days', String(DEFAULT_RETENTION_DAYS)), 10) || DEFAULT_RETENTION_DAYS);
|
||||
const cutoff = new Date(new Date(now).getTime() - days * DAY);
|
||||
const res = await prisma.consentRequest.updateMany({
|
||||
where: { medicalPurgedAt: null, event: { endDate: { lt: cutoff } }, OR: [{ medical: { not: null } }, { emergency: { not: null } }] },
|
||||
data: { medical: null, emergency: null, medicalPurgedAt: now },
|
||||
});
|
||||
return res.count;
|
||||
}
|
||||
|
||||
/** Clears parent, medical and emergency details for a person's registrations (anonymise). */
|
||||
async function clearConsentDataForUser(userId, db = prisma) {
|
||||
const attendees = await db.attendee.findMany({ where: { OR: [{ userId }, { registration: { userId } }] }, select: { id: true } });
|
||||
const ids = attendees.map(a => a.id);
|
||||
if (!ids.length) return 0;
|
||||
await db.attendee.updateMany({ where: { id: { in: ids } }, data: { parentName: null, parentEmail: null, parentPhone: null, dateOfBirth: null, email: null, phone: null } });
|
||||
const res = await db.consentRequest.updateMany({
|
||||
where: { attendeeId: { in: ids } },
|
||||
data: { medical: null, emergency: null, parentName: null, parentEmail: null, parentPhone: null, ip: null, device: null, medicalPurgedAt: new Date() },
|
||||
});
|
||||
return res.count;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
consentError,
|
||||
ageOn,
|
||||
isMinor,
|
||||
consentStatusFor,
|
||||
reminderDue,
|
||||
validateConsentAnswer,
|
||||
hashToken,
|
||||
consentLink,
|
||||
sealJson,
|
||||
openJson,
|
||||
latestRequestsFor,
|
||||
createConsentRequest,
|
||||
ensureConsentRequests,
|
||||
resendConsentRequest,
|
||||
findRequestByToken,
|
||||
consentStatusForTickets,
|
||||
sendConsentReminders,
|
||||
purgeConsentMedicalData,
|
||||
clearConsentDataForUser,
|
||||
};
|
||||
@@ -1503,6 +1503,7 @@ async function sendAttendeeFormLinkMessage(attendee) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildEventNotifyRecipientList,
|
||||
sendAttendeeFormLinkMessage,
|
||||
sendRegistrationEmails,
|
||||
sendRegistrationUpdatedEmails,
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('validateAttendees', () => {
|
||||
{ eventOptionId: 'kid', name: ' Sam ', email: ' ', phone: '0821234567' },
|
||||
{ eventOptionId: 'kid', name: 'Alex', email: 'alex@example.com' },
|
||||
]);
|
||||
expect(out).toEqual([
|
||||
expect(out).toMatchObject([
|
||||
{ eventOptionId: 'kid', variantId: null, name: 'Sam', email: null, phone: '0821234567' },
|
||||
{ eventOptionId: 'kid', variantId: null, name: 'Alex', email: 'alex@example.com', phone: null },
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
|
||||
const { ageOn, isMinor, consentStatusFor, reminderDue, validateConsentAnswer, hashToken } = require('../src/utils/consent');
|
||||
const { validateAttendees, lineKey } = require('../src/utils/attendees');
|
||||
|
||||
const START = new Date('2026-12-10T08:00:00Z');
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
describe('ageOn / isMinor', () => {
|
||||
test('counts whole years, turning a year older on the birthday', () => {
|
||||
expect(ageOn('2008-12-10', START)).toBe(18);
|
||||
expect(ageOn('2008-12-11', START)).toBe(17);
|
||||
expect(ageOn('2010-01-01', START)).toBe(16);
|
||||
});
|
||||
|
||||
test('under 18 on the event start date', () => {
|
||||
expect(isMinor('2008-12-11', START)).toBe(true);
|
||||
expect(isMinor('2008-12-10', START)).toBe(false);
|
||||
expect(isMinor(null, START)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consentStatusFor', () => {
|
||||
const event = { requiresParentalConsent: true, startDate: START };
|
||||
const kid = { dateOfBirth: '2012-05-01' };
|
||||
|
||||
test('only needed on youth events, for minors', () => {
|
||||
expect(consentStatusFor(kid, { ...event, requiresParentalConsent: false }, null)).toBe('not_required');
|
||||
expect(consentStatusFor({ dateOfBirth: '1990-01-01' }, event, null)).toBe('not_required');
|
||||
});
|
||||
|
||||
test('a minor with no request, or an unnamed ticket, is missing consent', () => {
|
||||
expect(consentStatusFor(kid, event, null)).toBe('missing');
|
||||
expect(consentStatusFor(null, event, null)).toBe('missing');
|
||||
});
|
||||
|
||||
test('otherwise the latest request decides', () => {
|
||||
expect(consentStatusFor(kid, event, { status: 'pending' })).toBe('pending');
|
||||
expect(consentStatusFor(kid, event, { status: 'given' })).toBe('given');
|
||||
expect(consentStatusFor(kid, event, { status: 'declined' })).toBe('declined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reminderDue', () => {
|
||||
const now = new Date('2026-12-01T10:00:00Z');
|
||||
const req = (over) => ({ status: 'pending', remindersSent: 0, lastSentAt: new Date(now.getTime() - 1 * DAY), expiresAt: START, ...over });
|
||||
|
||||
test('first reminder 2 days after the request', () => {
|
||||
expect(reminderDue(req({}), now)).toBe(false);
|
||||
expect(reminderDue(req({ lastSentAt: new Date(now.getTime() - 2 * DAY) }), now)).toBe(true);
|
||||
});
|
||||
|
||||
test('then every 3 days', () => {
|
||||
expect(reminderDue(req({ remindersSent: 1, lastSentAt: new Date(now.getTime() - 2 * DAY) }), now)).toBe(false);
|
||||
expect(reminderDue(req({ remindersSent: 1, lastSentAt: new Date(now.getTime() - 3 * DAY) }), now)).toBe(true);
|
||||
});
|
||||
|
||||
test('never once answered or expired', () => {
|
||||
expect(reminderDue(req({ status: 'given', lastSentAt: new Date(0) }), now)).toBe(false);
|
||||
expect(reminderDue(req({ lastSentAt: new Date(0), expiresAt: new Date(now.getTime() - 1) }), now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateConsentAnswer', () => {
|
||||
const event = { consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true };
|
||||
const ok = { decision: 'give', signedName: 'Pat Parent', isGuardian: true, medical: { allergies: 'Peanuts' }, emergency: { name: 'Gran', phone: '0821234567' }, photoConsent: false };
|
||||
|
||||
test('accepts a complete answer and keeps only known fields', () => {
|
||||
const out = validateConsentAnswer({ ...ok, extra: 'x' }, event);
|
||||
expect(out).toEqual({
|
||||
decision: 'give', signedName: 'Pat Parent',
|
||||
medical: { allergies: 'Peanuts', medication: '', conditions: '', medicalAid: '' },
|
||||
emergency: { name: 'Gran', phone: '0821234567', relationship: '' },
|
||||
photoConsent: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('needs a decision, a typed name and the guardian tick', () => {
|
||||
expect(() => validateConsentAnswer({ ...ok, decision: 'maybe' }, event)).toThrow(/give or decline/);
|
||||
expect(() => validateConsentAnswer({ ...ok, signedName: ' ' }, event)).toThrow(/full name/);
|
||||
expect(() => validateConsentAnswer({ ...ok, isGuardian: false }, event)).toThrow(/parent or legal guardian/);
|
||||
});
|
||||
|
||||
test('needs an emergency contact and a photo answer when asked for', () => {
|
||||
expect(() => validateConsentAnswer({ ...ok, emergency: {} }, event)).toThrow(/emergency contact/);
|
||||
expect(() => validateConsentAnswer({ ...ok, photoConsent: undefined }, event)).toThrow(/photo/);
|
||||
});
|
||||
|
||||
test('declining needs no extra details', () => {
|
||||
expect(validateConsentAnswer({ decision: 'decline', signedName: 'Pat', isGuardian: true }, event))
|
||||
.toEqual({ decision: 'decline', signedName: 'Pat', medical: null, emergency: null, photoConsent: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashToken', () => {
|
||||
test('is stable and never the token itself', () => {
|
||||
expect(hashToken('abc')).toBe(hashToken('abc'));
|
||||
expect(hashToken('abc')).not.toContain('abc');
|
||||
expect(hashToken('abc')).not.toBe(hashToken('abd'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAttendees on a youth event', () => {
|
||||
const event = {
|
||||
requiresParentalConsent: true, allowNamedAttendees: true, startDate: START,
|
||||
eventOptions: [{ id: 'kid', name: 'Camper', isMainTicket: true }, { id: 'shirt', name: 'T-shirt', isMainTicket: false }],
|
||||
};
|
||||
const places = { [lineKey('kid', null)]: 2, [lineKey('shirt', null)]: 1 };
|
||||
const parent = { parentName: 'Pat', parentEmail: 'pat@example.com', parentPhone: '0821234567' };
|
||||
|
||||
test('every main place must be named', () => {
|
||||
expect(() => validateAttendees(event, places, [{ eventOptionId: 'kid', name: 'A', dateOfBirth: '1990-01-01' }])).toThrow(/every "Camper" ticket/);
|
||||
});
|
||||
|
||||
test('a date of birth is required', () => {
|
||||
expect(() => validateAttendees(event, places, [{ eventOptionId: 'kid', name: 'A' }, { eventOptionId: 'kid', name: 'B' }])).toThrow(/date of birth/);
|
||||
});
|
||||
|
||||
test('minors need full parent details; adults do not', () => {
|
||||
expect(() => validateAttendees(event, places, [
|
||||
{ eventOptionId: 'kid', name: 'Kid', dateOfBirth: '2014-03-03', parentName: 'Pat' },
|
||||
{ eventOptionId: 'kid', name: 'Leader', dateOfBirth: '1990-01-01' },
|
||||
])).toThrow(/under 18/);
|
||||
const out = validateAttendees(event, places, [
|
||||
{ eventOptionId: 'kid', name: 'Kid', dateOfBirth: '2014-03-03', ...parent },
|
||||
{ eventOptionId: 'kid', name: 'Leader', dateOfBirth: '1990-01-01' },
|
||||
]);
|
||||
expect(out[0]).toMatchObject({ name: 'Kid', parentEmail: 'pat@example.com' });
|
||||
expect(out[0].dateOfBirth).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test('a date of birth in the future is refused', () => {
|
||||
expect(() => validateAttendees(event, { [lineKey('kid', null)]: 1 }, [{ eventOptionId: 'kid', name: 'A', dateOfBirth: '2999-01-01' }])).toThrow(/valid date of birth/);
|
||||
});
|
||||
});
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { ShieldCheck, CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
type ConsentInfo = {
|
||||
status: "pending" | "given" | "declined";
|
||||
superseded: boolean;
|
||||
expired: boolean;
|
||||
respondedAt: string | null;
|
||||
attendeeName: string;
|
||||
parentName: string | null;
|
||||
consentText: string;
|
||||
event: { title: string; startDate: string; endDate: string; location: string | null };
|
||||
collect: { medical: boolean; emergency: boolean; photo: boolean };
|
||||
};
|
||||
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
|
||||
/** Public page a parent/guardian opens from their consent email or WhatsApp. */
|
||||
export default function ConsentPage() {
|
||||
const params = useParams<{ token: string }>();
|
||||
const token = params?.token;
|
||||
const [info, setInfo] = useState<ConsentInfo | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState<"given" | "declined" | null>(null);
|
||||
|
||||
const [signedName, setSignedName] = useState("");
|
||||
const [isGuardian, setIsGuardian] = useState(false);
|
||||
const [medical, setMedical] = useState({ allergies: "", medication: "", conditions: "", medicalAid: "" });
|
||||
const [emergency, setEmergency] = useState({ name: "", phone: "", relationship: "" });
|
||||
const [photoConsent, setPhotoConsent] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<ConsentInfo>(`/api/consent/${encodeURIComponent(token)}`)
|
||||
.then(res => { setInfo(res); setSignedName(res.parentName || ""); })
|
||||
.catch(e => setLoadError(e instanceof ApiError ? e.message : "Couldn't load this consent request."));
|
||||
}, [token]);
|
||||
|
||||
const submit = async (decision: "give" | "decline") => {
|
||||
if (!token || !info) return;
|
||||
setError(null);
|
||||
if (decision === "give" && info.collect.photo && photoConsent === null) { setError("Please answer the photo consent question."); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiFetch(`/api/consent/${encodeURIComponent(token)}`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
decision, signedName, isGuardian,
|
||||
...(decision === "give" && info.collect.medical ? { medical } : {}),
|
||||
...(decision === "give" && info.collect.emergency ? { emergency } : {}),
|
||||
...(decision === "give" && info.collect.photo ? { photoConsent } : {}),
|
||||
},
|
||||
});
|
||||
setDone(decision === "give" ? "given" : "declined");
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : "Couldn't save your answer. Please try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const open = info && info.status === "pending" && !info.superseded && !info.expired && !done;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1 px-4 py-10 max-w-2xl mx-auto w-full">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<ShieldCheck className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Parental consent</h1>
|
||||
</div>
|
||||
|
||||
{loadError && <div className="border rounded-xl p-4 bg-red-50 text-red-800 text-sm">{loadError}</div>}
|
||||
{!info && !loadError && <div className="text-sm text-gray-600">Loading…</div>}
|
||||
|
||||
{info && (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Event</div>
|
||||
<div className="font-semibold text-gray-900">{info.event.title}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{formatDate(new Date(info.event.startDate))}{info.event.endDate ? ` – ${formatDate(new Date(info.event.endDate))}` : ""}
|
||||
{info.event.location ? ` · ${info.event.location}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Attendee</div>
|
||||
<div className="font-semibold text-gray-900">{info.attendeeName}</div>
|
||||
</div>
|
||||
|
||||
{(done || info.status !== "pending") && (
|
||||
<div className={`flex items-start gap-2 rounded-lg p-3 text-sm ${(done || info.status) === "given" ? "bg-green-50 text-green-800" : "bg-amber-50 text-amber-900"}`}>
|
||||
{(done || info.status) === "given" ? <CheckCircle2 className="w-5 h-5 shrink-0" /> : <XCircle className="w-5 h-5 shrink-0" />}
|
||||
<span>
|
||||
{(done || info.status) === "given" ? "Consent has been given. Thank you!" : "Consent has been declined. The organisers have been told."}
|
||||
{!done && info.respondedAt && ` (${formatDate(new Date(info.respondedAt))})`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!done && info.status === "pending" && info.superseded && (
|
||||
<div className="rounded-lg p-3 text-sm bg-amber-50 text-amber-900">A newer consent request has replaced this one. Please use the link in your latest message.</div>
|
||||
)}
|
||||
{!done && info.status === "pending" && info.expired && (
|
||||
<div className="rounded-lg p-3 text-sm bg-gray-100 text-gray-700">This consent link has expired because the event has ended.</div>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-800 mb-1">Please read</div>
|
||||
<div className="text-sm text-gray-700 whitespace-pre-line border rounded-lg p-3 bg-gray-50 max-h-72 overflow-y-auto">{info.consentText || "The organisers haven't added any consent text."}</div>
|
||||
</div>
|
||||
|
||||
{info.collect.medical && (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium text-gray-800">Medical information <span className="font-normal text-gray-500">(kept confidential and deleted after the event)</span></legend>
|
||||
<textarea className={inputCls} rows={2} placeholder="Allergies" value={medical.allergies} onChange={e => setMedical({ ...medical, allergies: e.target.value })} aria-label="Allergies" />
|
||||
<textarea className={inputCls} rows={2} placeholder="Medication" value={medical.medication} onChange={e => setMedical({ ...medical, medication: e.target.value })} aria-label="Medication" />
|
||||
<textarea className={inputCls} rows={2} placeholder="Medical conditions" value={medical.conditions} onChange={e => setMedical({ ...medical, conditions: e.target.value })} aria-label="Medical conditions" />
|
||||
<input className={inputCls} placeholder="Medical aid (scheme and number)" value={medical.medicalAid} onChange={e => setMedical({ ...medical, medicalAid: e.target.value })} aria-label="Medical aid" />
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{info.collect.emergency && (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium text-gray-800">Emergency contact</legend>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input className={inputCls} placeholder="Name" value={emergency.name} onChange={e => setEmergency({ ...emergency, name: e.target.value })} aria-label="Emergency contact name" />
|
||||
<input type="tel" className={inputCls} placeholder="Phone" value={emergency.phone} onChange={e => setEmergency({ ...emergency, phone: e.target.value })} aria-label="Emergency contact phone" />
|
||||
<input className={inputCls} placeholder="Relationship" value={emergency.relationship} onChange={e => setEmergency({ ...emergency, relationship: e.target.value })} aria-label="Relationship" />
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{info.collect.photo && (
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium text-gray-800 mb-1">May {info.attendeeName} appear in event photos and videos?</legend>
|
||||
<label className="inline-flex items-center gap-2 text-sm mr-4"><input type="radio" name="photo" checked={photoConsent === true} onChange={() => setPhotoConsent(true)} /> Yes</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm"><input type="radio" name="photo" checked={photoConsent === false} onChange={() => setPhotoConsent(false)} /> No</label>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<label className="block text-sm font-medium text-gray-800">Type your full name to sign</label>
|
||||
<input className={inputCls} value={signedName} onChange={e => setSignedName(e.target.value)} aria-label="Your full name" />
|
||||
<label className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" className="mt-0.5" checked={isGuardian} onChange={e => setIsGuardian(e.target.checked)} />
|
||||
<span>I am the parent or legal guardian of {info.attendeeName}.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button onClick={() => submit("give")} disabled={busy || !isGuardian || signedName.trim().length < 2} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{busy ? "Saving…" : "Give consent"}
|
||||
</button>
|
||||
<button onClick={() => submit("decline")} disabled={busy || !isGuardian || signedName.trim().length < 2} className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50 disabled:opacity-50">
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">We record your typed name, the time, and the device you used, together with the text above.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,9 @@ const ACTIONS = [
|
||||
"coupon_updated",
|
||||
"coupon_disabled",
|
||||
"coupon_applied_manual",
|
||||
"consent_override",
|
||||
"consent_rerequested",
|
||||
"medical_list_viewed",
|
||||
] as const;
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
@@ -38,6 +41,9 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
coupon_updated: "Coupon edited",
|
||||
coupon_disabled: "Coupon turned off",
|
||||
coupon_applied_manual: "Coupon applied/removed by staff",
|
||||
consent_override: "Consent overridden at check-in",
|
||||
consent_rerequested: "Consent re-requested",
|
||||
medical_list_viewed: "Medical list viewed",
|
||||
};
|
||||
|
||||
interface AuditLogEntry {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -27,6 +27,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
||||
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
|
||||
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
|
||||
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
|
||||
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
|
||||
|
||||
@@ -136,6 +136,7 @@ function SiteSettingsPageInner() {
|
||||
const [legalIoEmail, setLegalIoEmail] = useState("");
|
||||
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
|
||||
const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
|
||||
const [consentRetentionDays, setConsentRetentionDays] = useState("30");
|
||||
|
||||
// ── Load all settings once ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -171,6 +172,7 @@ function SiteSettingsPageInner() {
|
||||
setLegalIoEmail(s.legal_io_email || "");
|
||||
setLegalWebsiteUrl(s.legal_website_url || "");
|
||||
setLegalEffectiveDate(s.legal_effective_date || "");
|
||||
setConsentRetentionDays(s.consent_medical_retention_days || "30");
|
||||
})
|
||||
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
|
||||
.finally(() => setLoadingInitial(false));
|
||||
@@ -319,6 +321,7 @@ function SiteSettingsPageInner() {
|
||||
legal_io_email: legalIoEmail.trim(),
|
||||
legal_website_url: legalWebsiteUrl.trim(),
|
||||
legal_effective_date: legalEffectiveDate.trim(),
|
||||
consent_medical_retention_days: String(Math.max(0, parseInt(consentRetentionDays, 10) || 0)),
|
||||
});
|
||||
|
||||
const handleTestSmtp = async () => {
|
||||
@@ -658,6 +661,15 @@ function SiteSettingsPageInner() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm font-medium text-gray-700 mb-3">Youth events</p>
|
||||
<Field label="Delete medical & emergency details this many days after an event">
|
||||
<input type="number" min="0" className={`${inputCls} sm:w-32`}
|
||||
value={consentRetentionDays} onChange={e => setConsentRetentionDays(e.target.value)} />
|
||||
</Field>
|
||||
<p className="text-xs text-gray-500 mt-1">Collected on parental consent forms. The consent record itself (who agreed, and when) is kept.</p>
|
||||
</div>
|
||||
|
||||
<SaveBar saving={saving} onSave={saveLegal} result={result} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -36,6 +36,9 @@ export default function TicketScanningPage() {
|
||||
remaining: number;
|
||||
} | null>(null);
|
||||
const [confirmQty, setConfirmQty] = useState(1);
|
||||
// Youth events: the preview says whether parental consent allows check-in.
|
||||
const consentBlocked = !!confirmModal && !['not_required', 'given', undefined].includes(confirmModal.ticket?.consentStatus);
|
||||
const canOverrideConsent = user?.role === 'admin' || user?.role === 'supervisor';
|
||||
const [confirmQtyRaw, setConfirmQtyRaw] = useState("1");
|
||||
|
||||
// Event selection state
|
||||
@@ -165,7 +168,7 @@ export default function TicketScanningPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function commitScan() {
|
||||
async function commitScan(overrideConsent = false) {
|
||||
if (!confirmModal || !token) return;
|
||||
|
||||
// Capture everything before clearing modal state
|
||||
@@ -199,7 +202,7 @@ export default function TicketScanningPage() {
|
||||
await apiFetch<any>(url, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { qty },
|
||||
body: { qty, ...(overrideConsent ? { overrideConsent: true } : {}) },
|
||||
});
|
||||
refreshStatsAndRecent();
|
||||
} catch (e: any) {
|
||||
@@ -383,6 +386,17 @@ export default function TicketScanningPage() {
|
||||
<div><span className="font-medium">Remaining:</span> <span className={confirmModal.remaining < (confirmModal.ticket?.quantity || 1) ? 'text-amber-600 font-semibold' : 'text-green-700 font-semibold'}>{confirmModal.remaining}</span></div>
|
||||
</div>
|
||||
|
||||
{consentBlocked && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||
<div className="font-semibold">
|
||||
{confirmModal.ticket?.consentStatus === 'declined' ? 'Parental consent was declined'
|
||||
: confirmModal.ticket?.consentStatus === 'pending' ? 'Parental consent is still pending'
|
||||
: 'Parental consent is missing'}
|
||||
</div>
|
||||
<div className="mt-1">This person can't be checked in until a parent or guardian gives consent.{canOverrideConsent ? ' As a supervisor you can override this — it is recorded in the audit log.' : ' Ask a supervisor.'}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(confirmModal.ticket?.quantity || 1) > 1 && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Qty to redeem (max {confirmModal.remaining})</label>
|
||||
@@ -411,12 +425,23 @@ export default function TicketScanningPage() {
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={commitScan}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold hover:bg-brand-700"
|
||||
>
|
||||
Confirm Scan
|
||||
</button>
|
||||
{consentBlocked ? (
|
||||
canOverrideConsent && (
|
||||
<button
|
||||
onClick={() => commitScan(true)}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700"
|
||||
>
|
||||
Override and check in
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
onClick={() => commitScan()}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold hover:bg-brand-700"
|
||||
>
|
||||
Confirm Scan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { apiFetch } from "@/lib/api";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
|
||||
|
||||
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
|
||||
@@ -104,6 +104,7 @@ export default function AtTheDoorPage() {
|
||||
const [pendingUser, setPendingUser] = useState<any | null>(null);
|
||||
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
|
||||
const [doorAllowsNames, setDoorAllowsNames] = useState(false);
|
||||
const [doorYouthStart, setDoorYouthStart] = useState<string | null>(null);
|
||||
const [doorNaming, setDoorNaming] = useState(false);
|
||||
const [doorEntries, setDoorEntries] = useState<AttendeeEntries>({});
|
||||
const [doorDelivery, setDoorDelivery] = useState<TicketDelivery>("payer");
|
||||
@@ -160,7 +161,8 @@ export default function AtTheDoorPage() {
|
||||
});
|
||||
|
||||
setEventOptions(ev.options || ev.eventOptions || []);
|
||||
setDoorAllowsNames(!!ev.allowNamedAttendees);
|
||||
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
|
||||
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
|
||||
} catch {
|
||||
setEventOptions([]);
|
||||
}
|
||||
@@ -230,6 +232,11 @@ export default function AtTheDoorPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!pendingEditReg && doorYouthStart) {
|
||||
const missing = consentDetailsMissing(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries, doorYouthStart);
|
||||
if (missing) { setError(missing); return; }
|
||||
}
|
||||
|
||||
setConfirming(true);
|
||||
try {
|
||||
if (pendingEditReg) {
|
||||
@@ -272,7 +279,7 @@ export default function AtTheDoorPage() {
|
||||
options: opts,
|
||||
notificationPreference: pendingUser.notifPref,
|
||||
...(doorCoupon ? { couponCode: doorCoupon.code } : {}),
|
||||
...(doorAllowsNames && doorNaming && attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries).length > 0
|
||||
...(doorAllowsNames && (doorNaming || doorYouthStart) && attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries).length > 0
|
||||
? { attendees: attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries), ticketDelivery: doorDelivery }
|
||||
: {}),
|
||||
}
|
||||
@@ -505,6 +512,7 @@ export default function AtTheDoorPage() {
|
||||
enabled: doorNaming, onEnabledChange: setDoorNaming,
|
||||
entries: doorEntries, onEntriesChange: setDoorEntries,
|
||||
delivery: doorDelivery, onDeliveryChange: setDoorDelivery,
|
||||
consentEventStart: doorYouthStart || undefined,
|
||||
} : null}
|
||||
/>
|
||||
<DonationModal
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ShieldCheck, Send, RefreshCw, HeartPulse, Printer } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
|
||||
type ConsentRow = {
|
||||
attendeeId: string;
|
||||
name: string;
|
||||
dateOfBirth: string | null;
|
||||
minor: boolean;
|
||||
parentName: string | null;
|
||||
parentEmail: string | null;
|
||||
parentPhone: string | null;
|
||||
registrationStatus: string;
|
||||
bookedBy: { name: string; email: string };
|
||||
status: "not_required" | "missing" | "pending" | "given" | "declined";
|
||||
request: { id: string; status: string; textVersion: number; signedName: string | null; respondedAt: string | null; lastSentAt: string; remindersSent: number; photoConsent: boolean | null; outdated: boolean } | null;
|
||||
};
|
||||
type MedicalRow = {
|
||||
name: string; dateOfBirth: string | null; parentName: string | null; parentPhone: string | null; parentEmail: string | null;
|
||||
medical: { allergies: string; medication: string; conditions: string; medicalAid: string } | null;
|
||||
emergency: { name: string; phone: string; relationship: string } | null;
|
||||
photoConsent: boolean | null; purged: boolean;
|
||||
};
|
||||
|
||||
const STATUS_STYLE: Record<ConsentRow["status"], { label: string; cls: string }> = {
|
||||
not_required: { label: "Not needed (18+)", cls: "bg-gray-100 text-gray-600" },
|
||||
missing: { label: "No request", cls: "bg-red-100 text-red-800" },
|
||||
pending: { label: "Pending", cls: "bg-amber-100 text-amber-800" },
|
||||
given: { label: "Given", cls: "bg-green-100 text-green-800" },
|
||||
declined: { label: "Declined", cls: "bg-red-100 text-red-800" },
|
||||
};
|
||||
|
||||
const esc = (s: unknown) => String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c] as string));
|
||||
|
||||
export default function ConsentTrackerPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const canView = useMemo(() => ["admin", "supervisor", "staff"].includes(user?.role || ""), [user]);
|
||||
const isSupervisor = user?.role === "admin" || user?.role === "supervisor";
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [eventId, setEventId] = useState<string>(search.get("eventId") || "");
|
||||
const [data, setData] = useState<{ event: any; counts: Record<string, number>; rows: ConsentRow[] } | null>(null);
|
||||
const [filter, setFilter] = useState<"minors" | "all" | "waiting">("minors");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(evs => {
|
||||
const youth = (evs || []).filter(e => e.requiresParentalConsent);
|
||||
setEvents(youth);
|
||||
if (!eventId && youth.length) setEventId(youth[youth.length - 1].id);
|
||||
})
|
||||
.catch(e => setError(e?.message || "Failed to load events"));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token, canView]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
setData(await apiFetch(`/api/consent/events/${encodeURIComponent(eventId)}`, { authToken: token }));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load consent status");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [token, eventId, setError]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const all = data?.rows || [];
|
||||
if (filter === "all") return all;
|
||||
if (filter === "waiting") return all.filter(r => ["missing", "pending", "declined"].includes(r.status) || r.request?.outdated);
|
||||
return all.filter(r => r.minor);
|
||||
}, [data, filter]);
|
||||
|
||||
const outdatedCount = (data?.rows || []).filter(r => r.request?.outdated || (r.minor && r.status === "missing")).length;
|
||||
|
||||
const act = async (fn: () => Promise<{ message: string }>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fn();
|
||||
setInfo(res.message);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resendOne = (r: ConsentRow) => act(() => apiFetch(`/api/consent/requests/${r.request!.id}/resend`, { method: "POST", authToken: token! }));
|
||||
const resendAll = () => act(() => apiFetch(`/api/consent/events/${eventId}/resend-pending`, { method: "POST", authToken: token! }));
|
||||
const rerequest = () => act(() => apiFetch(`/api/consent/events/${eventId}/rerequest`, { method: "POST", authToken: token! }));
|
||||
|
||||
// Printable list for camp leaders. Opening it is recorded in the audit log.
|
||||
const printMedical = async () => {
|
||||
try {
|
||||
const res = await apiFetch<{ event: any; rows: MedicalRow[] }>(`/api/consent/events/${eventId}/medical`, { authToken: token! });
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) { setError("Allow pop-ups to print the medical list."); return; }
|
||||
const rowsHtml = res.rows.map(r => `
|
||||
<tr>
|
||||
<td><strong>${esc(r.name)}</strong><br/><small>${r.dateOfBirth ? esc(formatDate(new Date(r.dateOfBirth))) : ""}</small></td>
|
||||
<td>${r.purged ? "<em>Deleted after the event</em>" : `
|
||||
${r.medical?.allergies ? `<div><b>Allergies:</b> ${esc(r.medical.allergies)}</div>` : ""}
|
||||
${r.medical?.medication ? `<div><b>Medication:</b> ${esc(r.medical.medication)}</div>` : ""}
|
||||
${r.medical?.conditions ? `<div><b>Conditions:</b> ${esc(r.medical.conditions)}</div>` : ""}
|
||||
${r.medical?.medicalAid ? `<div><b>Medical aid:</b> ${esc(r.medical.medicalAid)}</div>` : ""}`}</td>
|
||||
<td>${r.emergency ? `${esc(r.emergency.name)}${r.emergency.relationship ? ` (${esc(r.emergency.relationship)})` : ""}<br/>${esc(r.emergency.phone)}` : ""}</td>
|
||||
<td>${esc(r.parentName)}<br/>${esc(r.parentPhone)}</td>
|
||||
<td>${r.photoConsent === null ? "" : r.photoConsent ? "Yes" : "No"}</td>
|
||||
</tr>`).join("");
|
||||
w.document.write(`<!doctype html><html><head><meta charset="utf-8"/><title>Medical & emergency list — ${esc(res.event.title)}</title>
|
||||
<style>body{font-family:system-ui,sans-serif;margin:24px;font-size:12px}h1{font-size:18px;margin:0 0 4px}p{color:#555;margin:0 0 12px}
|
||||
table{width:100%;border-collapse:collapse}th,td{border:1px solid #ccc;padding:6px;vertical-align:top;text-align:left}th{background:#f3f4f6}
|
||||
.conf{color:#b91c1c;font-weight:600}</style></head><body>
|
||||
<h1>Medical & emergency list — ${esc(res.event.title)}</h1>
|
||||
<p class="conf">Confidential — for event leaders only. Do not leave unattended; destroy after the event.</p>
|
||||
<table><thead><tr><th>Attendee</th><th>Medical</th><th>Emergency contact</th><th>Parent / guardian</th><th>Photos</th></tr></thead>
|
||||
<tbody>${rowsHtml || '<tr><td colspan="5">No consent given yet.</td></tr>'}</tbody></table>
|
||||
<script>window.onload=()=>window.print()</script></body></html>`);
|
||||
w.document.close();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load the medical list");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<ShieldCheck className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Parental consent</h1>
|
||||
<p className="text-sm text-gray-500">Youth events: who still needs a parent's consent before check-in</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canView && <div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need staff access to view consent status.</div>}
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
|
||||
|
||||
{canView && (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Youth event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.length === 0 && <option value="">No youth events</option>}
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Show</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm" value={filter} onChange={e => setFilter(e.target.value as typeof filter)}>
|
||||
<option value="minors">Under 18s</option>
|
||||
<option value="waiting">Still to sort out</option>
|
||||
<option value="all">Everyone</option>
|
||||
</select>
|
||||
</div>
|
||||
{isSupervisor && eventId && (
|
||||
<div className="flex flex-wrap gap-2 ml-auto">
|
||||
<button onClick={resendAll} disabled={busy || !(data?.counts?.pending)} className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50"><Send className="w-4 h-4" /> Resend to all pending</button>
|
||||
<button onClick={rerequest} disabled={busy || outdatedCount === 0} title="For consent given on an older version of the text, or under 18s who never got a request" className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50"><RefreshCw className="w-4 h-4" /> Re-request consent ({outdatedCount})</button>
|
||||
<button onClick={printMedical} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-brand-600 text-white text-sm hover:bg-brand-700 disabled:opacity-50"><HeartPulse className="w-4 h-4" /> <Printer className="w-4 h-4" /> Medical & emergency list</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<div className="flex flex-wrap gap-2 mb-3 text-xs">
|
||||
{(["given", "pending", "declined", "missing"] as const).map(s => (
|
||||
<span key={s} className={`px-2 py-1 rounded-full font-medium ${STATUS_STYLE[s].cls}`}>{STATUS_STYLE[s].label}: {data.counts?.[s] || 0}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Attendee</TableHead>
|
||||
<TableHead>Parent / guardian</TableHead>
|
||||
<TableHead>Consent</TableHead>
|
||||
<TableHead>Booked by</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map(r => {
|
||||
const st = STATUS_STYLE[r.status];
|
||||
return (
|
||||
<TableRow key={r.attendeeId}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{r.name}</div>
|
||||
<div className="text-xs text-gray-500">{r.dateOfBirth ? formatDate(new Date(r.dateOfBirth)) : "No date of birth"}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{r.parentName ? <>
|
||||
<div>{r.parentName}</div>
|
||||
<div className="text-xs text-gray-500">{[r.parentEmail, r.parentPhone].filter(Boolean).join(" · ")}</div>
|
||||
</> : <span className="text-gray-400">—</span>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${st.cls}`}>{st.label}</span>
|
||||
{r.request?.outdated && <div className="text-xs text-amber-700 mt-1">Given on an older version of the text</div>}
|
||||
{r.request?.respondedAt && <div className="text-xs text-gray-500 mt-1">{r.request.signedName} · {formatDate(new Date(r.request.respondedAt))}</div>}
|
||||
{r.status === "pending" && r.request && <div className="text-xs text-gray-500 mt-1">Sent {formatDate(new Date(r.request.lastSentAt))}{r.request.remindersSent ? ` · ${r.request.remindersSent} reminder(s)` : ""}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{r.bookedBy?.name}<div className="text-xs text-gray-500">{r.registrationStatus}</div></TableCell>
|
||||
<TableCell className="text-right">
|
||||
{r.status === "pending" && r.request && (
|
||||
<button onClick={() => resendOne(r)} disabled={busy} className="px-2 py-1 text-xs rounded border hover:bg-gray-50">Resend</button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{!busy && rows.length === 0 && (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-sm text-gray-500 py-8">{eventId ? "No attendees to show." : "Pick a youth event."}</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">Reminders go out automatically 2 days after a request, then every 3 days until the event. Medical details are deleted a set number of days after the event (Site Settings → Legal).</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -770,6 +770,8 @@ interface EventDraft {
|
||||
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
|
||||
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
|
||||
location: string; allowNamedAttendees: boolean;
|
||||
requiresParentalConsent: boolean; consentText: string;
|
||||
consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean;
|
||||
}
|
||||
|
||||
const blankDraft = (location = ""): EventDraft => ({
|
||||
@@ -778,6 +780,8 @@ const blankDraft = (location = ""): EventDraft => ({
|
||||
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
|
||||
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
|
||||
location, allowNamedAttendees: false,
|
||||
requiresParentalConsent: false, consentText: "",
|
||||
consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true,
|
||||
});
|
||||
|
||||
const blankOptions = (): OptionDraft[] => [
|
||||
@@ -813,6 +817,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
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,
|
||||
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
|
||||
location: ev.location || "",
|
||||
} : blankDraft(settings.org_address || ""));
|
||||
@@ -1086,7 +1092,12 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
picture: draft.picture || undefined, isHidden: draft.isHidden, requiresAuth: draft.requiresAuth,
|
||||
redirectUrl: draft.redirectUrl?.trim().replace(/\s+/g, "-") || undefined,
|
||||
requiresRegistration: draft.requiresRegistration,
|
||||
allowNamedAttendees: draft.allowNamedAttendees,
|
||||
allowNamedAttendees: draft.allowNamedAttendees || draft.requiresParentalConsent,
|
||||
requiresParentalConsent: draft.requiresParentalConsent,
|
||||
consentText: draft.consentText,
|
||||
consentCollectMedical: draft.consentCollectMedical,
|
||||
consentCollectEmergency: draft.consentCollectEmergency,
|
||||
consentCollectPhoto: draft.consentCollectPhoto,
|
||||
contactName: draft.contactName || undefined,
|
||||
contactPhone: draft.contactPhone || undefined,
|
||||
contactEmail: draft.contactEmail || undefined,
|
||||
@@ -1352,6 +1363,28 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Youth event</div>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.requiresParentalConsent} onChange={e => upd({ requiresParentalConsent: e.target.checked, ...(e.target.checked ? { allowNamedAttendees: true } : {}) })} />
|
||||
<span><strong>Requires parental consent</strong> — every attendee gives a date of birth; for anyone under 18, a parent or guardian is asked by email and WhatsApp to give consent before they can be checked in</span>
|
||||
</label>
|
||||
{draft.requiresParentalConsent && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Consent text (shown to the parent)</label>
|
||||
<textarea className="w-full border rounded px-2 py-1.5 text-sm" rows={6} value={draft.consentText} onChange={e => upd({ consentText: e.target.value })}
|
||||
placeholder="e.g. I give permission for my child to take part in all activities, including transport, and for leaders to arrange medical treatment in an emergency." />
|
||||
{mode === "edit" && <p className="text-[11px] text-gray-500 mt-1">Changing this starts a new version. Parents who already agreed keep their record; use "Re-request consent" on the Parental consent page to ask them again.</p>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Also ask the parent for:</div>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={draft.consentCollectMedical} onChange={e => upd({ consentCollectMedical: e.target.checked })} /> Medical info (allergies, medication, conditions, medical aid)</label>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={draft.consentCollectEmergency} onChange={e => upd({ consentCollectEmergency: e.target.checked })} /> An emergency contact</label>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={draft.consentCollectPhoto} onChange={e => upd({ consentCollectPhoto: e.target.checked })} /> Photo consent (yes/no)</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { UserPlus, CreditCard } from "lucide-react";
|
||||
|
||||
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||
@@ -224,7 +224,9 @@ export default function ManualRegistrationPage() {
|
||||
const [attendeeEntries, setAttendeeEntries] = useState<AttendeeEntries>({});
|
||||
const [delivery, setDelivery] = useState<TicketDelivery>("payer");
|
||||
const manualQtyFor = (optionId: string, variantId: string | null) => quantities[variantId ? `${optionId}::${variantId}` : optionId] || 0;
|
||||
const selectedEventAllowsNames = !!events.find(ev => ev.id === selectedEventId)?.allowNamedAttendees;
|
||||
const selectedEv = events.find(ev => ev.id === selectedEventId);
|
||||
const selectedIsYouth = !!selectedEv?.requiresParentalConsent;
|
||||
const selectedEventAllowsNames = !!selectedEv?.allowNamedAttendees || selectedIsYouth;
|
||||
const [pickedUserId, setPickedUserId] = useState<string | null>(null);
|
||||
|
||||
const selectUser = (u: any) => {
|
||||
@@ -307,6 +309,10 @@ export default function ManualRegistrationPage() {
|
||||
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
|
||||
});
|
||||
if (opts.length === 0) { setError("Please select at least one ticket option."); return; }
|
||||
if (selectedIsYouth) {
|
||||
const missing = consentDetailsMissing(options, manualQtyFor, attendeeEntries, selectedEv.startDate);
|
||||
if (missing) { setError(missing); return; }
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
@@ -322,7 +328,7 @@ export default function ManualRegistrationPage() {
|
||||
user: guest,
|
||||
notificationPreference: resolvedPref,
|
||||
...(coupon ? { couponCode: coupon.code } : {}),
|
||||
...(selectedEventAllowsNames && namingOthers && attendeesPayload(options, manualQtyFor, attendeeEntries).length > 0
|
||||
...(selectedEventAllowsNames && (namingOthers || selectedIsYouth) && attendeesPayload(options, manualQtyFor, attendeeEntries).length > 0
|
||||
? { attendees: attendeesPayload(options, manualQtyFor, attendeeEntries), ticketDelivery: delivery }
|
||||
: {}),
|
||||
}
|
||||
@@ -617,6 +623,7 @@ export default function ManualRegistrationPage() {
|
||||
delivery={delivery}
|
||||
onDeliveryChange={setDelivery}
|
||||
payerLabel="the person registering"
|
||||
consentEventStart={selectedIsYouth ? selectedEv.startDate : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -24,6 +24,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
||||
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
|
||||
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
|
||||
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
|
||||
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
|
||||
// Tiered low-stock threshold — mirrors events/[id]/page.tsx
|
||||
function lowStockThreshold(stockLimit: number): number {
|
||||
@@ -42,6 +42,7 @@ type Event = {
|
||||
form?: { isRequired: boolean; fields?: any[] } | null;
|
||||
requiresAuth?: boolean;
|
||||
allowNamedAttendees?: boolean;
|
||||
requiresParentalConsent?: boolean;
|
||||
};
|
||||
|
||||
// Key format: "optionId" for no-variant options, "optionId:variantId" for variant options
|
||||
@@ -254,7 +255,8 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
const [attendeeEntries, setAttendeeEntries] = useState<AttendeeEntries>({});
|
||||
const [delivery, setDelivery] = useState<TicketDelivery>("payer");
|
||||
const qtyFor = (optionId: string, variantId: string | null) => quantities[variantId ? `${optionId}:${variantId}` : optionId] || 0;
|
||||
const namedAttendees = () => (event.allowNamedAttendees && namingOthers
|
||||
const isYouthEvent = !!event.requiresParentalConsent;
|
||||
const namedAttendees = () => ((event.allowNamedAttendees && namingOthers) || isYouthEvent
|
||||
? attendeesPayload(event.eventOptions || [], qtyFor, attendeeEntries)
|
||||
: []);
|
||||
|
||||
@@ -327,6 +329,10 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
setError("Select at least one ticket.");
|
||||
return;
|
||||
}
|
||||
if (isYouthEvent) {
|
||||
const missing = consentDetailsMissing(event.eventOptions || [], qtyFor, attendeeEntries, event.startDate);
|
||||
if (missing) { setError(missing); return; }
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
if (!token) {
|
||||
@@ -505,7 +511,7 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
);
|
||||
})()}
|
||||
|
||||
{event.allowNamedAttendees && !eventSoldOut && (isLoggedIn || showGuestForm) && (
|
||||
{(event.allowNamedAttendees || isYouthEvent) && !eventSoldOut && (isLoggedIn || showGuestForm) && (
|
||||
<div className="mt-6">
|
||||
<AttendeeNamesEditor
|
||||
options={event.eventOptions || []}
|
||||
@@ -516,6 +522,7 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
onEntriesChange={setAttendeeEntries}
|
||||
delivery={delivery}
|
||||
onDeliveryChange={setDelivery}
|
||||
consentEventStart={isYouthEvent ? event.startDate : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
||||
@@ -41,6 +41,7 @@ type KioskEvent = {
|
||||
eventOptions?: EventOption[];
|
||||
form?: EventForm | null;
|
||||
allowNamedAttendees?: boolean;
|
||||
requiresParentalConsent?: boolean;
|
||||
};
|
||||
|
||||
type Screen = "setup" | "form" | "eventform" | "confirmation";
|
||||
@@ -388,6 +389,10 @@ export default function SelfServicePage() {
|
||||
setFormError("The email and phone number entered belong to two different existing accounts. Please check and correct one of them before continuing.");
|
||||
return;
|
||||
}
|
||||
if (selectedEvent?.requiresParentalConsent) {
|
||||
const missing = consentDetailsMissing(selectedEvent.eventOptions || [], (o, v) => quantities[v ? `${o}::${v}` : o] || 0, attendeeEntries, selectedEvent.startDate);
|
||||
if (missing) { setFormError(missing); return; }
|
||||
}
|
||||
if (pendingChanges.length > 0) {
|
||||
setShowUpdateConfirm(true);
|
||||
return;
|
||||
@@ -417,7 +422,7 @@ export default function SelfServicePage() {
|
||||
notificationPreference: notificationPref,
|
||||
...(kioskCoupon ? { couponCode: kioskCoupon.code } : {}),
|
||||
};
|
||||
const kioskNames = selectedEvent?.allowNamedAttendees && namingOthers
|
||||
const kioskNames = (selectedEvent?.allowNamedAttendees && namingOthers) || selectedEvent?.requiresParentalConsent
|
||||
? attendeesPayload(selectedEvent.eventOptions || [], (o, v) => quantities[v ? `${o}::${v}` : o] || 0, attendeeEntries)
|
||||
: [];
|
||||
if (kioskNames.length > 0) {
|
||||
@@ -1008,7 +1013,7 @@ export default function SelfServicePage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedEvent?.allowNamedAttendees && (
|
||||
{(selectedEvent?.allowNamedAttendees || selectedEvent?.requiresParentalConsent) && (
|
||||
<div className="mt-4">
|
||||
<AttendeeNamesEditor
|
||||
options={selectedEvent.eventOptions || []}
|
||||
@@ -1019,6 +1024,7 @@ export default function SelfServicePage() {
|
||||
onEntriesChange={setAttendeeEntries}
|
||||
delivery={delivery}
|
||||
onDeliveryChange={setDelivery}
|
||||
consentEventStart={selectedEvent.requiresParentalConsent ? selectedEvent.startDate : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
BarChart3,
|
||||
ClipboardList,
|
||||
TicketPercent,
|
||||
ShieldCheck,
|
||||
HeartPulse,
|
||||
} from "lucide-react";
|
||||
|
||||
export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other";
|
||||
@@ -34,6 +36,8 @@ export const REPORTS = [
|
||||
{ key: "financeReport", label: "Finance report (revenue & costs)", description: "Revenue & costs overview", category: "Financial", icon: FileBarChart, fields: 6 },
|
||||
{ key: "profitReport", label: "Profit report", description: "View profit report", category: "Financial", icon: BarChart3, fields: 5 },
|
||||
{ key: "cashupAudit", label: "Cashup audit trail", description: "Audit trail of cashup actions", category: "Cashup", icon: ClipboardList, fields: 6 },
|
||||
{ key: "consentStatus", label: "Parental consent status", description: "Youth events: consent per attendee", category: "Registration", icon: ShieldCheck, fields: 5 },
|
||||
{ key: "medicalList", label: "Medical & emergency list", description: "Youth events: confidential list for leaders (audited)", category: "Registration", icon: HeartPulse, fields: 5 },
|
||||
{ key: "discounts", label: "Discounts (coupon codes)", description: "Uses and value given away per code and event", category: "Financial", icon: TicketPercent, fields: 4 },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
key: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
|
||||
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent } from "lucide-react";
|
||||
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck } from "lucide-react";
|
||||
import { REPORTS, type ReportKey } from "./ReportCatalog";
|
||||
import ReportsShell from "./ReportsShell";
|
||||
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
|
||||
@@ -174,6 +174,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
const [financialsByEvent, setFinancialsByEvent] = useState<Record<string, any>>({});
|
||||
const [auditRows, setAuditRows] = useState<any[]>([]);
|
||||
const [discountRows, setDiscountRows] = useState<any[]>([]);
|
||||
// Youth events (one row per attendee), each tagged with its event title.
|
||||
const [consentRows, setConsentRows] = useState<any[]>([]);
|
||||
const [medicalRows, setMedicalRows] = useState<any[]>([]);
|
||||
|
||||
// Deep-linking: dashboards link here with ?report=<key>&range=trailing_month to jump
|
||||
// straight into a specific report, pre-filtered to every currently-visible event and the
|
||||
@@ -296,6 +299,20 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const loadConsent = async (eventIds: string[], kind: "status" | "medical") => {
|
||||
if (!token) return [] as any[];
|
||||
const out: any[] = [];
|
||||
for (const id of eventIds) {
|
||||
const ev = filteredEvents.find((e: any) => e.id === id);
|
||||
if (!ev?.requiresParentalConsent) continue;
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/consent/events/${encodeURIComponent(id)}${kind === "medical" ? "/medical" : ""}`, { authToken: token });
|
||||
(res?.rows || []).forEach((r: any) => out.push({ ...r, eventTitle: ev.title }));
|
||||
} catch { /* not allowed or not found — skip */ }
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const loadCashupAudit = async (eventIds: string[]) => {
|
||||
if (!token) return [] as any[];
|
||||
const query = new URLSearchParams();
|
||||
@@ -390,6 +407,10 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
setAuditRows(rows);
|
||||
} else if (report === "discounts") {
|
||||
setDiscountRows(await loadDiscounts(selectedEventIds));
|
||||
} else if (report === "consentStatus") {
|
||||
setConsentRows(await loadConsent(selectedEventIds, "status"));
|
||||
} else if (report === "medicalList") {
|
||||
setMedicalRows(await loadConsent(selectedEventIds, "medical"));
|
||||
}
|
||||
setReady(true);
|
||||
} finally {
|
||||
@@ -963,6 +984,34 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
table: { columns: ["Event", "Donations (R)", "Used (R)", "Unused (R)", "Count"], rows }
|
||||
};
|
||||
}
|
||||
if (report === "consentStatus") {
|
||||
const minors = consentRows.filter(r => r.minor);
|
||||
const count = (s: string) => minors.filter(r => r.status === s).length;
|
||||
return {
|
||||
title: 'Parental consent status', subtitle, kind: 'table', orientation: 'landscape',
|
||||
stats: [
|
||||
{ label: 'Given', value: String(count('given')), tone: 'green' },
|
||||
{ label: 'Pending', value: String(count('pending')), tone: 'amber' },
|
||||
{ label: 'Declined / missing', value: String(count('declined') + count('missing')), tone: 'rose' },
|
||||
],
|
||||
note: 'Under-18 attendees on youth events. Pending, declined or missing consent blocks check-in.',
|
||||
table: {
|
||||
columns: ["Event", "Attendee", "Date of birth", "Parent / guardian", "Consent", "Answered"],
|
||||
rows: minors.map(r => [r.eventTitle, r.name, r.dateOfBirth ? new Date(r.dateOfBirth).toLocaleDateString() : '', [r.parentName, r.parentPhone].filter(Boolean).join(' · '), r.status + (r.request?.outdated ? ' (older text)' : ''), r.request?.respondedAt ? `${r.request.signedName || ''} ${new Date(r.request.respondedAt).toLocaleDateString()}`.trim() : '']),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (report === "medicalList") {
|
||||
const fmt = (m: any) => m ? [m.allergies && `Allergies: ${m.allergies}`, m.medication && `Medication: ${m.medication}`, m.conditions && `Conditions: ${m.conditions}`, m.medicalAid && `Medical aid: ${m.medicalAid}`].filter(Boolean).join('; ') : '';
|
||||
return {
|
||||
title: 'Medical & emergency list', subtitle, kind: 'table', orientation: 'landscape',
|
||||
note: 'CONFIDENTIAL — for event leaders only. Viewing this list is recorded in the audit log.',
|
||||
table: {
|
||||
columns: ["Event", "Attendee", "Medical", "Emergency contact", "Parent / guardian", "Photos"],
|
||||
rows: medicalRows.map(r => [r.eventTitle, r.name, r.purged ? 'Deleted after the event' : fmt(r.medical), r.emergency ? `${r.emergency.name} ${r.emergency.phone}${r.emergency.relationship ? ` (${r.emergency.relationship})` : ''}` : '', [r.parentName, r.parentPhone].filter(Boolean).join(' · '), r.photoConsent === null || r.photoConsent === undefined ? '' : r.photoConsent ? 'Yes' : 'No']),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (report === "discounts") {
|
||||
return {
|
||||
title: 'Discounts (coupon codes)', subtitle, kind: 'table', orientation: 'portrait',
|
||||
@@ -1168,6 +1217,8 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
profitReport: "Profit Report",
|
||||
cashupAudit: "Cashup Audit Trail",
|
||||
discounts: "Discounts Report",
|
||||
consentStatus: "Parental Consent Status",
|
||||
medicalList: "Medical & Emergency List",
|
||||
};
|
||||
|
||||
const doPrintPdf = async () => {
|
||||
@@ -1618,6 +1669,41 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(report === "consentStatus" || report === "medicalList") && (() => {
|
||||
const payload: any = buildReportPayload();
|
||||
const rows: any[] = payload?.table?.rows || [];
|
||||
const cols: string[] = payload?.table?.columns || [];
|
||||
return (
|
||||
<div>
|
||||
{report === "medicalList" && <div className="mb-3 text-sm text-red-700 font-medium">Confidential — for event leaders only. Viewing this list is recorded in the audit log.</div>}
|
||||
{payload?.stats && (
|
||||
<StatTileRow>
|
||||
{payload.stats.map((s: any) => <StatTile key={s.label} icon={ShieldCheck} label={s.label} value={s.value} tone={s.tone} />)}
|
||||
</StatTileRow>
|
||||
)}
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 mt-3">Nothing to show — pick youth events (ones that need parental consent) in the filters.</div>
|
||||
) : (
|
||||
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
|
||||
<table className="min-w-[760px] w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
|
||||
{cols.map(c => <th key={c} className="py-2 px-3">{c}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className="border-t border-gray-100 align-top">
|
||||
{r.map((cell: any, j: number) => <td key={j} className="py-2 px-3">{String(cell ?? '')}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{report === "discounts" && (
|
||||
<div>
|
||||
{discountSummary.rows.length === 0 ? (
|
||||
|
||||
@@ -10,11 +10,28 @@ export type AttendeeOption = {
|
||||
variants?: { id: string; name: string }[] | null;
|
||||
};
|
||||
|
||||
export type AttendeeEntry = { name: string; email: string; phone: string };
|
||||
export type AttendeeEntry = {
|
||||
name: string; email: string; phone: string;
|
||||
dateOfBirth?: string; parentName?: string; parentEmail?: string; parentPhone?: string;
|
||||
};
|
||||
export type AttendeeEntries = Record<string, AttendeeEntry>;
|
||||
export type TicketDelivery = "payer" | "each";
|
||||
|
||||
export type AttendeePayload = { eventOptionId: string; variantId?: string | null; name: string; email?: string; phone?: string };
|
||||
export type AttendeePayload = {
|
||||
eventOptionId: string; variantId?: string | null; name: string; email?: string; phone?: string;
|
||||
dateOfBirth?: string; parentName?: string; parentEmail?: string; parentPhone?: string;
|
||||
};
|
||||
|
||||
/** Under 18 on the event's start date (mirrors backend utils/consent.js isMinor). */
|
||||
export function isMinorOn(dateOfBirth: string | undefined, eventStart: string | undefined): boolean {
|
||||
if (!dateOfBirth || !eventStart) return false;
|
||||
const dob = new Date(dateOfBirth);
|
||||
const on = new Date(eventStart);
|
||||
if (isNaN(dob.getTime()) || isNaN(on.getTime())) return false;
|
||||
let age = on.getUTCFullYear() - dob.getUTCFullYear();
|
||||
if (on.getUTCMonth() < dob.getUTCMonth() || (on.getUTCMonth() === dob.getUTCMonth() && on.getUTCDate() < dob.getUTCDate())) age -= 1;
|
||||
return age < 18;
|
||||
}
|
||||
|
||||
type Slot = { key: string; eventOptionId: string; variantId: string | null; label: string };
|
||||
|
||||
@@ -49,9 +66,29 @@ export function attendeesPayload(options: AttendeeOption[], qtyFor: (optionId: s
|
||||
name: e.name.trim(),
|
||||
...(e.email.trim() ? { email: e.email.trim() } : {}),
|
||||
...(e.phone.trim() ? { phone: e.phone.trim() } : {}),
|
||||
...(e.dateOfBirth ? { dateOfBirth: e.dateOfBirth } : {}),
|
||||
...(e.parentName?.trim() ? { parentName: e.parentName.trim() } : {}),
|
||||
...(e.parentEmail?.trim() ? { parentEmail: e.parentEmail.trim() } : {}),
|
||||
...(e.parentPhone?.trim() ? { parentPhone: e.parentPhone.trim() } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Youth events: what's still missing before registering (every place named with a date of
|
||||
* birth; parent details for minors). Empty when everything's in.
|
||||
*/
|
||||
export function consentDetailsMissing(options: AttendeeOption[], qtyFor: (optionId: string, variantId: string | null) => number, entries: AttendeeEntries, eventStart: string): string | null {
|
||||
for (const s of slotsFor(options, qtyFor)) {
|
||||
const e = entries[s.key];
|
||||
if (!e?.name.trim()) return `Add a name for ${s.label}.`;
|
||||
if (!e.dateOfBirth) return `Add a date of birth for ${e.name.trim()}.`;
|
||||
if (isMinorOn(e.dateOfBirth, eventStart) && (!e.parentName?.trim() || !e.parentEmail?.trim() || !e.parentPhone?.trim())) {
|
||||
return `${e.name.trim()} is under 18 — add a parent or guardian's name, email and phone.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Registering for other people?" — a name (and optional email/phone) for each main
|
||||
* ticket place, plus who gets the tickets. Named places get their own ticket; any left
|
||||
@@ -67,6 +104,7 @@ export function AttendeeNamesEditor({
|
||||
delivery,
|
||||
onDeliveryChange,
|
||||
payerLabel = "me",
|
||||
consentEventStart,
|
||||
}: {
|
||||
options: AttendeeOption[];
|
||||
qtyFor: (optionId: string, variantId: string | null) => number;
|
||||
@@ -77,28 +115,40 @@ export function AttendeeNamesEditor({
|
||||
delivery: TicketDelivery;
|
||||
onDeliveryChange: (v: TicketDelivery) => void;
|
||||
payerLabel?: string;
|
||||
/** Set for youth events: names are required, with a date of birth, and parent details for minors. */
|
||||
consentEventStart?: string;
|
||||
}) {
|
||||
const consent = !!consentEventStart;
|
||||
const slots = slotsFor(options, qtyFor);
|
||||
const set = (key: string, field: keyof AttendeeEntry, value: string) => {
|
||||
const prev = entries[key] || { name: "", email: "", phone: "" };
|
||||
const prev: AttendeeEntry = entries[key] || { name: "", email: "", phone: "" };
|
||||
onEntriesChange({ ...entries, [key]: { ...prev, [field]: value } });
|
||||
};
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-800 cursor-pointer">
|
||||
<input type="checkbox" checked={enabled} onChange={e => onEnabledChange(e.target.checked)} />
|
||||
<Users className="w-4 h-4 text-brand-600" />
|
||||
Registering for other people?
|
||||
</label>
|
||||
{enabled && (
|
||||
{consent ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-800">
|
||||
<Users className="w-4 h-4 text-brand-600" /> Who's coming?
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">This is a youth event: add each person's name and date of birth. For anyone under 18, a parent or guardian will be asked to give consent by email and WhatsApp before they can be checked in.</p>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-800 cursor-pointer">
|
||||
<input type="checkbox" checked={enabled} onChange={e => onEnabledChange(e.target.checked)} />
|
||||
<Users className="w-4 h-4 text-brand-600" />
|
||||
Registering for other people?
|
||||
</label>
|
||||
)}
|
||||
{(enabled || consent) && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{slots.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Choose how many tickets first, then add a name for each person.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-gray-500">Each named person gets their own ticket and their own attendee form. Leave a name blank to keep that ticket with the others.</p>
|
||||
{!consent && <p className="text-xs text-gray-500">Each named person gets their own ticket and their own attendee form. Leave a name blank to keep that ticket with the others.</p>}
|
||||
{slots.map(s => {
|
||||
const e = entries[s.key] || { name: "", email: "", phone: "" };
|
||||
return (
|
||||
@@ -109,6 +159,22 @@ export function AttendeeNamesEditor({
|
||||
<input type="email" className={inputCls} placeholder="Email (optional)" value={e.email} onChange={ev => set(s.key, "email", ev.target.value)} aria-label={`${s.label} email`} />
|
||||
<input type="tel" className={inputCls} placeholder="Phone (optional)" value={e.phone} onChange={ev => set(s.key, "phone", ev.target.value)} aria-label={`${s.label} phone`} />
|
||||
</div>
|
||||
{consent && (
|
||||
<div className="mt-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Date of birth</label>
|
||||
<input type="date" className={`${inputCls} sm:w-48`} value={e.dateOfBirth || ""} max={new Date().toISOString().slice(0, 10)} onChange={ev => set(s.key, "dateOfBirth", ev.target.value)} aria-label={`${s.label} date of birth`} />
|
||||
{isMinorOn(e.dateOfBirth, consentEventStart) && (
|
||||
<div className="mt-2 rounded-lg bg-amber-50 border border-amber-200 p-2">
|
||||
<div className="text-xs font-medium text-amber-900 mb-1">Parent or guardian (they'll be asked for consent)</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input className={inputCls} placeholder="Parent/guardian name" value={e.parentName || ""} onChange={ev => set(s.key, "parentName", ev.target.value)} aria-label={`${s.label} parent name`} />
|
||||
<input type="email" className={inputCls} placeholder="Parent email" value={e.parentEmail || ""} onChange={ev => set(s.key, "parentEmail", ev.target.value)} aria-label={`${s.label} parent email`} />
|
||||
<input type="tel" className={inputCls} placeholder="Parent phone" value={e.parentPhone || ""} onChange={ev => set(s.key, "parentPhone", ev.target.value)} aria-label={`${s.label} parent phone`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,9 +3,24 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Users, Send } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
|
||||
|
||||
type Attendee = { id: string; eventOptionId: string; variantId: string | null; name: string; email: string | null; phone: string | null; ticket?: { id: string; isUsed: boolean } | null };
|
||||
type Row = { id?: string; eventOptionId: string; variantId: string | null; label: string; name: string; email: string; phone: string; locked: boolean };
|
||||
const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
pending: { text: "consent pending", cls: "text-amber-700" },
|
||||
given: { text: "consent given", cls: "text-green-700" },
|
||||
declined: { text: "consent declined", cls: "text-red-700" },
|
||||
};
|
||||
|
||||
type Attendee = {
|
||||
id: string; eventOptionId: string; variantId: string | null; name: string; email: string | null; phone: string | null;
|
||||
dateOfBirth?: string | null; parentName?: string | null; parentEmail?: string | null; parentPhone?: string | null;
|
||||
ticket?: { id: string; isUsed: boolean } | null;
|
||||
consentRequests?: { id: string; status: "pending" | "given" | "declined" }[];
|
||||
};
|
||||
type Row = {
|
||||
id?: string; eventOptionId: string; variantId: string | null; label: string; name: string; email: string; phone: string; locked: boolean;
|
||||
dateOfBirth: string; parentName: string; parentEmail: string; parentPhone: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Named attendees on a registration (group tickets): shows who each main-ticket place
|
||||
@@ -21,6 +36,8 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
onMessage: (msg: string) => void;
|
||||
}) {
|
||||
const attendees: Attendee[] = registration.attendees || [];
|
||||
const youth = !!registration.event?.requiresParentalConsent;
|
||||
const eventStart: string | undefined = registration.event?.startDate;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [delivery, setDelivery] = useState<"payer" | "each">(registration.ticketDelivery || "payer");
|
||||
@@ -39,6 +56,8 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
out.push({
|
||||
id: a?.id, eventOptionId: ro.eventOptionId, variantId: ro.variantId || null, label: `${label} ${i + 1}`,
|
||||
name: a?.name || "", email: a?.email || "", phone: a?.phone || "",
|
||||
dateOfBirth: a?.dateOfBirth ? String(a.dateOfBirth).slice(0, 10) : "",
|
||||
parentName: a?.parentName || "", parentEmail: a?.parentEmail || "", parentPhone: a?.parentPhone || "",
|
||||
locked: !!a?.ticket?.isUsed,
|
||||
});
|
||||
}
|
||||
@@ -46,10 +65,11 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
return out;
|
||||
}, [registration, attendees]);
|
||||
|
||||
if (!registration.event?.allowNamedAttendees && attendees.length === 0) return null;
|
||||
if (!registration.event?.allowNamedAttendees && !youth && attendees.length === 0) return null;
|
||||
|
||||
const startEdit = () => { setRows(buildRows()); setDelivery(registration.ticketDelivery || "payer"); setError(null); setEditing(true); };
|
||||
const update = (i: number, field: "name" | "email" | "phone", value: string) => setRows(prev => prev.map((r, j) => (j === i ? { ...r, [field]: value } : r)));
|
||||
const update = (i: number, field: "name" | "email" | "phone" | "dateOfBirth" | "parentName" | "parentEmail" | "parentPhone", value: string) =>
|
||||
setRows(prev => prev.map((r, j) => (j === i ? { ...r, [field]: value } : r)));
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
@@ -67,6 +87,10 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
name: r.name.trim(),
|
||||
email: r.email.trim() || undefined,
|
||||
phone: r.phone.trim() || undefined,
|
||||
...(r.dateOfBirth ? { dateOfBirth: r.dateOfBirth } : {}),
|
||||
...(r.parentName.trim() ? { parentName: r.parentName.trim() } : {}),
|
||||
...(r.parentEmail.trim() ? { parentEmail: r.parentEmail.trim() } : {}),
|
||||
...(r.parentPhone.trim() ? { parentPhone: r.parentPhone.trim() } : {}),
|
||||
})),
|
||||
},
|
||||
});
|
||||
@@ -91,6 +115,15 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
|
||||
const answered = new Set((registration.formResponses || []).map((r: any) => r.attendeeId).filter(Boolean));
|
||||
|
||||
const resendConsent = async (requestId: string) => {
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/consent/requests/${encodeURIComponent(requestId)}/resend`, { method: "POST", authToken: token });
|
||||
onMessage(res.message);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Couldn't re-send the consent request");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="font-medium mb-1 flex items-center justify-between">
|
||||
@@ -112,7 +145,13 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
{a.name}
|
||||
{(a.email || a.phone) && <span className="text-xs text-gray-500"> · {[a.email, a.phone].filter(Boolean).join(" · ")}</span>}
|
||||
{hasForm && <span className={`ml-2 text-xs ${answered.has(a.id) ? "text-green-700" : "text-amber-700"}`}>{answered.has(a.id) ? "form done" : "form to do"}</span>}
|
||||
{youth && a.consentRequests?.[0] && (
|
||||
<span className={`ml-2 text-xs ${CONSENT_LABEL[a.consentRequests[0].status]?.cls}`}>{CONSENT_LABEL[a.consentRequests[0].status]?.text}</span>
|
||||
)}
|
||||
</span>
|
||||
{canEdit && youth && a.consentRequests?.[0]?.status === "pending" && (
|
||||
<button onClick={() => resendConsent(a.consentRequests![0].id)} className="text-xs text-brand-700 hover:underline shrink-0 inline-flex items-center gap-1"><Send className="w-3 h-3" /> Resend consent</button>
|
||||
)}
|
||||
{canEdit && hasForm && !answered.has(a.id) && (a.email || a.phone) && (
|
||||
<button onClick={() => sendLink(a)} className="text-xs text-brand-700 hover:underline shrink-0 inline-flex items-center gap-1"><Send className="w-3 h-3" /> Send form link</button>
|
||||
)}
|
||||
@@ -132,6 +171,18 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
<input className="border rounded px-2 py-1 text-sm" placeholder="Email (optional)" value={r.email} disabled={r.locked} onChange={e => update(i, "email", e.target.value)} aria-label={`${r.label} email`} />
|
||||
<input className="border rounded px-2 py-1 text-sm" placeholder="Phone (optional)" value={r.phone} disabled={r.locked} onChange={e => update(i, "phone", e.target.value)} aria-label={`${r.label} phone`} />
|
||||
</div>
|
||||
{youth && (
|
||||
<div className="mt-1 space-y-1">
|
||||
<input type="date" className="border rounded px-2 py-1 text-sm" value={r.dateOfBirth} disabled={r.locked} onChange={e => update(i, "dateOfBirth", e.target.value)} aria-label={`${r.label} date of birth`} />
|
||||
{isMinorOn(r.dateOfBirth, eventStart) && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input className="border rounded px-2 py-1 text-sm" placeholder="Parent/guardian name" value={r.parentName} disabled={r.locked} onChange={e => update(i, "parentName", e.target.value)} aria-label={`${r.label} parent name`} />
|
||||
<input className="border rounded px-2 py-1 text-sm" placeholder="Parent email" value={r.parentEmail} disabled={r.locked} onChange={e => update(i, "parentEmail", e.target.value)} aria-label={`${r.label} parent email`} />
|
||||
<input className="border rounded px-2 py-1 text-sm" placeholder="Parent phone" value={r.parentPhone} disabled={r.locked} onChange={e => update(i, "parentPhone", e.target.value)} aria-label={`${r.label} parent phone`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="text-sm pt-1">
|
||||
|
||||
@@ -26,7 +26,7 @@ export const eventDetailHelpContent: HelpContent = {
|
||||
"N remaining" appears once stock runs low, and "Sold out" once an option is fully booked.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Users} title="Registering for other people?" tone="violet">
|
||||
Some events let you name each person you're booking for. Each named person gets their own ticket and their own attendee form, and you can choose to have every ticket sent to you or each person their own.
|
||||
Some events let you name each person you're booking for. Each named person gets their own ticket and their own attendee form, and you can choose to have every ticket sent to you or each person their own. On youth events, you'll give each person's date of birth; for anyone under 18, their parent or guardian is asked to give consent by email and WhatsApp before they can be checked in.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { supervisorAtTheDoorHelpContent } from "./supervisor-at-the-door";
|
||||
import { supervisorPaymentsHelpContent } from "./supervisor-payments";
|
||||
import { supervisorFormsHelpContent } from "./supervisor-forms";
|
||||
import { supervisorCouponsHelpContent } from "./supervisor-coupons";
|
||||
import { supervisorConsentHelpContent } from "./supervisor-consent";
|
||||
import { supervisorEmailAttendeesHelpContent } from "./supervisor-email-attendees";
|
||||
import { supervisorWhatsappAttendeesHelpContent } from "./supervisor-whatsapp-attendees";
|
||||
import { staffEventTicketsHelpContent } from "./staff-event-tickets";
|
||||
@@ -148,6 +149,11 @@ const REGISTRY: HelpRegistryEntry[] = [
|
||||
matches: pathname => pathname === "/dashboard/supervisor/payments",
|
||||
content: supervisorPaymentsHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-consent",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/consent",
|
||||
content: supervisorConsentHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-coupons",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/coupons",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { QrCode, Filter, CheckCircle2 } from "lucide-react";
|
||||
import { QrCode, Filter, CheckCircle2, ShieldAlert } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -19,6 +19,9 @@ export const staffTicketScanningHelpContent: HelpContent = {
|
||||
<GuideItem icon={CheckCircle2} title="Outcomes" tone="green">
|
||||
You'll see Success (with redeemed/remaining counts), Already Used, or an Error if something's wrong with the ticket.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ShieldAlert} title="Parental consent" tone="rose">
|
||||
On youth events, the confirm screen warns when a parent hasn't given consent yet (or declined). That person can't be checked in; a supervisor can override, and the override is recorded in the audit log.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import { ShieldCheck, Send, RefreshCw, HeartPulse, Ban, Clock, QrCode } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
/** Help content for /dashboard/supervisor/consent. */
|
||||
export const supervisorConsentHelpContent: HelpContent = {
|
||||
title: "Parental consent",
|
||||
subtitle: "Youth events: who still needs a parent's consent before check-in.",
|
||||
tabs: [
|
||||
{
|
||||
key: "how",
|
||||
label: "How it works",
|
||||
icon: ShieldCheck,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={ShieldCheck} title="Turning it on" tone="brand">
|
||||
In the event wizard's Visibility step, tick "Requires parental consent", write the consent text, and choose whether to ask for medical info, an emergency contact and photo consent.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Send} title="Requests" tone="green">
|
||||
Everyone registering gives a date of birth for each attendee. For anyone under 18 on the event's start date, the parent or guardian is sent a consent link by email and WhatsApp. They read the text, fill in any medical details, type their name to sign, and give or decline consent.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Clock} title="Reminders" tone="amber">
|
||||
Unanswered requests are reminded automatically 2 days later, then every 3 days until the event. Links stop working when the event ends.
|
||||
</GuideItem>
|
||||
<GuideItem icon={QrCode} title="At check-in" tone="rose">
|
||||
Tickets are sent as normal, but the scanner won't check someone in until consent is given. Supervisors can override, which is recorded in the audit log.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Ban} title="Declined" tone="gray">
|
||||
The registrant and the event's notification recipients are told. The registration stays, but can't be checked in — handle any refund from Payments.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
label: "This page",
|
||||
icon: RefreshCw,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Send} title="Resend" tone="brand">
|
||||
Resend one request, or every pending one at once. Each resend issues a new link; old links stop working.
|
||||
</GuideItem>
|
||||
<GuideItem icon={RefreshCw} title="Re-request consent" tone="violet">
|
||||
If you change the consent text, parents who already agreed keep their record of the old version. "Re-request consent" asks them again on the new text, and also sends requests to under-18s who never got one (for example, names added later).
|
||||
</GuideItem>
|
||||
<GuideItem icon={HeartPulse} title="Medical & emergency list" tone="rose">
|
||||
A printable, confidential list for camp leaders, for attendees whose consent has been given. Every time it's opened is recorded in the audit log. Medical details are deleted a set number of days after the event (Site Settings → Legal).
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -44,7 +44,7 @@ export const supervisorEventsHelpContent: HelpContent = {
|
||||
Build a custom registration form — text, number, date, yes/no, statement, and heading fields, reorderable, optionally required before tickets are generated.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Eye} title="Visibility" tone="gray">
|
||||
Hide the event, allow guest (no-account) registration, turn on named attendees (group tickets — each named person gets their own ticket and form), or toggle it active.
|
||||
Hide the event, allow guest (no-account) registration, turn on named attendees (group tickets — each named person gets their own ticket and form), make it a youth event that needs parental consent for under-18s, or toggle it active.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Bell} title="Notifications" tone="blue">
|
||||
Choose which staff get registration, payment, and daily-summary emails for this event.
|
||||
|
||||
@@ -13,6 +13,7 @@ It runs in any web browser — on a phone, tablet, or computer — with nothing
|
||||
### For your congregation
|
||||
|
||||
- **Browse events** — a clean, mobile-friendly listing of upcoming events with photos, descriptions, dates, and location (with a map and directions). Fully booked events show a "Sold Out" badge, and popular tickets show how many are left.
|
||||
- **Parental consent for youth events** — parents receive an email and WhatsApp to give consent online (with medical details and an emergency contact); children can't be checked in until they have. Reminders go out automatically.
|
||||
- **Group bookings** — a parent can register several children at once, naming each one so they get their own ticket and their own form, with the tickets sent to the parent or to each person.
|
||||
- **Discount codes** — enter a code for a staff, leader or promotional discount, shown on the invoice and receipt.
|
||||
- **Register online** — choose from multiple ticket types (e.g. adult, child, family, dinner, T-shirt sizes) at different prices, including early-bird pricing. People can register as a guest or with an account, depending on how you set up the event.
|
||||
@@ -42,7 +43,7 @@ It runs in any web browser — on a phone, tablet, or computer — with nothing
|
||||
- **Messaging** — send emails or WhatsApp messages to attendees, filtered by who has paid or still owes. Personalise messages with each person's name, balance, or their own payment link. Schedule reminders, thank-you messages, and promotions in advance.
|
||||
- **Daily summary** — the event team gets a daily email with registrations, payments, and outstanding balances in the run-up to each event.
|
||||
- **Forms** — view, filter, and print attendee form responses.
|
||||
- **Reports** — 15 ready-made reports covering payments, revenue, attendees, ticket usage, registration status, donations, discounts, costs, and profit. Each can be printed or exported to PDF, Excel, email, or WhatsApp.
|
||||
- **Reports** — 17 ready-made reports covering payments, revenue, attendees, ticket usage, registration status, donations, discounts, parental consent, medical lists, costs, and profit. Each can be printed or exported to PDF, Excel, email, or WhatsApp.
|
||||
- **Dashboard** — revenue, donations, registrations, and tickets sold at a glance, compared with the previous month.
|
||||
|
||||
### For administrators and treasurers
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user