Add parental consent for youth events

Youth events need a name and date of birth for every attendee, and a
parent/guardian's details for under-18s. Parents get an email and
WhatsApp link to a consent page that records the signed name, time, IP,
device and exact consent text; medical and emergency details are stored
encrypted. Tickets are issued but check-in is blocked until consent is
given (supervisor override audited). A daily job sends reminders and
purges medical details after the retention period. Adds a supervisor
consent tracker, consent status and medical list reports, and clears
consent data when an account is anonymised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 13:24:44 +02:00
co-authored by Claude Opus 5.5
parent f1e0b62c73
commit 809b6a11d7
41 changed files with 1988 additions and 88 deletions
@@ -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;
+88 -31
View File
@@ -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])
}