Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdc3db8d23 | ||
|
|
1a25896999 | ||
|
|
97c368c898 |
@@ -7,6 +7,20 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.51.13] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — kids check-in and volunteers:** my family (`/me/family`, its children and adults); kids
|
||||
set-up and the check-in desk (`/events/{id}/kids/…`: settings, board, families, check-in and out,
|
||||
collection codes, duty, offline snapshot and sync; `/kids/check-ins/{id}/…`, `/kids/alerts/{id}/resolve`);
|
||||
serving (`/me/volunteering`, `/events/{id}/volunteer-roles`, sign-up, swaps, self check-in) and rosters
|
||||
(`/events/{id}/volunteers` and its QR and settings, roles, assigning, manual check-in, hours).
|
||||
|
||||
### Changed
|
||||
|
||||
- The kids desk, rooms view, family page, serving page and volunteer rosters use v2.
|
||||
|
||||
## [1.51.12] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -12,6 +12,11 @@ Write for church admins, not developers: no environment variables, endpoints or
|
||||
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
|
||||
-->
|
||||
|
||||
## 1.51.13
|
||||
|
||||
- **More groundwork for 2.0.** Kids check-in and volunteer rosters now run on the new version of
|
||||
the server's connection to the app. They work just as before.
|
||||
|
||||
## 1.51.12
|
||||
|
||||
- **More groundwork for 2.0.** Tickets, ticket transfers, offline scanning, the attendance register,
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.12",
|
||||
"version": "1.51.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.12",
|
||||
"version": "1.51.13",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@cj-tech-master/excelts": "^10.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.12",
|
||||
"version": "1.51.13",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -46,6 +46,46 @@ const SUCCESSORS: Successor[] = [
|
||||
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/copy-from$/, v2: m => `/api/v2/events/${m[1]}/copy-from` },
|
||||
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
|
||||
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
|
||||
// 1.51.13: kids check-in and volunteer rosters
|
||||
{ method: 'GET', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
|
||||
{ method: 'POST', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
|
||||
{ method: 'PUT', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
|
||||
{ method: 'DELETE', v1: /^\/api\/kids\/me$/, v2: () => '/api/v2/me/family' },
|
||||
{ method: 'POST', v1: /^\/api\/kids\/me\/(children|adults)$/, v2: m => `/api/v2/me/family/${m[1]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/kids\/me\/(children|adults)\/([^/]+)$/, v2: m => `/api/v2/me/family/${m[1]}/${m[2]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/kids\/me\/(children|adults)\/([^/]+)$/, v2: m => `/api/v2/me/family/${m[1]}/${m[2]}` },
|
||||
{ method: 'GET', v1: /^\/api\/kids\/setup-events$/, v2: () => '/api/v2/kids/setup-events' },
|
||||
{ method: 'GET', v1: /^\/api\/kids\/events$/, v2: () => '/api/v2/kids/events' },
|
||||
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/(settings|board|families|offline)$/, v2: m => `/api/v2/events/${m[1]}/kids/${m[2]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/kids\/events\/([^/]+)\/settings$/, v2: m => `/api/v2/events/${m[1]}/kids/settings` },
|
||||
{ method: 'POST', v1: /^\/api\/kids\/events\/([^/]+)\/(families|check-in|check-out|duty|sync)$/, v2: m => `/api/v2/events/${m[1]}/kids/${m[2]}` },
|
||||
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/families\/qr\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/qr/${m[2]}` },
|
||||
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/families\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/${m[2]}` },
|
||||
{ method: 'POST', v1: /^\/api\/kids\/events\/([^/]+)\/families\/([^/]+)\/(parents|children|adults)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/${m[2]}/${m[3]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/kids\/events\/([^/]+)\/families\/([^/]+)\/children\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/families/${m[2]}/children/${m[3]}` },
|
||||
{ method: 'GET', v1: /^\/api\/kids\/events\/([^/]+)\/code\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/kids/code/${m[2]}` },
|
||||
{ method: 'POST', v1: /^\/api\/kids\/check-ins\/([^/]+)\/(override|page)$/, v2: m => `/api/v2/kids/check-ins/${m[1]}/${m[2]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/kids\/check-ins\/([^/]+)\/room$/, v2: m => `/api/v2/kids/check-ins/${m[1]}/room` },
|
||||
{ method: 'POST', v1: /^\/api\/kids\/alerts\/([^/]+)\/resolve$/, v2: m => `/api/v2/kids/alerts/${m[1]}/resolve` },
|
||||
{ method: 'GET', v1: /^\/api\/volunteers\/me$/, v2: () => '/api/v2/me/volunteering' },
|
||||
{ method: 'GET', v1: /^\/api\/volunteers\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/volunteer-roles` },
|
||||
{ method: 'POST', v1: /^\/api\/volunteers\/roles\/([^/]+)\/sign-up$/, v2: m => `/api/v2/volunteer-roles/${m[1]}/sign-up` },
|
||||
{ method: 'DELETE', v1: /^\/api\/volunteers\/slots\/([^/]+)$/, v2: m => `/api/v2/volunteer-slots/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/volunteers\/slots\/([^/]+)\/swap$/, v2: m => `/api/v2/volunteer-slots/${m[1]}/swap` },
|
||||
{ method: 'DELETE', v1: /^\/api\/volunteers\/swaps\/([^/]+)$/, v2: m => `/api/v2/volunteer-swaps/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/volunteers\/swaps\/([^/]+)\/accept$/, v2: m => `/api/v2/volunteer-swaps/${m[1]}/accept` },
|
||||
{ method: 'POST', v1: /^\/api\/volunteers\/check-in$/, v2: () => '/api/v2/volunteering/check-in' },
|
||||
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/events$/, v2: () => '/api/v2/volunteering/events' },
|
||||
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/hours$/, v2: () => '/api/v2/volunteering/hours' },
|
||||
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/volunteers` },
|
||||
{ method: 'GET', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)\/(qr|settings)$/, v2: m => `/api/v2/events/${m[1]}/volunteers/${m[2]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)\/settings$/, v2: m => `/api/v2/events/${m[1]}/volunteers/settings` },
|
||||
{ method: 'POST', v1: /^\/api\/volunteers\/manage\/events\/([^/]+)\/roles$/, v2: m => `/api/v2/events/${m[1]}/volunteer-roles` },
|
||||
{ method: 'PUT', v1: /^\/api\/volunteers\/manage\/roles\/([^/]+)$/, v2: m => `/api/v2/volunteer-roles/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/volunteers\/manage\/roles\/([^/]+)$/, v2: m => `/api/v2/volunteer-roles/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/volunteers\/manage\/roles\/([^/]+)\/assign$/, v2: m => `/api/v2/volunteer-roles/${m[1]}/assign` },
|
||||
{ method: 'POST', v1: /^\/api\/volunteers\/manage\/slots\/([^/]+)\/check-in$/, v2: m => `/api/v2/volunteer-slots/${m[1]}/check-in` },
|
||||
{ method: 'DELETE', v1: /^\/api\/volunteers\/manage\/slots\/([^/]+)$/, v2: m => `/api/v2/volunteer-slots/${m[1]}/remove` },
|
||||
// 1.51.12: tickets, scan locks, attendance and certificates, allocations
|
||||
{ method: 'GET', v1: /^\/api\/tickets\/mytickets$/, v2: () => '/api/v2/me/tickets' },
|
||||
{ method: 'POST', v1: /^\/api\/tickets\/email$/, v2: () => '/api/v2/me/tickets/email' },
|
||||
|
||||
@@ -16,6 +16,7 @@ import './resources/registrations';
|
||||
import './resources/registrationsManage';
|
||||
import './resources/money';
|
||||
import './resources/attendance';
|
||||
import './resources/kidsVolunteers';
|
||||
import './resources/checkin';
|
||||
import './resources/auth';
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Kids check-in and volunteer rosters on v2 (1.51.13). Each route runs the v1 handler
|
||||
* (viaV1.ts) behind the same access and campus checks as v1.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { id, idParams } from '../schemas';
|
||||
import { v1Route } from '../viaV1';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
|
||||
const cs = require('../../utils/campusScope') as {
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
|
||||
scopeList: any;
|
||||
};
|
||||
const kids = require('../../controllers/kidsController');
|
||||
const vol = require('../../controllers/volunteerController');
|
||||
|
||||
const loose = z.looseObject({});
|
||||
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
|
||||
/** v2 `:id` is the event; v1 called it `:eventId` (other params keep their names). */
|
||||
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
|
||||
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
|
||||
const view = { campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()] };
|
||||
const manage = { campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })] };
|
||||
|
||||
// ─── Kids: my family ─────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/me/family', tag: 'Kids', summary: 'My family', description: 'Children, the adults who may collect them, and the family QR code.', access: 'user', v1: kids.getMine });
|
||||
v1Route({ method: 'post', path: '/me/family', tag: 'Kids', summary: 'Start my family', access: 'user', status: 201, body: loose, v1: kids.createMine });
|
||||
v1Route({ method: 'patch', path: '/me/family', tag: 'Kids', summary: 'Rename my family', access: 'user', body: loose, v1: kids.renameMine });
|
||||
v1Route({ method: 'delete', path: '/me/family', tag: 'Kids', summary: 'Leave my family', access: 'user', v1: kids.leaveMine });
|
||||
v1Route({ method: 'post', path: '/me/family/children', tag: 'Kids', summary: 'Add a child', access: 'user', status: 201, body: loose, v1: kids.saveMyChild });
|
||||
v1Route({ method: 'put', path: '/me/family/children/:id', tag: 'Kids', summary: 'Change a child', access: 'user', params: idParams, body: loose, v1: kids.saveMyChild });
|
||||
v1Route({ method: 'delete', path: '/me/family/children/:id', tag: 'Kids', summary: 'Remove a child', access: 'user', params: idParams, v1: kids.deleteMyChild });
|
||||
v1Route({ method: 'post', path: '/me/family/adults', tag: 'Kids', summary: 'Add an adult who may collect', access: 'user', status: 201, body: loose, v1: kids.saveMyAdult });
|
||||
v1Route({ method: 'put', path: '/me/family/adults/:id', tag: 'Kids', summary: 'Change an adult', access: 'user', params: idParams, body: loose, v1: kids.saveMyAdult });
|
||||
v1Route({ method: 'delete', path: '/me/family/adults/:id', tag: 'Kids', summary: 'Remove an adult', access: 'user', params: idParams, v1: kids.deleteMyAdult });
|
||||
|
||||
// ─── Kids: set-up and the check-in desk ──────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/kids/setup-events', tag: 'Kids', summary: 'Events that can have kids check-in', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: kids.setupEvents });
|
||||
v1Route({ method: 'get', path: '/kids/events', tag: 'Kids', summary: 'Events with kids check-in', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: kids.listEvents });
|
||||
v1Route({ method: 'get', path: '/events/:id/kids/settings', tag: 'Kids', summary: 'An event\'s kids check-in set-up', access: 'supervisor', ...view, params: idParams, map: asEvent, v1: kids.getSettings });
|
||||
v1Route({ method: 'put', path: '/events/:id/kids/settings', tag: 'Kids', summary: 'Set up kids check-in', access: 'supervisor', ...manage, params: idParams, body: loose, map: asEvent, v1: kids.saveSettings });
|
||||
v1Route({ method: 'get', path: '/events/:id/kids/board', tag: 'Kids', summary: 'Who is checked in, by room', access: 'staff', ...view, params: idParams, query: filters('sessionId', 'log'), map: asEvent, v1: kids.getBoard });
|
||||
v1Route({ method: 'get', path: '/events/:id/kids/families', tag: 'Kids', summary: 'Find a family', access: 'staff', ...view, params: idParams, query: filters('q', 'sessionId'), map: asEvent, v1: kids.searchFamilies });
|
||||
v1Route({ method: 'post', path: '/events/:id/kids/families', tag: 'Kids', summary: 'Register a family at the desk', access: 'staff', ...view, status: 201, params: idParams, body: loose, map: asEvent, v1: kids.createFamilyAtDesk });
|
||||
v1Route({ method: 'get', path: '/events/:id/kids/families/qr/:token', tag: 'Kids', summary: 'The family for a QR code', access: 'staff', ...view, params: z.object({ id, token: z.string().min(1).max(200) }), map: asEvent, v1: kids.familyByQr });
|
||||
const familyParams = z.object({ id, familyId: id });
|
||||
v1Route({ method: 'get', path: '/events/:id/kids/families/:familyId', tag: 'Kids', summary: 'One family', access: 'staff', ...view, params: familyParams, map: asEvent, v1: kids.getFamily });
|
||||
v1Route({ method: 'post', path: '/events/:id/kids/families/:familyId/parents', tag: 'Kids', summary: 'Add a parent at the desk', access: 'staff', ...view, status: 201, params: familyParams, body: loose, map: asEvent, v1: kids.addParentAtDesk });
|
||||
v1Route({ method: 'post', path: '/events/:id/kids/families/:familyId/children', tag: 'Kids', summary: 'Add a child at the desk', access: 'staff', ...view, status: 201, params: familyParams, body: loose, map: asEvent, v1: kids.saveChildAtDesk });
|
||||
v1Route({ method: 'put', path: '/events/:id/kids/families/:familyId/children/:childId', tag: 'Kids', summary: 'Change a child at the desk', access: 'staff', ...view, params: z.object({ id, familyId: id, childId: id }), body: loose, map: asEvent, v1: kids.saveChildAtDesk });
|
||||
v1Route({ method: 'post', path: '/events/:id/kids/families/:familyId/adults', tag: 'Kids', summary: 'Add an adult at the desk', access: 'staff', ...view, status: 201, params: familyParams, body: loose, map: asEvent, v1: kids.addAdultAtDesk });
|
||||
v1Route({ method: 'post', path: '/events/:id/kids/check-in', tag: 'Kids', summary: 'Check children in', description: 'Answers the collection code and labels to print.', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, v1: kids.checkIn });
|
||||
v1Route({ method: 'post', path: '/events/:id/kids/check-out', tag: 'Kids', summary: 'Check children out', description: 'With the collection code; refused for someone not allowed to collect.', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, v1: kids.checkOut });
|
||||
v1Route({ method: 'get', path: '/events/:id/kids/code/:code', tag: 'Kids', summary: 'Who a collection code is for', access: 'staff', ...view, params: z.object({ id, code: z.string().min(1).max(40) }), query: filters('sessionId'), map: asEvent, v1: kids.lookupCode });
|
||||
v1Route({ method: 'post', path: '/events/:id/kids/duty', tag: 'Kids', summary: 'Say which room I\'m on', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, v1: kids.setDuty });
|
||||
v1Route({ method: 'get', path: '/events/:id/kids/offline', tag: 'Kids', summary: 'Everything the desk needs offline', access: 'staff', ...view, params: idParams, query: filters('deviceId', 'sessionId'), map: asEvent, v1: kids.offlineSnapshot });
|
||||
v1Route({ method: 'post', path: '/events/:id/kids/sync', tag: 'Kids', summary: 'Upload check-ins made offline', access: 'staff', ...view, params: idParams, body: loose, map: asEvent, v1: kids.syncOffline });
|
||||
const checkInScope = { campusScope: 'scopeEvent(kidsCheckIn)', middleware: [cs.scopeEvent({ resolve: 'kidsCheckIn' })] };
|
||||
v1Route({ method: 'post', path: '/kids/check-ins/:id/override', tag: 'Kids', summary: 'Let someone not on the list collect', description: 'Logged, with the reason.', access: 'staff', ...checkInScope, params: idParams, body: loose, v1: kids.overrideCheckOut });
|
||||
v1Route({ method: 'post', path: '/kids/check-ins/:id/page', tag: 'Kids', summary: 'Page a child\'s parent', access: 'staff', ...checkInScope, params: idParams, body: loose, v1: kids.pageParent });
|
||||
v1Route({ method: 'put', path: '/kids/check-ins/:id/room', tag: 'Kids', summary: 'Move a child to another room', access: 'staff', ...checkInScope, params: idParams, body: loose, v1: kids.moveRoom });
|
||||
v1Route({ method: 'post', path: '/kids/alerts/:id/resolve', tag: 'Kids', summary: 'Resolve an alert', access: 'staff', campusScope: 'scopeEvent(kidsAlert)', middleware: [cs.scopeEvent({ resolve: 'kidsAlert' })], params: idParams, body: loose, v1: kids.resolveAlert });
|
||||
|
||||
// ─── Volunteering: mine ──────────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/me/volunteering', tag: 'Volunteers', summary: 'Where I\'m serving', access: 'user', v1: vol.getMine });
|
||||
v1Route({ method: 'get', path: '/events/:id/volunteer-roles', tag: 'Volunteers', summary: 'Roles I can serve in at an event', access: 'user', params: idParams, map: asEvent, v1: vol.getEventRoles });
|
||||
v1Route({ method: 'post', path: '/volunteer-roles/:id/sign-up', tag: 'Volunteers', summary: 'Sign up to serve', access: 'user', status: 201, params: idParams, body: loose, v1: vol.signUp });
|
||||
v1Route({ method: 'delete', path: '/volunteer-slots/:id', tag: 'Volunteers', summary: 'Stop serving in a slot', access: 'user', params: idParams, v1: vol.withdraw });
|
||||
v1Route({ method: 'post', path: '/volunteer-slots/:id/swap', tag: 'Volunteers', summary: 'Ask someone to take my slot', access: 'user', params: idParams, body: loose, v1: vol.requestSwap });
|
||||
v1Route({ method: 'delete', path: '/volunteer-swaps/:id', tag: 'Volunteers', summary: 'Cancel a swap request', access: 'user', params: idParams, v1: vol.cancelSwap });
|
||||
v1Route({ method: 'post', path: '/volunteer-swaps/:id/accept', tag: 'Volunteers', summary: 'Take someone\'s slot', access: 'user', params: idParams, body: loose, v1: vol.acceptSwap });
|
||||
v1Route({ method: 'post', path: '/volunteering/check-in', tag: 'Volunteers', summary: 'Check myself in (the event\'s QR code)', access: 'user', body: loose, v1: vol.checkIn });
|
||||
|
||||
// ─── Volunteering: rosters ───────────────────────────────────────────────────
|
||||
|
||||
v1Route({ method: 'get', path: '/volunteering/events', tag: 'Volunteers', summary: 'Events with volunteer roles', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('all'), v1: vol.listEvents });
|
||||
v1Route({ method: 'get', path: '/volunteering/hours', tag: 'Volunteers', summary: 'Hours served', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('from', 'to', 'eventId'), v1: vol.hours });
|
||||
v1Route({ method: 'get', path: '/events/:id/volunteers', tag: 'Volunteers', summary: 'An event\'s roster', access: 'staff', ...view, params: idParams, query: filters('log'), map: asEvent, v1: vol.getRoster });
|
||||
v1Route({ method: 'get', path: '/events/:id/volunteers/qr', tag: 'Volunteers', summary: 'The self check-in QR code', access: 'staff', ...view, params: idParams, map: asEvent, v1: vol.getQr });
|
||||
v1Route({ method: 'get', path: '/events/:id/volunteers/settings', tag: 'Volunteers', summary: 'Volunteer set-up', access: 'supervisor', ...view, params: idParams, map: asEvent, v1: vol.getSettings });
|
||||
v1Route({ method: 'put', path: '/events/:id/volunteers/settings', tag: 'Volunteers', summary: 'Set up volunteering', access: 'supervisor', ...manage, params: idParams, body: loose, map: asEvent, v1: vol.saveSettings });
|
||||
v1Route({ method: 'post', path: '/events/:id/volunteer-roles', tag: 'Volunteers', summary: 'Add a role', access: 'supervisor', ...manage, status: 201, params: idParams, body: loose, map: asEvent, v1: vol.createRole });
|
||||
const roleManage = { campusScope: 'scopeEvent(servingRole, manage)', middleware: [cs.scopeEvent({ resolve: 'servingRole', need: 'manage' })] };
|
||||
v1Route({ method: 'patch', path: '/volunteer-roles/:id', tag: 'Volunteers', summary: 'Change a role', access: 'supervisor', ...roleManage, params: idParams, body: loose, v1: vol.updateRole });
|
||||
v1Route({ method: 'delete', path: '/volunteer-roles/:id', tag: 'Volunteers', summary: 'Delete a role', access: 'supervisor', ...roleManage, params: idParams, v1: vol.deleteRole });
|
||||
v1Route({ method: 'post', path: '/volunteer-roles/:id/assign', tag: 'Volunteers', summary: 'Put someone in a role', access: 'supervisor', campusScope: 'scopeEvent(servingRole)', middleware: [cs.scopeEvent({ resolve: 'servingRole' })], params: idParams, body: loose, v1: vol.assign });
|
||||
const slotScope = { campusScope: 'scopeEvent(volunteerSlot)', middleware: [cs.scopeEvent({ resolve: 'volunteerSlot' })] };
|
||||
v1Route({ method: 'post', path: '/volunteer-slots/:id/check-in', tag: 'Volunteers', summary: 'Check a volunteer in', access: 'staff', ...slotScope, params: idParams, body: loose, v1: vol.checkInManual });
|
||||
v1Route({ method: 'post', path: '/volunteer-slots/:id/remove', tag: 'Volunteers', summary: 'Take someone off a slot', access: 'supervisor', ...slotScope, params: idParams, body: loose, v1: vol.remove });
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-29, after **1.51.12** (and Hub 1.5.0). The plan itself is in
|
||||
Last updated: 2026-10-29, after **1.51.13** (and Hub 1.5.0). The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
@@ -17,6 +17,7 @@ encrypted settings unreadable or break existing installs.
|
||||
| 1.51.3 | **Express 5** (qs query parser and `req.body = {}` kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on `/api/v2` (`legacyErrorsToV2`, `reason`); v1 `Deprecation` + `successor-version` headers (`src/v2/deprecation.ts`) |
|
||||
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
|
||||
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
|
||||
| 1.51.13 | **Kids check-in and volunteers on v2** — `resources/kidsVolunteers.ts` |
|
||||
| 1.51.12 | **Tickets and attendance on v2** — `resources/attendance.ts`; frontend `apiV2Raw()` for string-path helpers |
|
||||
| 1.51.11 | **Money on v2** — `src/v2/viaV1.ts` (`v1Route`: a v2 route answered by the v1 handler); `resources/money.ts`; `/docs/v2` collapsed with a filter |
|
||||
| 1.51.10 | **Managing registrations on v2** — `src/services/registrationAdmin.ts` (the rest of `registrationController.js`); frontend `fetchAllRegistrations()`; money split out to 1.51.11 |
|
||||
@@ -72,7 +73,7 @@ encrypted settings unreadable or break existing installs.
|
||||
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
|
||||
a test server (both done 2026-10-28).
|
||||
|
||||
1. **1.51.13 – 1.51.16: every endpoint onto v2**, one area per release (roadmap build order, row
|
||||
1. **1.51.14 – 1.51.16: every endpoint onto v2**, one area per release (roadmap build order, row
|
||||
11b): services in TS, screens on `apiV2`, v1 routes into `SUCCESSORS`. 1.51.16 gives incoming
|
||||
webhooks and links in sent messages v2 URLs, with the v1 paths still answering (links redirect,
|
||||
webhooks handled by the same code). Then **2.0.0** — AI helpers and v1 removed.
|
||||
|
||||
+3
-2
@@ -43,7 +43,8 @@ foundations**, 1.51.1 **v2 registrations and payments**, 1.51.2 **v2 accounts an
|
||||
scanner on v2, v1 deprecation headers** shipped. 1.51.4 **security and dependency updates**, 1.51.5 **Next.js 16** and 1.51.6 **the complete command palette and
|
||||
ExcelTS** , 1.51.7 **setting events up on v2, pg-boss 11** and 1.51.8 **the rest of event set-up on v2** and 1.51.9
|
||||
**series, passes, deals and campuses on v2** 1.51.10 **managing registrations on v2**, 1.51.11 **money on v2** and 1.51.12 **tickets and
|
||||
attendance on v2** shipped. **Next: 1.51.13 – 1.51.16 (every endpoint onto v2), then 2.0.0.**
|
||||
attendance on v2** and 1.51.13 **kids and volunteers on v2** shipped. **Next: 1.51.14 – 1.51.16 (every
|
||||
endpoint onto v2), then 2.0.0.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -1012,7 +1013,7 @@ income justify the hardware:
|
||||
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
|
||||
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (Afrikaans removed from scope) | Polish before the big release |
|
||||
| 11 | 1.51.0 ✅, 1.51.1 ✅, 1.51.2 ✅, 1.51.3 ✅ | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 registrations and payments as TS services, typed frontend calls; 1.51.2 check-in and accounts; 1.51.3 Express 5, the scanner on v2, v1 deprecation headers, v2-shaped shared middleware | The breaking-change groundwork |
|
||||
| 11b | 1.51.4 ✅ – 1.51.12 ✅, 1.51.13 – 1.51.16 | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 managing registrations (the staff lists, status, at the door, bulk actions, items, coupons, attendees, attendee forms); 1.51.11 money, staff side (payments, cashup, provider transactions, waitlist, sponsorship, consent, form builder, feedback); 1.51.12 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.13 kids check-in and volunteer rosters; 1.51.14 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.15 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.16 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each puts its area's endpoints on v2 (1.51.1 – 1.51.10 by moving the logic into TS services; from 1.51.11 by `v1Route`, which runs the v1 handler behind a v2 route — moving those handlers into TS services is a 2.x clean-up that doesn't change the API), its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
|
||||
| 11b | 1.51.4 ✅ – 1.51.13 ✅, 1.51.14 – 1.51.16 | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 managing registrations (the staff lists, status, at the door, bulk actions, items, coupons, attendees, attendee forms); 1.51.11 money, staff side (payments, cashup, provider transactions, waitlist, sponsorship, consent, form builder, feedback); 1.51.12 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.13 kids check-in and volunteer rosters; 1.51.14 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.15 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.16 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each puts its area's endpoints on v2 (1.51.1 – 1.51.10 by moving the logic into TS services; from 1.51.11 by `v1Route`, which runs the v1 handler behind a v2 route — moving those handlers into TS services is a 2.x clean-up that doesn't change the API), its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
|
||||
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 removed (all of it is on v2 by then; the old webhook URLs and links already sent in messages keep forwarding to v2), migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
|
||||
@@ -263,6 +263,18 @@ test.describe("API v2", () => {
|
||||
expect((await transfer.json()).error.code).toMatch(/NOT_FOUND|VALIDATION_FAILED/);
|
||||
});
|
||||
|
||||
test("kids and volunteers: my family, where I serve, the staff lists", async ({ request }) => {
|
||||
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
|
||||
const user = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
for (const [path, headers] of [["/api/v2/me/family", user], ["/api/v2/me/volunteering", user], ["/api/v2/kids/events", staff], ["/api/v2/volunteering/events?all=1", staff]] as const) {
|
||||
const res = await request.get(`${API}${path}`, { headers });
|
||||
expect(res.status(), path).toBe(200);
|
||||
expect(await res.json(), path).toHaveProperty("data");
|
||||
}
|
||||
// Staff lists aren't for everyone.
|
||||
expect((await request.get(`${API}/api/v2/kids/events`, { headers: user })).status()).toBe(403);
|
||||
});
|
||||
|
||||
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
|
||||
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
expect((await request.get(`${API}/api/v2/openapi.json`, { headers })).status()).toBe(403);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.12",
|
||||
"version": "1.51.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.12",
|
||||
"version": "1.51.13",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.12",
|
||||
"version": "1.51.13",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
@@ -14,6 +14,7 @@ import { KidsEventPicker } from "@/components/kids/KidsEventPicker";
|
||||
import { RoomBoard } from "@/components/kids/RoomBoard";
|
||||
import { StickerPanel } from "@/components/kids/StickerPanel";
|
||||
import { ChildForm, type ChildInput } from "@/components/kids/ChildForm";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
import {
|
||||
FAMILY_QR_PREFIX, QR_PREFIX, deviceId, loadOffline, offlineCheckIn, saveOffline, stickerAlert, suggestRoom,
|
||||
type CheckInResult, type KidsBoard, type KidsFamily, type OfflineState,
|
||||
@@ -64,19 +65,18 @@ export default function KidsDeskPage() {
|
||||
const [collectedBy, setCollectedBy] = useState("");
|
||||
const [wrongCode, setWrongCode] = useState<string | null>(null);
|
||||
|
||||
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
|
||||
|
||||
const loadBoard = useCallback(async (log = false) => {
|
||||
if (!token || !eventId || working === "offline") return;
|
||||
try {
|
||||
const data = await apiFetch<KidsBoard>(`/api/kids/events/${eventId}/board${qs}${log ? `${qs ? "&" : "?"}log=1` : ""}`, { authToken: token });
|
||||
const data = await (apiV2Data("get", "/events/{id}/kids/board", { params: { id: eventId }, query: { sessionId: sessionId || undefined, log: log ? "1" : undefined }, authToken: token }) as Promise<KidsBoard>);
|
||||
setBoard(data);
|
||||
setBoardError(null);
|
||||
} catch (e) {
|
||||
setBoard(null);
|
||||
setBoardError(errorMessage(e) || "Could not load the desk");
|
||||
}
|
||||
}, [token, eventId, qs, working]);
|
||||
}, [token, eventId, sessionId, working]);
|
||||
|
||||
useEffect(() => {
|
||||
setBoard(null);
|
||||
@@ -145,7 +145,7 @@ export default function KidsDeskPage() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setResults(await apiFetch<KidsFamily[]>(`/api/kids/events/${eventId}/families?q=${encodeURIComponent(term)}`, { authToken: token }));
|
||||
setResults(await (apiV2Data("get", "/events/{id}/kids/families", { params: { id: eventId }, query: { q: term }, authToken: token }) as Promise<KidsFamily[]>));
|
||||
} catch (err) {
|
||||
fail(err, "Search failed");
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export default function KidsDeskPage() {
|
||||
if (!t.toUpperCase().startsWith(FAMILY_QR_PREFIX)) { setMsg({ type: "err", text: "That isn't a family or pickup QR code." }); return; }
|
||||
if (working === "offline") { setMsg({ type: "err", text: "Family QR codes need a connection — search by name or phone instead." }); return; }
|
||||
try {
|
||||
chooseFamily(await apiFetch<KidsFamily>(`/api/kids/events/${eventId}/families/qr/${encodeURIComponent(t.slice(FAMILY_QR_PREFIX.length))}`, { authToken: token }));
|
||||
chooseFamily(await (apiV2Data("get", "/events/{id}/kids/families/qr/{token}", { params: { id: eventId, token: t.slice(FAMILY_QR_PREFIX.length) }, authToken: token }) as Promise<KidsFamily>));
|
||||
} catch (err) {
|
||||
fail(err, "Family not found");
|
||||
}
|
||||
@@ -173,10 +173,11 @@ export default function KidsDeskPage() {
|
||||
if (!newFamily) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const f = await apiFetch<KidsFamily>(`/api/kids/events/${eventId}/families`, {
|
||||
method: "POST", authToken: token,
|
||||
const f = await (apiV2Data("post", "/events/{id}/kids/families", {
|
||||
params: { id: eventId },
|
||||
authToken: token,
|
||||
body: { name: newFamily.name, parent: { name: newFamily.parentName, phone: newFamily.phone, email: newFamily.email }, children: [child] },
|
||||
});
|
||||
}) as Promise<KidsFamily>);
|
||||
setNewFamily(null);
|
||||
chooseFamily(f);
|
||||
setMsg({ type: "ok", text: `${f.name} added.` });
|
||||
@@ -191,8 +192,8 @@ export default function KidsDeskPage() {
|
||||
if (!family) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiFetch(`/api/kids/events/${eventId}/families/${family.id}/children`, { method: "POST", authToken: token, body: child });
|
||||
chooseFamily(await apiFetch<KidsFamily>(`/api/kids/events/${eventId}/families/${family.id}`, { authToken: token }));
|
||||
await apiV2Data("post", "/events/{id}/kids/families/{familyId}/children", { params: { id: eventId, familyId: family.id }, authToken: token, body: child });
|
||||
chooseFamily(await (apiV2Data("get", "/events/{id}/kids/families/{familyId}", { params: { id: eventId, familyId: family.id }, authToken: token }) as Promise<KidsFamily>));
|
||||
setMsg({ type: "ok", text: `${child.firstName} added.` });
|
||||
} catch (err) {
|
||||
fail(err, "Could not add the child");
|
||||
@@ -225,9 +226,11 @@ export default function KidsDeskPage() {
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await apiFetch<CheckInResult>(`/api/kids/events/${eventId}/check-in`, {
|
||||
method: "POST", authToken: token, body: { familyId: family.id, sessionId: sessionId || undefined, children, sendToAdultId: sendTo || undefined },
|
||||
});
|
||||
const r = await (apiV2Data("post", "/events/{id}/kids/check-in", {
|
||||
params: { id: eventId },
|
||||
authToken: token,
|
||||
body: { familyId: family.id, sessionId: sessionId || undefined, children, sendToAdultId: sendTo || undefined },
|
||||
}) as Promise<CheckInResult>);
|
||||
setResult(r);
|
||||
loadBoard();
|
||||
} catch (err) {
|
||||
@@ -268,7 +271,7 @@ export default function KidsDeskPage() {
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
l = await apiFetch<CodeLookup>(`/api/kids/events/${eventId}/code/${encodeURIComponent(raw.trim())}${qs}`, { authToken: token });
|
||||
l = await (apiV2Data("get", "/events/{id}/kids/code/{code}", { params: { id: eventId, code: raw.trim() }, query: { sessionId: sessionId || undefined }, authToken: token }) as Promise<CodeLookup>);
|
||||
} catch (err) {
|
||||
fail(err, "Lookup failed");
|
||||
return;
|
||||
@@ -276,7 +279,7 @@ export default function KidsDeskPage() {
|
||||
if (!l.found) {
|
||||
// Record the wrong code (alerts the rooms) through the check-out call.
|
||||
try {
|
||||
await apiFetch(`/api/kids/events/${eventId}/check-out`, { method: "POST", authToken: token, body: { code: raw, collectedBy: "", sessionId: sessionId || undefined } });
|
||||
await apiV2Data("post", "/events/{id}/kids/check-out", { params: { id: eventId }, authToken: token, body: { code: raw, collectedBy: "", sessionId: sessionId || undefined } });
|
||||
} catch (err) {
|
||||
setWrongCode(errorMessage(err) || "Wrong code");
|
||||
}
|
||||
@@ -306,9 +309,11 @@ export default function KidsDeskPage() {
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await apiFetch<{ checkedOut: { name: string }[] }>(`/api/kids/events/${eventId}/check-out`, {
|
||||
method: "POST", authToken: token, body: { code: lookup.code, checkInIds: ids, collectedBy, sessionId: sessionId || undefined },
|
||||
});
|
||||
const r = await (apiV2Data("post", "/events/{id}/kids/check-out", {
|
||||
params: { id: eventId },
|
||||
authToken: token,
|
||||
body: { code: lookup.code, checkInIds: ids, collectedBy, sessionId: sessionId || undefined },
|
||||
}) as Promise<{ checkedOut: { name: string }[] }>);
|
||||
setMsg({ type: "ok", text: `Collected: ${r.checkedOut.map(c => c.name).join(", ")}.` });
|
||||
setLookup(null);
|
||||
setCode("");
|
||||
@@ -325,7 +330,7 @@ export default function KidsDeskPage() {
|
||||
const getReady = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const snapshot = await apiFetch<OfflineState["snapshot"]>(`/api/kids/events/${eventId}/offline?deviceId=${encodeURIComponent(deviceId())}${sessionId ? `&sessionId=${encodeURIComponent(sessionId)}` : ""}`, { authToken: token });
|
||||
const snapshot = await (apiV2Data("get", "/events/{id}/kids/offline", { params: { id: eventId }, query: { deviceId: deviceId(), sessionId: sessionId || undefined }, authToken: token }) as Promise<OfflineState["snapshot"]>);
|
||||
const prev = loadOffline(eventId);
|
||||
const sameOccasion = prev?.snapshot.occasion === snapshot.occasion;
|
||||
const state: OfflineState = {
|
||||
@@ -348,10 +353,11 @@ export default function KidsDeskPage() {
|
||||
if (!offline) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await apiFetch<{ checkedIn: number; checkedOut: number; conflicts: { clientId: string | null; message: string }[] }>(`/api/kids/events/${eventId}/sync`, {
|
||||
method: "POST", authToken: token,
|
||||
const r = await (apiV2Data("post", "/events/{id}/kids/sync", {
|
||||
params: { id: eventId },
|
||||
authToken: token,
|
||||
body: { deviceId: deviceId(), occasion: offline.snapshot.occasion, checkIns: offline.checkIns, checkOuts: offline.checkOuts },
|
||||
});
|
||||
}) as Promise<{ checkedIn: number; checkedOut: number; conflicts: { clientId: string | null; message: string }[] }>);
|
||||
const next: OfflineState = { ...offline, checkIns: [], checkOuts: [] };
|
||||
saveOffline(eventId, next);
|
||||
setOffline(next);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
@@ -11,6 +10,7 @@ import { BackLink } from "@/components/shared/BackLink";
|
||||
import { KidsEventPicker } from "@/components/kids/KidsEventPicker";
|
||||
import { RoomBoard, RoomStatusBadges } from "@/components/kids/RoomBoard";
|
||||
import type { KidsBoard } from "@/lib/kids";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const ROOM_KEY = "hope_events_kids_room";
|
||||
|
||||
@@ -26,17 +26,16 @@ export default function KidsRoomsPage() {
|
||||
|
||||
useEffect(() => { try { setRoomId(localStorage.getItem(ROOM_KEY) || ""); } catch { /* ignore */ } }, []);
|
||||
|
||||
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
|
||||
const load = useCallback(async (log = false) => {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
setBoard(await apiFetch<KidsBoard>(`/api/kids/events/${eventId}/board${qs}${log ? `${qs ? "&" : "?"}log=1` : ""}`, { authToken: token }));
|
||||
setBoard(await (apiV2Data("get", "/events/{id}/kids/board", { params: { id: eventId }, query: { sessionId: sessionId || undefined, log: log ? "1" : undefined }, authToken: token }) as Promise<KidsBoard>));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setBoard(null);
|
||||
setError(errorMessage(e) || "Could not load the rooms");
|
||||
}
|
||||
}, [token, eventId, qs]);
|
||||
}, [token, eventId, sessionId]);
|
||||
|
||||
useEffect(() => { load(true); }, [load]);
|
||||
useVisiblePolling(() => { load(); }, 5000, !!token && !!eventId);
|
||||
@@ -49,7 +48,7 @@ export default function KidsRoomsPage() {
|
||||
const onDuty = !!room && !!user && room.leaderIds.includes(user.id);
|
||||
const duty = async (on: boolean) => {
|
||||
try {
|
||||
await apiFetch(`/api/kids/events/${eventId}/duty`, { method: "POST", authToken: token, body: { roomId, on, sessionId: sessionId || undefined } });
|
||||
await apiV2Data("post", "/events/{id}/kids/duty", { params: { id: eventId }, authToken: token, body: { roomId, on, sessionId: sessionId || undefined } });
|
||||
load();
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not change duty" });
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
@@ -11,6 +10,7 @@ import { CheckInQr } from "@/components/volunteers/CheckInQr";
|
||||
import { HandHeart } from "lucide-react";
|
||||
import type { RosterEvent } from "@/lib/volunteers";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
/** Staff → Volunteers: the roster on the day — who's here, ticking people in, and the check-in QR code. */
|
||||
export default function StaffVolunteersPage() {
|
||||
@@ -22,7 +22,7 @@ export default function StaffVolunteersPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<RosterEvent[]>("/api/volunteers/manage/events", { authToken: token })
|
||||
(apiV2Data("get", "/volunteering/events", { authToken: token }) as Promise<RosterEvent[]>)
|
||||
.then(list => {
|
||||
setEvents(list);
|
||||
const now = Date.now();
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Baby, Plus, Trash2 } from "lucide-react";
|
||||
@@ -35,13 +34,13 @@ export default function KidsSetupPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<SetupEvent[]>("/api/kids/setup-events", { authToken: token }).then(setEvents).catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load events" }));
|
||||
(apiV2Data("get", "/kids/setup-events", { authToken: token }) as Promise<SetupEvent[]>).then(setEvents).catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load events" }));
|
||||
}, [token, setMsg]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
const data = await apiFetch<Settings>(`/api/kids/events/${eventId}/settings`, { authToken: token });
|
||||
const data = await (apiV2Data("get", "/events/{id}/kids/settings", { params: { id: eventId }, authToken: token }) as Promise<Settings>);
|
||||
setS(data);
|
||||
const set = data.sets.find(x => x.id === data.event.kidsRoomSetId);
|
||||
setDraft(Object.fromEntries((set?.groups || []).map(g => [g.id, { name: g.name, minAge: g.minAge?.toString() ?? "", maxAge: g.maxAge?.toString() ?? "", capacity: g.capacity?.toString() ?? "", ratio: g.ratio?.toString() ?? "" }])));
|
||||
@@ -65,7 +64,7 @@ export default function KidsSetupPage() {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const save = (body: Record<string, unknown>, ok = "Saved.") => run(() => apiFetch(`/api/kids/events/${eventId}/settings`, { method: "PUT", authToken: token, body }), ok);
|
||||
const save = (body: Record<string, unknown>, ok = "Saved.") => run(() => apiV2Data("put", "/events/{id}/kids/settings", { params: { id: eventId }, authToken: token, body }), ok);
|
||||
|
||||
const rooms = s?.sets.find(x => x.id === s.event.kidsRoomSetId)?.groups || [];
|
||||
const roomBody = (d: { name: string; minAge: string; maxAge: string; capacity: string; ratio: string }) => ({ name: d.name, minAge: num(d.minAge), maxAge: num(d.maxAge), capacity: num(d.capacity), ratio: num(d.ratio) });
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
@@ -11,6 +10,7 @@ import { CheckInQr } from "@/components/volunteers/CheckInQr";
|
||||
import { HandHeart } from "lucide-react";
|
||||
import { hoursText, type HoursRow, type RosterEvent } from "@/lib/volunteers";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Settings = {
|
||||
event: { id: string; title: string; volunteerOptionId: string | null };
|
||||
@@ -39,7 +39,7 @@ export default function VolunteerRostersPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<RosterEvent[]>("/api/volunteers/manage/events?all=1", { authToken: token })
|
||||
(apiV2Data("get", "/volunteering/events", { query: { all: "1" }, authToken: token }) as Promise<RosterEvent[]>)
|
||||
.then(list => {
|
||||
setEvents(list);
|
||||
const wanted = new URLSearchParams(window.location.search).get("event");
|
||||
@@ -51,7 +51,7 @@ export default function VolunteerRostersPage() {
|
||||
const loadSettings = useCallback(async () => {
|
||||
if (!token || !eventId) { setSettings(null); return; }
|
||||
try {
|
||||
setSettings(await apiFetch<Settings>(`/api/volunteers/manage/events/${eventId}/settings`, { authToken: token }));
|
||||
setSettings(await (apiV2Data("get", "/events/{id}/volunteers/settings", { params: { id: eventId }, authToken: token }) as Promise<Settings>));
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not load the settings" });
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export default function VolunteerRostersPage() {
|
||||
useEffect(() => {
|
||||
if (!token || tab !== "hours") return;
|
||||
setHours(null);
|
||||
apiFetch<HoursRow[]>(`/api/volunteers/manage/hours${!hoursAll && eventId ? `?eventId=${eventId}` : ""}`, { authToken: token })
|
||||
(apiV2Data("get", "/volunteering/hours", { query: { eventId: !hoursAll && eventId ? eventId : undefined }, authToken: token }) as Promise<HoursRow[]>)
|
||||
.then(setHours)
|
||||
.catch(e => setMsg({ type: "err", text: errorMessage(e) || "Could not load hours" }));
|
||||
}, [token, tab, hoursAll, eventId, setMsg]);
|
||||
@@ -69,7 +69,7 @@ export default function VolunteerRostersPage() {
|
||||
const saveTicket = async (optionId: string | null, ok: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setSettings(await apiFetch<Settings>(`/api/volunteers/manage/events/${eventId}/settings`, { method: "PUT", authToken: token, body: { optionId } }));
|
||||
setSettings(await (apiV2Data("put", "/events/{id}/volunteers/settings", { params: { id: eventId }, authToken: token, body: { optionId } }) as Promise<Settings>));
|
||||
setMsg({ type: "ok", text: ok });
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not save" });
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
@@ -11,6 +10,7 @@ import { BackLink } from "@/components/shared/BackLink";
|
||||
import { QrImage } from "@/components/shared/QrImage";
|
||||
import { ChildForm, type ChildInput } from "@/components/kids/ChildForm";
|
||||
import { FAMILY_QR_PREFIX, type KidsAdult, type KidsChild, type KidsFamily } from "@/lib/kids";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type ActiveCode = { code: string; qr: string; event: { id: string; title: string }; children: { name: string; room: string | null; checkedInAt: string }[] };
|
||||
type Mine = { family: KidsFamily | null; qrToken: string | null; codes: ActiveCode[]; pages: { id: string; message: string; createdAt: string }[] };
|
||||
@@ -31,7 +31,7 @@ export default function MyKidsPage() {
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setData(await apiFetch<Mine>("/api/kids/me", { authToken: token }));
|
||||
setData(await (apiV2Data("get", "/me/family", { authToken: token }) as Promise<Mine>));
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not load your family" });
|
||||
}
|
||||
@@ -56,22 +56,22 @@ export default function MyKidsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const startFamily = () => call(() => apiFetch("/api/kids/me", { method: "POST", authToken: token, body: { name: familyName || `${user?.name?.split(" ").slice(-1)[0] || "My"} family` } }), "Family added — now add your children.");
|
||||
const startFamily = () => call(() => apiV2Data("post", "/me/family", { authToken: token, body: { name: familyName || `${user?.name?.split(" ").slice(-1)[0] || "My"} family` } }), "Family added — now add your children.");
|
||||
const saveChild = (child: KidsChild | null, input: ChildInput) => call(
|
||||
() => apiFetch(child ? `/api/kids/me/children/${child.id}` : "/api/kids/me/children", { method: child ? "PUT" : "POST", authToken: token, body: input }),
|
||||
() => (child ? apiV2Data("put", "/me/family/children/{id}", { params: { id: child.id }, authToken: token, body: input }) : apiV2Data("post", "/me/family/children", { authToken: token, body: input })),
|
||||
child ? "Saved." : "Child added.",
|
||||
).then(ok => { if (ok) setEditing(null); });
|
||||
const removeChild = (child: KidsChild) => {
|
||||
if (!confirm(`Remove ${child.firstName}? Their details are deleted.`)) return;
|
||||
call(() => apiFetch(`/api/kids/me/children/${child.id}`, { method: "DELETE", authToken: token }), "Removed.");
|
||||
call(() => apiV2Data("delete", "/me/family/children/{id}", { params: { id: child.id }, authToken: token }), "Removed.");
|
||||
};
|
||||
const saveAdult = () => adultForm && call(
|
||||
() => apiFetch(adultForm.id ? `/api/kids/me/adults/${adultForm.id}` : "/api/kids/me/adults", { method: adultForm.id ? "PUT" : "POST", authToken: token, body: adultForm }),
|
||||
() => (adultForm.id ? apiV2Data("put", "/me/family/adults/{id}", { params: { id: adultForm.id }, authToken: token, body: adultForm }) : apiV2Data("post", "/me/family/adults", { authToken: token, body: adultForm })),
|
||||
"Saved.",
|
||||
).then(ok => { if (ok) setAdultForm(null); });
|
||||
const removeAdult = (a: KidsAdult) => {
|
||||
if (!confirm(`Remove ${a.name} from your authorised adults?`)) return;
|
||||
call(() => apiFetch(`/api/kids/me/adults/${a.id}`, { method: "DELETE", authToken: token }), "Removed.");
|
||||
call(() => apiV2Data("delete", "/me/family/adults/{id}", { params: { id: a.id }, authToken: token }), "Removed.");
|
||||
};
|
||||
const share = async (c: ActiveCode) => {
|
||||
const text = `Pickup code for ${c.children.map(k => k.name.split(" ")[0]).join(", ")} at ${c.event.title}: ${c.code}`;
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { CheckCircle2, HandHeart, QrCode, Repeat } from "lucide-react";
|
||||
import type { CoverRequest, MySlot, Roster, ServingEvent } from "@/lib/volunteers";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Mine = { slots: MySlot[]; cover: CoverRequest[]; events: ServingEvent[] };
|
||||
type Msg = { type: "ok" | "err"; text: string } | null;
|
||||
@@ -27,7 +27,7 @@ export default function ServingPage() {
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setData(await apiFetch<Mine>("/api/volunteers/me", { authToken: token }));
|
||||
setData(await (apiV2Data("get", "/me/volunteering", { authToken: token }) as Promise<Mine>));
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not load your shifts" });
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export default function ServingPage() {
|
||||
const loadRoster = useCallback(async (id: string) => {
|
||||
if (!token || !id) { setRoster(null); return; }
|
||||
try {
|
||||
setRoster(await apiFetch<Roster>(`/api/volunteers/events/${id}`, { authToken: token }));
|
||||
setRoster(await (apiV2Data("get", "/events/{id}/volunteer-roles", { params: { id: id }, authToken: token }) as Promise<Roster>));
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not load the roles" });
|
||||
}
|
||||
@@ -65,10 +65,10 @@ export default function ServingPage() {
|
||||
const askCover = (s: MySlot) => {
|
||||
const note = prompt(`Ask the other volunteers to take over ${s.role.name}? The first to accept takes your place. Add a note (optional):`, "");
|
||||
if (note === null) return;
|
||||
call(() => apiFetch(`/api/volunteers/slots/${s.slotId}/swap`, { method: "POST", authToken: token, body: { note } }), "Cover requested — you stay on the roster until someone takes over.");
|
||||
call(() => apiV2Data("post", "/volunteer-slots/{id}/swap", { params: { id: s.slotId }, authToken: token, body: { note } }), "Cover requested — you stay on the roster until someone takes over.");
|
||||
};
|
||||
const withdraw = (s: MySlot) => confirm(`Withdraw from ${s.role.name} at ${s.event.title}? The organisers will be told.`)
|
||||
&& call(() => apiFetch(`/api/volunteers/slots/${s.slotId}`, { method: "DELETE", authToken: token }), "You're off the roster for this shift.");
|
||||
&& call(() => apiV2Data("delete", "/volunteer-slots/{id}", { params: { id: s.slotId }, authToken: token }), "You're off the roster for this shift.");
|
||||
|
||||
const upcoming = (data?.slots || []).filter(s => !s.past);
|
||||
const past = (data?.slots || []).filter(s => s.past);
|
||||
@@ -109,7 +109,7 @@ export default function ServingPage() {
|
||||
{!s.checkedInAt && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{s.swapId
|
||||
? <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${s.swapId}`, { method: "DELETE", authToken: token }), "Cover request cancelled.")} className={btn}>I can make it after all</button>
|
||||
? <button disabled={busy} onClick={() => call(() => apiV2Data("delete", "/volunteer-swaps/{id}", { params: { id: s.swapId! }, authToken: token }), "Cover request cancelled.")} className={btn}>I can make it after all</button>
|
||||
: <button disabled={busy} onClick={() => askCover(s)} className={`${btn} inline-flex items-center gap-1`}><Repeat className="w-4 h-4" /> Can't make it</button>}
|
||||
<button disabled={busy} onClick={() => withdraw(s)} className={`${btn} text-gray-600`}>Withdraw</button>
|
||||
</div>
|
||||
@@ -137,7 +137,7 @@ export default function ServingPage() {
|
||||
<div className="text-sm text-gray-700">{c.role.sessionLabel ? `${c.role.sessionLabel} · ` : ""}{c.shift.text}</div>
|
||||
<div className="text-xs text-gray-600">{c.from || "A volunteer"} can't make it{c.note ? `: “${c.note}”` : ""}</div>
|
||||
</div>
|
||||
<button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${c.swapId}/accept`, { method: "POST", authToken: token }), `You've taken over ${c.role.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over</button>
|
||||
<button disabled={busy} onClick={() => call(() => apiV2Data("post", "/volunteer-swaps/{id}/accept", { params: { id: c.swapId }, authToken: token, body: {} }), `You've taken over ${c.role.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
@@ -170,10 +170,10 @@ export default function ServingPage() {
|
||||
{r.mine
|
||||
? <span className="inline-flex items-center gap-1 text-sm text-green-700"><CheckCircle2 className="w-4 h-4" /> You're serving</span>
|
||||
: r.coverNeeded.length > 0
|
||||
? <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/swaps/${r.coverNeeded[0].swapId}/accept`, { method: "POST", authToken: token }), `You've taken over ${r.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over a place</button>
|
||||
? <button disabled={busy} onClick={() => call(() => apiV2Data("post", "/volunteer-swaps/{id}/accept", { params: { id: r.coverNeeded[0].swapId }, authToken: token, body: {} }), `You've taken over ${r.name} — thank you!`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Take over a place</button>
|
||||
: r.full
|
||||
? <span className="text-sm text-gray-500">Full</span>
|
||||
: <button disabled={busy} onClick={() => call(() => apiFetch(`/api/volunteers/roles/${r.id}/sign-up`, { method: "POST", authToken: token }), `Thank you — you're on the roster for ${r.name}.`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Sign up</button>}
|
||||
: <button disabled={busy} onClick={() => call(() => apiV2Data("post", "/volunteer-roles/{id}/sign-up", { params: { id: r.id }, authToken: token, body: {} }), `Thank you — you're on the roster for ${r.name}.`)} className="px-3 py-1.5 rounded-lg text-sm bg-brand-600 text-white disabled:opacity-50">Sign up</button>}
|
||||
</div>
|
||||
))}
|
||||
{roster.roles.every(r => r.past) && <p className="text-sm text-gray-500">All shifts at this event are over.</p>}
|
||||
|
||||
@@ -6,9 +6,9 @@ import { useSearchParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { CheckCircle2, HandHeart } from "lucide-react";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Result = { event: { id: string; title: string }; checkedIn: { role: string; kidsRoom: string | null }[]; already: string[] };
|
||||
|
||||
@@ -23,7 +23,7 @@ function CheckIn() {
|
||||
useEffect(() => {
|
||||
if (!code || loading || !token || sent.current) return;
|
||||
sent.current = true;
|
||||
apiFetch<Result>("/api/volunteers/check-in", { method: "POST", authToken: token, body: { qr: code } })
|
||||
(apiV2Data("post", "/volunteering/check-in", { authToken: token, body: { qr: code } }) as Promise<Result>)
|
||||
.then(setResult)
|
||||
.catch(e => setError(errorMessage(e) || "Couldn't check you in."));
|
||||
}, [code, loading, token]);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import type { KidsEvent } from "@/lib/kids";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Props = {
|
||||
token: string | null;
|
||||
@@ -21,7 +21,7 @@ export function KidsEventPicker({ token, eventId, sessionId, onChange }: Props)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<KidsEvent[]>("/api/kids/events", { authToken: token })
|
||||
(apiV2Data("get", "/kids/events", { authToken: token }) as Promise<KidsEvent[]>)
|
||||
.then(list => {
|
||||
setEvents(list);
|
||||
if (eventId || !list.length) return;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { AlertTriangle, BellRing, HeartPulse, LogOut, ShieldAlert, Users } from "lucide-react";
|
||||
import { ageRange, type KidsBoard, type KidsCheckIn, type KidsRoom } from "@/lib/kids";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Props = {
|
||||
board: KidsBoard;
|
||||
@@ -52,17 +52,17 @@ export function RoomBoard({ board, token, roomId, onChanged, onMessage }: Props)
|
||||
const page = (c: KidsCheckIn) => {
|
||||
const extra = prompt(`Message the parents of ${c.firstName}? They'll be asked to come to the room. Add a note (optional):`, "");
|
||||
if (extra === null) return;
|
||||
act(c.id, () => apiFetch(`/api/kids/check-ins/${c.id}/page`, { method: "POST", authToken: token, body: { message: extra } }), `Message sent to ${c.firstName}'s parents.`);
|
||||
act(c.id, () => apiV2Data("post", "/kids/check-ins/{id}/page", { params: { id: c.id }, authToken: token, body: { message: extra } }), `Message sent to ${c.firstName}'s parents.`);
|
||||
};
|
||||
const override = (c: KidsCheckIn) => {
|
||||
const who = prompt(`Who is collecting ${c.firstName}?`, c.family?.parents[0]?.name || "");
|
||||
if (!who) return;
|
||||
const reason = prompt("Why without the pickup code? (recorded in the audit log)", "");
|
||||
if (!reason) return;
|
||||
act(c.id, () => apiFetch(`/api/kids/check-ins/${c.id}/override`, { method: "POST", authToken: token, body: { collectedBy: who, reason } }), `${c.firstName} checked out.`);
|
||||
act(c.id, () => apiV2Data("post", "/kids/check-ins/{id}/override", { params: { id: c.id }, authToken: token, body: { collectedBy: who, reason } }), `${c.firstName} checked out.`);
|
||||
};
|
||||
const move = (c: KidsCheckIn, to: string) => act(c.id, () => apiFetch(`/api/kids/check-ins/${c.id}/room`, { method: "PUT", authToken: token, body: { roomId: to } }), `${c.firstName} moved.`);
|
||||
const resolve = (id: string) => act(id, () => apiFetch(`/api/kids/alerts/${id}/resolve`, { method: "POST", authToken: token }), "Alert cleared.");
|
||||
const move = (c: KidsCheckIn, to: string) => act(c.id, () => apiV2Data("put", "/kids/check-ins/{id}/room", { params: { id: c.id }, authToken: token, body: { roomId: to } }), `${c.firstName} moved.`);
|
||||
const resolve = (id: string) => act(id, () => apiV2Data("post", "/kids/alerts/{id}/resolve", { params: { id: id }, authToken: token, body: {} }), "Alert cleared.");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { QrImage } from "@/components/shared/QrImage";
|
||||
import { Printer } from "lucide-react";
|
||||
import { checkInUrl } from "@/lib/volunteers";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
/** The event's volunteer check-in QR code, to show on a screen or print and put up at the venue. */
|
||||
export function CheckInQr({ token, eventId, title }: { token: string | null; eventId: string; title: string }) {
|
||||
@@ -15,7 +15,7 @@ export function CheckInQr({ token, eventId, title }: { token: string | null; eve
|
||||
useEffect(() => {
|
||||
if (!token || !eventId) return;
|
||||
setQr(null);
|
||||
apiFetch<{ qr: string }>(`/api/volunteers/manage/events/${eventId}/qr`, { authToken: token })
|
||||
(apiV2Data("get", "/events/{id}/volunteers/qr", { params: { id: eventId }, authToken: token }) as Promise<{ qr: string }>)
|
||||
.then(r => setQr(r.qr))
|
||||
.catch(e => setError(errorMessage(e) || "Could not load the QR code"));
|
||||
}, [token, eventId]);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { errorMessage } from "@/lib/utils";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import { CheckCircle2, Pencil, Plus, Printer, Trash2, UserPlus, X } from "lucide-react";
|
||||
import { fromLocalInput, toLocalInput, type Roster, type ServingRole } from "@/lib/volunteers";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Msg = { type: "ok" | "err"; text: string };
|
||||
type Props = {
|
||||
@@ -40,7 +41,7 @@ export function RosterSheet({ token, eventId, canEdit, kidsRooms = [], onMessage
|
||||
const load = useCallback(async (log = false) => {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
setRoster(await apiFetch<Roster>(`/api/volunteers/manage/events/${eventId}${log ? "?log=1" : ""}`, { authToken: token }));
|
||||
setRoster(await (apiV2Data("get", "/events/{id}/volunteers", { params: { id: eventId }, query: { log: log ? "1" : undefined }, authToken: token }) as Promise<Roster>));
|
||||
} catch (e) {
|
||||
onMessage({ type: "err", text: errorMessage(e) || "Could not load the roster" });
|
||||
}
|
||||
@@ -71,12 +72,12 @@ export function RosterSheet({ token, eventId, canEdit, kidsRooms = [], onMessage
|
||||
});
|
||||
const saveRole = () => run(
|
||||
() => editing === "new"
|
||||
? apiFetch(`/api/volunteers/manage/events/${eventId}/roles`, { method: "POST", authToken: token, body: body(draft) })
|
||||
: apiFetch(`/api/volunteers/manage/roles/${editing}`, { method: "PUT", authToken: token, body: body(draft) }),
|
||||
? apiV2Data("post", "/events/{id}/volunteer-roles", { params: { id: eventId }, authToken: token, body: body(draft) })
|
||||
: apiV2Data("patch", "/volunteer-roles/{id}", { params: { id: editing! }, authToken: token, body: body(draft) }),
|
||||
editing === "new" ? "Role added." : "Role saved.",
|
||||
).then(ok => { if (ok) { setEditing(null); setDraft(EMPTY); } });
|
||||
const deleteRole = (r: ServingRole) => confirm(`Delete ${r.name}?${r.filled ? ` The ${r.filled} volunteer${r.filled === 1 ? "" : "s"} on it will be told.` : ""}`)
|
||||
&& run(() => apiFetch(`/api/volunteers/manage/roles/${r.id}`, { method: "DELETE", authToken: token }), "Role deleted.");
|
||||
&& run(() => apiV2Data("delete", "/volunteer-roles/{id}", { params: { id: r.id }, authToken: token }), "Role deleted.");
|
||||
|
||||
const search = async () => {
|
||||
if (!token || query.trim().length < 2) return;
|
||||
@@ -88,7 +89,7 @@ export function RosterSheet({ token, eventId, canEdit, kidsRooms = [], onMessage
|
||||
}
|
||||
};
|
||||
const assign = (roleId: string, u: { id: string; name: string }) => run(
|
||||
() => apiFetch(`/api/volunteers/manage/roles/${roleId}/assign`, { method: "POST", authToken: token, body: { userId: u.id } }),
|
||||
() => apiV2Data("post", "/volunteer-roles/{id}/assign", { params: { id: roleId }, authToken: token, body: { userId: u.id } }),
|
||||
`${u.name} is on the roster.`,
|
||||
).then(ok => { if (ok) { setAssignFor(null); setQuery(""); setFound([]); } });
|
||||
|
||||
@@ -196,9 +197,9 @@ export function RosterSheet({ token, eventId, canEdit, kidsRooms = [], onMessage
|
||||
: <span className="text-xs text-gray-400 print:inline hidden">☐</span>}
|
||||
<span className="print:hidden flex gap-1">
|
||||
{vl.checkedInAt
|
||||
? <button disabled={busy} onClick={() => run(() => apiFetch(`/api/volunteers/manage/slots/${vl.slotId}/check-in`, { method: "POST", authToken: token, body: { undo: true } }), "Check-in undone.")} className="text-xs px-2 py-1 rounded-lg border">Undo</button>
|
||||
: <button disabled={busy} onClick={() => run(() => apiFetch(`/api/volunteers/manage/slots/${vl.slotId}/check-in`, { method: "POST", authToken: token }), `${vl.name} checked in.`)} className="text-xs px-2 py-1 rounded-lg border">Check in</button>}
|
||||
{canEdit && !vl.checkedInAt && <button aria-label={`Take ${vl.name} off ${r.name}`} disabled={busy} onClick={() => confirm(`Take ${vl.name} off ${r.name}? They'll be told.`) && run(() => apiFetch(`/api/volunteers/manage/slots/${vl.slotId}`, { method: "DELETE", authToken: token }), `${vl.name} is off the roster.`)} className="text-xs p-1 rounded-lg border"><X className="w-3.5 h-3.5" /></button>}
|
||||
? <button disabled={busy} onClick={() => run(() => apiV2Data("post", "/volunteer-slots/{id}/check-in", { params: { id: vl.slotId }, authToken: token, body: { undo: true } }), "Check-in undone.")} className="text-xs px-2 py-1 rounded-lg border">Undo</button>
|
||||
: <button disabled={busy} onClick={() => run(() => apiV2Data("post", "/volunteer-slots/{id}/check-in", { params: { id: vl.slotId }, authToken: token, body: {} }), `${vl.name} checked in.`)} className="text-xs px-2 py-1 rounded-lg border">Check in</button>}
|
||||
{canEdit && !vl.checkedInAt && <button aria-label={`Take ${vl.name} off ${r.name}`} disabled={busy} onClick={() => confirm(`Take ${vl.name} off ${r.name}? They'll be told.`) && run(() => apiV2Data("post", "/volunteer-slots/{id}/remove", { params: { id: vl.slotId }, authToken: token, body: {} }), `${vl.name} is off the roster.`)} className="text-xs p-1 rounded-lg border"><X className="w-3.5 h-3.5" /></button>}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+3990
File diff suppressed because it is too large
Load Diff
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.12",
|
||||
"version": "1.51.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.51.12",
|
||||
"version": "1.51.13",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.12",
|
||||
"version": "1.51.13",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user