449 lines
19 KiB
JavaScript
449 lines
19 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { safeErrorMessage } = require('../utils/errorUtils');
|
|
const { logAdminAction } = require('../utils/adminAudit');
|
|
const { getClientIp } = require('../utils/requestUtils');
|
|
const { logDataAccess } = require('../utils/accessLog');
|
|
const { findOrCreateAccount } = require('../utils/accountMatch');
|
|
const { listWhere, withScope } = require('../utils/campusScope');
|
|
const kids = require('../utils/kids');
|
|
|
|
const SUPERVISORS = ['admin', 'supervisor'];
|
|
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error), ...(error.code && typeof error.code === 'string' && !error.code.startsWith('P') ? { code: error.code } : {}) });
|
|
const audit = (req, action, targetType, targetId, metadata) => logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action, targetType, targetId, metadata, ip: getClientIp(req) });
|
|
|
|
/** Child fields from a request; only supervisors, admins and the child's parents set custody notes. */
|
|
function childInput(req, { parent = false } = {}) {
|
|
const body = { ...(req.body || {}) };
|
|
if (!parent && !SUPERVISORS.includes(req.user.role)) delete body.custodyNotes;
|
|
return body;
|
|
}
|
|
|
|
async function saveChild(req, familyId, childId, { parent }) {
|
|
const existing = childId ? await prisma.child.findFirst({ where: { id: String(childId), familyId } }) : null;
|
|
if (childId && !existing) throw kids.kidsError('Child not found', 404);
|
|
const data = kids.cleanChild(childInput(req, { parent }), existing);
|
|
const child = existing
|
|
? await prisma.child.update({ where: { id: existing.id }, data })
|
|
: await prisma.child.create({ data: { ...data, familyId } });
|
|
if (kids.custodyChanged(existing, data)) audit(req, 'kids_custody_notes_changed', 'child', child.id, { familyId, by: parent ? 'parent' : 'staff' });
|
|
return child;
|
|
}
|
|
|
|
// ─── My account → Kids (parents) ──────────────────────────────────────────────
|
|
|
|
// @route GET /api/kids/me
|
|
const getMine = async (req, res) => {
|
|
try {
|
|
const family = await kids.familyOfUser(req.user.id);
|
|
if (!family) return res.json({ family: null, qrToken: null, codes: [], pages: [] });
|
|
const active = await kids.activeForFamily(family.id);
|
|
res.json({ family: kids.familyView(family), qrToken: family.qrToken, ...active });
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route POST /api/kids/me { name }
|
|
const createMine = async (req, res) => {
|
|
try {
|
|
const family = await kids.createFamily({ name: req.body?.name || `${req.user.name || 'My'} family`, userId: req.user.id });
|
|
res.status(201).json({ family: kids.familyView(family), qrToken: family.qrToken, codes: [], pages: [] });
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
async function myFamilyId(req) {
|
|
const m = await prisma.familyMember.findUnique({ where: { userId: req.user.id } });
|
|
if (!m) throw kids.kidsError('Add your family first.', 404);
|
|
return m.familyId;
|
|
}
|
|
|
|
// @route PUT /api/kids/me { name }
|
|
const renameMine = async (req, res) => {
|
|
try {
|
|
const id = await myFamilyId(req);
|
|
const name = String(req.body?.name || '').trim().slice(0, 100);
|
|
if (!name) throw kids.kidsError('Enter a name for the family.');
|
|
await prisma.family.update({ where: { id }, data: { name } });
|
|
res.json(kids.familyView(await kids.loadFamily(id)));
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route POST /api/kids/me/children, PUT /api/kids/me/children/:id
|
|
const saveMyChild = async (req, res) => {
|
|
try {
|
|
const id = await myFamilyId(req);
|
|
const child = await saveChild(req, id, req.params.id, { parent: true });
|
|
res.status(req.params.id ? 200 : 201).json(kids.childView(child));
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route DELETE /api/kids/me/children/:id
|
|
const deleteMyChild = async (req, res) => {
|
|
try {
|
|
const id = await myFamilyId(req);
|
|
const out = await prisma.child.deleteMany({ where: { id: String(req.params.id), familyId: id } });
|
|
if (!out.count) throw kids.kidsError('Child not found', 404);
|
|
res.json({ message: 'Deleted' });
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route POST /api/kids/me/adults, PUT /api/kids/me/adults/:id
|
|
const saveMyAdult = async (req, res) => {
|
|
try {
|
|
const id = await myFamilyId(req);
|
|
const data = kids.cleanAdult(req.body);
|
|
if (req.params.id) {
|
|
const out = await prisma.authorisedAdult.updateMany({ where: { id: String(req.params.id), familyId: id }, data });
|
|
if (!out.count) throw kids.kidsError('Not found', 404);
|
|
return res.json(await prisma.authorisedAdult.findUnique({ where: { id: String(req.params.id) } }));
|
|
}
|
|
if (await prisma.authorisedAdult.count({ where: { familyId: id } }) >= 10) throw kids.kidsError('A family can list up to 10 authorised adults.');
|
|
res.status(201).json(await prisma.authorisedAdult.create({ data: { ...data, familyId: id } }));
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route DELETE /api/kids/me/adults/:id
|
|
const deleteMyAdult = async (req, res) => {
|
|
try {
|
|
const id = await myFamilyId(req);
|
|
const out = await prisma.authorisedAdult.deleteMany({ where: { id: String(req.params.id), familyId: id } });
|
|
if (!out.count) throw kids.kidsError('Not found', 404);
|
|
res.json({ message: 'Deleted' });
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route DELETE /api/kids/me (leave the family; the last parent leaving deletes it)
|
|
const leaveMine = async (req, res) => {
|
|
try {
|
|
await myFamilyId(req);
|
|
const out = await prisma.$transaction(tx => kids.removeFromFamily(tx, req.user.id));
|
|
res.json(out);
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// ─── Staff: the desk and the rooms ────────────────────────────────────────────
|
|
|
|
// @desc Events with kids check-in that are on now or soon (and the last week's)
|
|
// @route GET /api/kids/events
|
|
const listEvents = async (req, res) => {
|
|
try {
|
|
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
const events = await prisma.event.findMany({
|
|
where: withScope({ kidsCheckIn: true, endDate: { gte: since } }, await listWhere(req)),
|
|
select: {
|
|
id: true, title: true, startDate: true, endDate: true, scheduleType: true, campusId: true,
|
|
sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } },
|
|
},
|
|
orderBy: { startDate: 'asc' },
|
|
take: 100,
|
|
});
|
|
res.json(events);
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route GET /api/kids/events/:eventId/board?sessionId=&log=1
|
|
const getBoard = async (req, res) => {
|
|
try {
|
|
const data = await kids.board({ eventId: req.params.eventId, sessionId: req.query.sessionId || null });
|
|
// Polling refreshes don't log; opening the screen does (it shows medical and custody notes).
|
|
if (req.query.log === '1') {
|
|
logDataAccess(req, { action: 'kids_rooms', eventId: data.event.id, records: data.checkIns.length, sensitive: true });
|
|
}
|
|
res.json(data);
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route GET /api/kids/events/:eventId/families?q=
|
|
const searchFamilies = async (req, res) => {
|
|
try {
|
|
const rows = await kids.searchFamilies(req.query.q);
|
|
res.json(rows.map(f => kids.familyView(f)));
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route GET /api/kids/events/:eventId/families/qr/:token
|
|
const familyByQr = async (req, res) => {
|
|
try {
|
|
const raw = String(req.params.token || '').trim().replace(/^FAMILY-/i, '');
|
|
const f = await prisma.family.findUnique({ where: { qrToken: raw } });
|
|
if (!f) throw kids.kidsError('That isn\'t a family QR code from this site.', 404);
|
|
res.json(kids.familyView(await kids.loadFamily(f.id)));
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route GET /api/kids/events/:eventId/families/:familyId
|
|
const getFamily = async (req, res) => {
|
|
try {
|
|
res.json(kids.familyView(await kids.loadFamily(req.params.familyId)));
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
/** A parent from the desk: their account (matched by email/phone, or a new inactive one). */
|
|
async function parentAccount(input) {
|
|
const name = String(input?.name || '').trim().slice(0, 100);
|
|
if (!name) throw kids.kidsError('Enter the parent\'s name.');
|
|
const email = String(input?.email || '').trim();
|
|
const phone = String(input?.phone || '').trim();
|
|
if (!email && !phone) throw kids.kidsError('Enter the parent\'s phone number or email address, so the pickup code can be sent to them.');
|
|
const { user } = await findOrCreateAccount({ name, email, phone });
|
|
return user;
|
|
}
|
|
|
|
// @desc New family at the desk: a parent (found or created) and their children
|
|
// @route POST /api/kids/events/:eventId/families { name, parent: { name, phone, email }, children: [...] }
|
|
const createFamilyAtDesk = async (req, res) => {
|
|
try {
|
|
const user = await parentAccount(req.body?.parent);
|
|
const existing = await kids.familyOfUser(user.id);
|
|
// Don't tell the desk anything about an account beyond what they typed: if the person is
|
|
// already in a family, that family is used (they're standing at the desk).
|
|
const family = existing || await kids.createFamily({ name: req.body?.name || `${user.name.split(' ').slice(-1)[0]} family`, userId: user.id });
|
|
for (const c of (Array.isArray(req.body?.children) ? req.body.children : []).slice(0, 12)) {
|
|
await saveChild({ ...req, body: c }, family.id, null, { parent: false });
|
|
}
|
|
res.status(existing ? 200 : 201).json(kids.familyView(await kids.loadFamily(family.id)));
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route POST /api/kids/events/:eventId/families/:familyId/parents { name, phone, email }
|
|
const addParentAtDesk = async (req, res) => {
|
|
try {
|
|
const family = await kids.loadFamily(req.params.familyId);
|
|
const user = await parentAccount(req.body);
|
|
const m = await prisma.familyMember.findUnique({ where: { userId: user.id } });
|
|
if (m && m.familyId !== family.id) throw kids.kidsError('This person is already a parent in another family.', 409);
|
|
if (!m) await prisma.familyMember.create({ data: { familyId: family.id, userId: user.id } });
|
|
res.json(kids.familyView(await kids.loadFamily(family.id)));
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route POST /api/kids/events/:eventId/families/:familyId/children, PUT .../children/:id
|
|
const saveChildAtDesk = async (req, res) => {
|
|
try {
|
|
const family = await kids.loadFamily(req.params.familyId);
|
|
const child = await saveChild(req, family.id, req.params.childId, { parent: false });
|
|
res.status(req.params.childId ? 200 : 201).json(kids.childView(child));
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route POST /api/kids/events/:eventId/families/:familyId/adults { name, phone, relationship }
|
|
const addAdultAtDesk = async (req, res) => {
|
|
try {
|
|
const family = await kids.loadFamily(req.params.familyId);
|
|
if (family.adults.length >= 10) throw kids.kidsError('A family can list up to 10 authorised adults.');
|
|
await prisma.authorisedAdult.create({ data: { ...kids.cleanAdult(req.body), familyId: family.id } });
|
|
res.status(201).json(kids.familyView(await kids.loadFamily(family.id)));
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route POST /api/kids/events/:eventId/check-in { familyId, sessionId?, children: [{ childId, roomId? }], sendToAdultId? }
|
|
const checkIn = async (req, res) => {
|
|
try {
|
|
const out = await kids.checkIn({
|
|
eventId: req.params.eventId, sessionId: req.body?.sessionId || null, familyId: req.body?.familyId,
|
|
children: req.body?.children, staffUserId: req.user.id,
|
|
});
|
|
const fresh = out.checkIns.filter(c => !out.already.includes(c.id));
|
|
if (fresh.length) kids.sendCheckInMessages({ event: out.event, family: out.family, code: out.code, checkIns: fresh, adultId: req.body?.sendToAdultId || null }).catch(() => {});
|
|
res.status(201).json({
|
|
code: out.code,
|
|
qr: kids.QR_PREFIX + out.code,
|
|
occasion: out.occasion,
|
|
family: { id: out.family.id, name: out.family.name },
|
|
checkIns: out.checkIns.map(c => {
|
|
const child = c.child ? kids.childView(c.child) : null;
|
|
return {
|
|
id: c.id, name: c.childName, firstName: child?.firstName || c.childName, room: c.room, already: out.already.includes(c.id),
|
|
sticker: kids.stickerAlert(child?.allergies, child?.medicalNotes),
|
|
};
|
|
}),
|
|
});
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route POST /api/kids/events/:eventId/check-out { code, checkInIds?, collectedBy, roomId?, sessionId? }
|
|
const checkOut = async (req, res) => {
|
|
try {
|
|
const out = await kids.checkOut({
|
|
eventId: req.params.eventId, sessionId: req.body?.sessionId || null, code: req.body?.code, checkInIds: req.body?.checkInIds,
|
|
collectedBy: req.body?.collectedBy, roomId: req.body?.roomId || null, staffUserId: req.user.id,
|
|
});
|
|
res.json({ checkedOut: out.checkIns.map(c => ({ id: c.id, name: c.childName, room: c.room?.name || null })) });
|
|
} catch (error) {
|
|
if (error.code === 'WRONG_CODE') audit(req, 'kids_wrong_code', 'event', req.params.eventId, { typed: String(req.body?.code || '').slice(0, 20), roomId: req.body?.roomId || null });
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @desc Look up a code before checking out: which children are on it (to confirm who's collected)
|
|
// @route GET /api/kids/events/:eventId/code/:code?sessionId=
|
|
const lookupCode = async (req, res) => {
|
|
try {
|
|
const event = await kids.loadKidsEvent(req.params.eventId);
|
|
const { occasion } = kids.occasionFor(event, event.sessions, req.query.sessionId || null);
|
|
const code = kids.normaliseCode(req.params.code);
|
|
const row = code ? await prisma.kidsPickupCode.findUnique({ where: { eventId_occasion_code: { eventId: event.id, occasion, code } } }) : null;
|
|
if (!row) return res.json({ code, found: false, children: [] });
|
|
const rows = await prisma.kidsCheckIn.findMany({ where: { pickupCodeId: row.id }, include: { room: { select: { id: true, name: true } } }, orderBy: { childName: 'asc' } });
|
|
const family = row.familyId ? kids.familyView(await kids.loadFamily(row.familyId), { notes: false }) : null;
|
|
res.json({
|
|
code, found: true,
|
|
family: family ? { name: family.name, parents: family.parents, adults: family.adults } : null,
|
|
children: rows.map(c => ({ id: c.id, name: c.childName, room: c.room, checkedOutAt: c.checkedOutAt, collectedBy: c.collectedBy })),
|
|
});
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route POST /api/kids/check-ins/:id/override { reason, collectedBy }
|
|
const overrideCheckOut = async (req, res) => {
|
|
try {
|
|
const ci = await kids.overrideCheckOut({ checkInId: req.params.id, reason: req.body?.reason, collectedBy: req.body?.collectedBy, staffUserId: req.user.id });
|
|
audit(req, 'kids_checkout_override', 'kids_check_in', ci.id, { child: ci.childName, collectedBy: ci.collectedBy, reason: ci.overrideReason });
|
|
res.json({ id: ci.id, checkedOutAt: ci.checkedOutAt });
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route POST /api/kids/check-ins/:id/page { message? }
|
|
const pageParent = async (req, res) => {
|
|
try {
|
|
res.json(await kids.pageParent({ checkInId: req.params.id, message: req.body?.message, staffUserId: req.user.id }));
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route PUT /api/kids/check-ins/:id/room { roomId }
|
|
const moveRoom = async (req, res) => {
|
|
try {
|
|
const ci = await kids.moveRoom({ checkInId: req.params.id, roomId: req.body?.roomId });
|
|
res.json({ id: ci.id, roomId: ci.roomId });
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route POST /api/kids/events/:eventId/duty { roomId, on, sessionId? }
|
|
const setDuty = async (req, res) => {
|
|
try {
|
|
res.json(await kids.setDuty({ eventId: req.params.eventId, sessionId: req.body?.sessionId || null, roomId: req.body?.roomId, userId: req.user.id, on: req.body?.on !== false }));
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route POST /api/kids/alerts/:id/resolve
|
|
const resolveAlert = async (req, res) => {
|
|
try {
|
|
const out = await prisma.kidsAlert.updateMany({ where: { id: String(req.params.id), resolvedAt: null }, data: { resolvedAt: new Date() } });
|
|
res.json({ resolved: out.count });
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route GET /api/kids/events/:eventId/offline?deviceId=&sessionId=
|
|
const offlineSnapshot = async (req, res) => {
|
|
try {
|
|
const data = await kids.offlineSnapshot({ eventId: req.params.eventId, sessionId: req.query.sessionId || null, deviceId: req.query.deviceId });
|
|
logDataAccess(req, { action: 'kids_offline', eventId: req.params.eventId, records: data.families.length, sensitive: true });
|
|
res.json(data);
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route POST /api/kids/events/:eventId/sync { deviceId, occasion, checkIns, checkOuts }
|
|
const syncOffline = async (req, res) => {
|
|
try {
|
|
const out = await kids.syncOffline({ eventId: req.params.eventId, ...(req.body || {}), staffUserId: req.user.id });
|
|
if (out.checkedIn || out.checkedOut || out.conflicts.length) audit(req, 'kids_offline_sync', 'event', req.params.eventId, { checkedIn: out.checkedIn, checkedOut: out.checkedOut, conflicts: out.conflicts.length });
|
|
res.json(out);
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// ─── Supervisor: setup ────────────────────────────────────────────────────────
|
|
|
|
// @route GET /api/kids/events/:eventId/settings
|
|
const getSettings = async (req, res) => {
|
|
try {
|
|
res.json(await kids.getSettings(req.params.eventId));
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route PUT /api/kids/events/:eventId/settings { enabled?, optionId?, roomSetId? } ('new' makes one)
|
|
const saveSettings = async (req, res) => {
|
|
try {
|
|
const { enabled, optionId, roomSetId } = req.body || {};
|
|
const out = await kids.saveSettings(req.params.eventId, { enabled, optionId, roomSetId });
|
|
audit(req, 'kids_settings_updated', 'event', out.event.id, { enabled: out.event.kidsCheckIn, optionId: out.event.kidsOptionId, roomSetId: out.event.kidsRoomSetId });
|
|
res.json(out);
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @desc Events a supervisor can switch kids check-in on for (upcoming, newest first)
|
|
// @route GET /api/kids/setup-events
|
|
const setupEvents = async (req, res) => {
|
|
try {
|
|
const events = await prisma.event.findMany({
|
|
where: withScope({ endDate: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }, await listWhere(req)),
|
|
select: { id: true, title: true, startDate: true, kidsCheckIn: true, campusId: true },
|
|
orderBy: { startDate: 'asc' },
|
|
take: 200,
|
|
});
|
|
res.json(events);
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
getMine, createMine, renameMine, saveMyChild, deleteMyChild, saveMyAdult, deleteMyAdult, leaveMine,
|
|
listEvents, getBoard, searchFamilies, familyByQr, getFamily, createFamilyAtDesk, addParentAtDesk, saveChildAtDesk, addAdultAtDesk,
|
|
checkIn, checkOut, lookupCode, overrideCheckOut, pageParent, moveRoom, setDuty, resolveAlert, offlineSnapshot, syncOffline,
|
|
getSettings, saveSettings, setupEvents,
|
|
};
|