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>
265 lines
13 KiB
JavaScript
265 lines
13 KiB
JavaScript
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 };
|