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(`
${line}
`), 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(`${line}
`), 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 };