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:
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user