Files
joshuaandClaude Opus 5.5 809b6a11d7 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>
2026-10-19 13:24:44 +02:00

105 lines
5.0 KiB
JavaScript

const { validateAttendees, planLineTickets, lineKey } = require('../src/utils/attendees');
const EVENT = {
allowNamedAttendees: true,
eventOptions: [
{ id: 'kid', name: 'Camper', isMainTicket: true },
{ id: 'shirt', name: 'T-shirt', isMainTicket: false },
],
};
const places = (n) => ({ [lineKey('kid', null)]: n });
describe('validateAttendees', () => {
test('cleans up names and contact details', () => {
const out = validateAttendees(EVENT, places(2), [
{ eventOptionId: 'kid', name: ' Sam ', email: ' ', phone: '0821234567' },
{ eventOptionId: 'kid', name: 'Alex', email: 'alex@example.com' },
]);
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 },
]);
});
test('nothing to check when no attendees are given', () => {
expect(validateAttendees({ ...EVENT, allowNamedAttendees: false }, places(1), [])).toEqual([]);
});
test('refuses names on events that do not take them', () => {
expect(() => validateAttendees({ ...EVENT, allowNamedAttendees: false }, places(1), [{ eventOptionId: 'kid', name: 'Sam' }])).toThrow(/doesn't take attendee names/);
});
test('only main tickets can be named', () => {
expect(() => validateAttendees(EVENT, { [lineKey('shirt', null)]: 1 }, [{ eventOptionId: 'shirt', name: 'Sam' }])).toThrow(/main tickets/);
});
test('needs a name and a valid email', () => {
expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: ' ' }])).toThrow(/enter a name/);
expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: 'Sam', email: 'nope' }])).toThrow(/valid email/);
});
test('no more names than places, counting names already given', () => {
expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: 'A' }, { eventOptionId: 'kid', name: 'B' }])).toThrow(/More names/);
expect(() => validateAttendees(EVENT, places(3), [{ eventOptionId: 'kid', name: 'C' }], places(2))).not.toThrow();
expect(() => validateAttendees(EVENT, places(2), [{ eventOptionId: 'kid', name: 'C' }], places(2))).toThrow(/More names/);
});
});
describe('planLineTickets', () => {
const scan = (q = 1) => [{ quantityRedeemed: q }];
test('no attendees: one shared ticket for the whole quantity', () => {
expect(planLineTickets({ quantity: 3 }, [], [])).toEqual({ create: [{ attendeeId: null, holderUserId: null, quantity: 3 }], update: [], remove: [] });
});
test('every attendee gets their own ticket; unnamed places share one', () => {
const plan = planLineTickets({ quantity: 3 }, [{ id: 'a1', userId: 'u9' }, { id: 'a2', userId: null }], []);
expect(plan.create).toEqual([
{ attendeeId: 'a1', holderUserId: 'u9', quantity: 1 },
{ attendeeId: 'a2', holderUserId: null, quantity: 1 },
{ attendeeId: null, holderUserId: null, quantity: 1 },
]);
});
test('naming every place replaces the shared ticket', () => {
const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }, { id: 'a2' }], [{ id: 't0', attendeeId: null, quantity: 2, usages: [] }]);
expect(plan.remove).toEqual(['t0']);
expect(plan.create).toHaveLength(2);
});
test('a scanned shared ticket is kept and never shrunk below what was used', () => {
const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }, { id: 'a2' }], [{ id: 't0', attendeeId: null, quantity: 2, usages: scan(1) }]);
expect(plan.remove).toEqual([]);
expect(plan.update).toEqual([{ id: 't0', data: { quantity: 1 } }]);
});
test('removing an attendee removes their unscanned ticket', () => {
const plan = planLineTickets({ quantity: 1 }, [{ id: 'a1' }], [
{ id: 't1', attendeeId: 'a1', quantity: 1, usages: [] },
{ id: 't2', attendeeId: 'gone', quantity: 1, usages: [] },
]);
expect(plan).toEqual({ create: [], update: [], remove: ['t2'] });
});
test("keeps a scanned ticket whose attendee was removed, and counts it as a used place", () => {
const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }], [
{ id: 't1', attendeeId: 'a1', quantity: 1, usages: [] },
{ id: 't2', attendeeId: 'gone', quantity: 1, usages: scan() },
]);
expect(plan).toEqual({ create: [], update: [], remove: [] });
});
test('follows the attendee to a newly linked account', () => {
const plan = planLineTickets({ quantity: 1 }, [{ id: 'a1', userId: 'u2' }], [{ id: 't1', attendeeId: 'a1', quantity: 1, holderUserId: null, usages: [] }]);
expect(plan.update).toEqual([{ id: 't1', data: { holderUserId: 'u2', quantity: 1 } }]);
});
test('existing single shared ticket is resized, duplicates dropped', () => {
const plan = planLineTickets({ quantity: 4 }, [], [
{ id: 't1', attendeeId: null, quantity: 2, usages: [] },
{ id: 't2', attendeeId: null, quantity: 2, usages: [] },
]);
expect(plan).toEqual({ create: [], update: [{ id: 't1', data: { quantity: 4 } }], remove: ['t2'] });
});
});