Compare commits

...
Author SHA1 Message Date
joshuaandClaude Opus 5.5 51992814d2 Deploy: stop first when files in the install were changed by hand (1.52.2)
A setup wizard run on one server added files to backend/src that the
backend build picked up, so the deploy failed part-way and rolled back.
deploy.sh now lists files git reports as changed or added (ignored files
and the PM2 config don't count) and stops before backing up or checking
out. --check runs only that; ALLOW_LOCAL_CHANGES=1 deploys anyway; the
script also runs from a copy outside the install (the folder you're in).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:03:12 +02:00
joshua 88d9276960 Merge branch 'docs/openapi-complete' 2026-11-02 13:27:05 +02:00
joshuaandClaude Opus 5.5 1ae4c47001 Docs: every v2 route documents its body, answer and parameters (1.52.1)
Most routes answered by a v1 handler showed an empty body and
`data: unknown` in /docs/v2. Each now documents what its handler reads
and answers, and every path and query parameter is described.

- `documented: { body?, response? }` on v1Route/defineRoute, with shared
  shapes in src/v2/docs.ts. Documentation only: not enforced, so nothing
  that works can start failing; outside production a body or answer that
  doesn't fit is logged ("doesn't match its documented schema").
- QUERY_HELP / PATH_HELP describe common parameters; empty bodies say
  "No body needed".
- Declared the funnel's eventIds and webhook deliveries' status/before.
- tests/v2Docs.test.ts keeps the document complete; frontend types
  regenerated, three call sites' types tightened (no payload change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:26:15 +02:00
joshua 3693d5f2c8 Merge branch 'fix/door-tickets-corrections' 2026-11-02 12:24:00 +02:00
29 changed files with 34804 additions and 1038 deletions
+32
View File
@@ -7,6 +7,38 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.52.2] - 2026-11-02
### Changed
- **`scripts/deploy.sh` stops before touching anything when files in the install folder were
changed or added by hand**, and lists them. A setup wizard run on one server had added files to
`backend/src` that the backend build picked up, so the deploy failed halfway and rolled back.
Ignored files (`.env`, uploads, backups, `ecosystem.config.js`) never count.
`ALLOW_LOCAL_CHANGES=1` deploys anyway.
- `bash scripts/deploy.sh --check` runs only that check (no backup, fetch, build or reload). The
script can also be run from a copy outside the install (e.g. `/tmp/deploy.sh`, from the folder
you're in), so an older install can check before updating — see DEPLOYMENT.md, "Updating".
## [1.52.1] - 2026-11-02
### Changed
- **The API documentation (`/docs/v2`, `GET /api/v2/openapi.json`) now describes every route in
full.** Before, most routes answered by a v1 handler showed an empty body and `data: unknown`.
Each one now documents the body it reads and what it answers, from the handler's actual code, and
every path and query parameter has a description. Actions that take no input say "No body
needed".
- How: `documented: { body?, response? }` on `v1Route` (and `defineRoute`), with shared shapes in
`src/v2/docs.ts`. These are documentation only — requests and answers aren't checked against
them, so nothing an app already does can start failing — but outside production a body or
answer that doesn't fit is logged (`[api/v2] … doesn't match its documented schema`).
`tests/v2Docs.test.ts` keeps the document complete.
- The funnel report's `eventIds` and the webhook deliveries' `status`/`before` filters are now
declared (they worked, but weren't in the document or the typed frontend calls).
- The frontend's generated API types are richer; a few calls had their types tightened to match
(ticket transfer, breach register, kids check-out). Nothing changes in what they send.
## [1.52.0] - 2026-11-02 — urgent
### Fixed
+18
View File
@@ -311,6 +311,24 @@ and goes back to the previous version if anything fails first. `backend/.env`,
`frontend/.env.production`, `ecosystem.config.js` and the nginx/frpc configs are not in git, so
updates keep your changes from this guide.
Since 1.52.2 it first checks for files in the install folder that were changed or added by hand
(e.g. by a setup wizard run on the server) and stops, listing them, before it backs anything up
or checks anything out: they would be built into the release, or block the checkout. Move added
files out of the folder and put changed ones back with `git checkout -- <file>`, or deploy anyway
with `ALLOW_LOCAL_CHANGES=1`. To check without deploying:
```bash
sudo -u hopeevents -H bash scripts/deploy.sh --check
```
An install still on an older release can run the new check without updating anything — fetch the
script on its own and run it from the install folder:
```bash
git fetch -q origin && git show origin/main:scripts/deploy.sh > /tmp/deploy.sh
bash /tmp/deploy.sh --check
```
### Upgrading Node
Node 20 stopped getting security fixes in April 2026; Cross Events runs on Node 24 LTS (supported
+11
View File
@@ -12,6 +12,17 @@ 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.52.2
- **Safer updates.** If files on your server were changed by hand, an update now stops straight
away and says which files, instead of failing part-way. Nothing else changes.
## 1.52.1
- **Better documentation for developers.** If someone builds on your Cross Events site (for example
an integration or a website plug-in), the API reference now explains every part of it in full.
Nothing changes in how the site works.
## 1.52.0 — urgent
- **Paying at the door always gives people their tickets now.** On events with a required
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.52.0",
"version": "1.52.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.52.0",
"version": "1.52.2",
"hasInstallScript": true,
"dependencies": {
"@cj-tech-master/excelts": "^10.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.52.0",
"version": "1.52.2",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+210
View File
@@ -0,0 +1,210 @@
/**
* Shapes for the API documentation (1.52.1): the records v1 handlers answer with, and the bodies
* they take, described once so every route shows the same thing the same way. Used as
* `documented: { body, response }` on v1Route (see RouteDef.documented) — shown in the OpenAPI
* document, not enforced; outside production anything that doesn't fit is logged.
*
* Everything is a loose object (extra fields pass, as v1 answers carry their Prisma includes),
* dates are ISO strings (as they arrive in JSON), and fields a handler may leave out are optional.
*/
import { z } from 'zod';
export const obj = <T extends z.ZodRawShape>(shape: T) => z.looseObject(shape);
export const str = z.string();
export const optStr = z.string().nullish();
export const num = z.number();
export const optNum = z.number().nullish();
export const bool = z.boolean();
export const optBool = z.boolean().nullish();
export const date = z.string().describe('Date and time (ISO 8601)');
export const optDate = date.nullish();
export const rand = z.number().describe('Rand');
export const optRand = rand.nullish();
export const anyObj = z.looseObject({});
/** A number sent as a number or as text (forms send text). */
export const numeric = z.union([z.number(), z.string()]);
export const ids = z.array(z.string()).describe('Ids');
export const channel = z.enum(['email', 'whatsapp', 'both']).describe('How to send it');
/** `{ message }`: what was done, in words for the person. */
export const message = obj({ message: str });
/** `{ ok: true }` */
export const ok = obj({ ok: z.literal(true) });
/** v1's paged lists: `{ data, total, page, limit, pages }`. */
export const v1Page = <T extends z.ZodType>(item: T) => obj({ data: z.array(item), total: num, page: num, limit: num, pages: num });
/** A `{ redirectUrl }` answer from a checkout: where to send the person to pay. */
export const checkout = obj({
checkoutId: optStr,
redirectUrl: optStr.describe('Where to send the person to pay (null when the provider takes a form)'),
provider: optStr,
form: obj({ action: str, fields: z.record(z.string(), z.string()) }).nullish().describe('A form to post (PayFast)'),
});
export const userSummary = obj({ id: str.optional(), name: str, email: optStr, phoneNumber: optStr });
export const userRef = obj({ id: str.optional(), name: str, email: optStr });
export const user = obj({
id: str,
name: str,
email: str,
phoneNumber: optStr,
role: z.enum(['admin', 'supervisor', 'staff', 'user']),
isActive: optBool,
notificationPreference: z.enum(['email', 'whatsapp', 'both']).nullish(),
homeCampusId: optStr,
createdAt: optDate,
});
export const eventRef = obj({ id: str, title: str, startDate: optDate, endDate: optDate });
export const event = obj({
id: str,
title: str,
description: optStr,
startDate: date,
endDate: date,
location: optStr,
price: optRand,
isActive: optBool,
isHidden: optBool,
imageUrl: optStr,
campusId: optStr,
scheduleType: z.enum(['single', 'sessions']).nullish(),
cashupStatus: z.enum(['open', 'closed']).nullish(),
});
export const session = obj({ id: str, label: optStr, startsAt: date, endsAt: date, cancelledAt: optDate, capacity: optNum });
export const eventOption = obj({
id: str,
eventId: str.optional(),
name: str,
description: optStr,
price: rand,
isMainTicket: bool,
stockLimit: optNum,
});
export const payment = obj({
id: str,
amount: rand.describe('Rand; a refund is negative'),
method: str.describe('cash, card, eft, other — or the wallet type for an online payment'),
provider: optStr.describe('yoco or payfast for an online payment'),
userId: str,
recordedById: optStr,
registrationId: optStr,
eventId: optStr,
isDonation: bool,
isSponsorship: optBool,
originalPaymentId: optStr.describe('A refund: the payment it refunds; a donation leg: the donation'),
externalId: optStr,
createdAt: date,
user: userSummary.nullish(),
recordedBy: userRef.nullish(),
event: eventRef.nullish(),
});
export const registrationStatus = z.enum(['pending', 'confirmed', 'partial_paid', 'paid', 'cancelled']);
export const registration = obj({
id: str,
userId: str,
eventId: str,
status: registrationStatus,
discountTotal: optRand,
totalDueComputed: optRand.describe('What it costs now, after discounts'),
createdAt: date,
user: userSummary.nullish(),
event: eventRef.nullish(),
});
export const ticket = obj({
id: str,
qrCode: str.describe('What the QR code holds'),
quantity: num.describe('People on the ticket'),
isUsed: bool,
eventId: str,
userId: str.describe('The payer'),
registrationOptionId: str,
attendeeId: optStr.describe('A named ticket: the person it\'s for'),
holderUserId: optStr,
createdAt: date,
});
export const auditEntry = obj({
id: str,
action: str,
actorRole: optStr,
targetType: optStr,
targetId: optStr,
metadata: anyObj.nullish(),
ip: optStr,
createdAt: date,
actor: userRef.nullish(),
});
/**
* What the common query parameters mean, for routes that declare them by name only (`filters(…)`
* in the resource files). The OpenAPI document uses these when a parameter has no description.
*/
export const QUERY_HELP: Record<string, string> = {
page: 'Page number, from 1',
limit: 'How many per page',
eventId: 'Only this event',
eventIds: 'These events (comma-separated ids)',
registrationId: 'Only this registration',
userId: 'Only this person',
actorId: 'Only things this person did',
sessionId: 'Multi-day events: the session (default: today\'s)',
deviceId: 'This device\'s id (offline scanning)',
from: 'On or after (ISO date or date-time)',
to: 'On or before (ISO date or date-time)',
startDate: 'On or after (ISO date)',
endDate: 'On or before (ISO date)',
before: 'Older than (ISO date-time), for the next page',
cursor: 'Where the last page ended (its `nextCursor`)',
search: 'Text to search for',
q: 'Text to search for',
status: 'Only this status',
role: 'admin, supervisor, staff or user',
isActive: '`true` or `false`',
campus: 'Campus id or slug (`all` for every campus)',
series: 'Event series slug',
format: 'File type, e.g. `pdf` or `xlsx`',
log: '`1` also writes it to the access log (a list with personal details)',
all: '`true`/`1`: include everything, not just the current ones',
medical: '`true`: include medical notes (supervisors; logged)',
type: 'Which kind',
action: 'Only this action',
sensitive: '`true`: only access to sensitive information',
version: 'A version number (default: the current one)',
provider: 'yoco or payfast',
method: 'Payment method: cash, card, eft, other',
kind: 'payment or refund',
donations: '`1`: only donations and the parts put towards registrations',
channel: 'email or whatsapp',
email: 'An email address',
phone: 'A phone number',
phoneNumber: 'A phone number',
past: '`true`: include ones that are over too',
unpaid: '`true`: only ones with money still owed',
unreviewed: '`true`: only the ones not reviewed yet',
seriesId: 'Only this event series',
registrants: '`true`: also people registered for its events in the last 12 months',
};
/** What path parameters are, when the route doesn't say. */
export const PATH_HELP: Record<string, string> = {
id: 'The id',
userId: 'The person\'s id',
attendeeId: 'The named attendee\'s id',
attachmentId: 'The attachment\'s id',
familyId: 'The family\'s id',
childId: 'The child\'s id',
slug: 'The short name in the address',
token: 'The token from the link (it is the credential)',
qrCode: 'What the ticket\'s QR code holds',
code: 'The code',
name: 'The name',
filename: 'The file name',
};
+13 -3
View File
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { routes as allRoutes, type AnyRoute, type Access } from './route';
import { ERROR_STATUS } from './errors';
import { PATH_HELP, QUERY_HELP } from './docs';
/**
* The OpenAPI 3.1 document for /api/v2, built from the route declarations (route.ts), so it is
@@ -48,7 +49,8 @@ function parameters(def: AnyRoute) {
if (!schema) return;
const json = toSchema(schema, 'input') as { properties?: Record<string, JsonSchema>; required?: string[] };
for (const [name, prop] of Object.entries(json.properties || {})) {
const { description, ...propSchema } = prop as JsonSchema & { description?: string };
const { description: own, ...propSchema } = prop as JsonSchema & { description?: string };
const description = own || (where === 'query' ? QUERY_HELP[name] : PATH_HELP[name]);
out.push({ name, in: where, required: where === 'path' || (json.required || []).includes(name), ...(description ? { description } : {}), schema: propSchema });
}
};
@@ -57,6 +59,14 @@ function parameters(def: AnyRoute) {
return out;
}
/** A JSON body; one with nothing in it (an action that takes no input) says so. */
function requestBody(schema: JsonSchema) {
const empty = !(schema as { properties?: object }).properties || !Object.keys((schema as { properties: object }).properties).length;
return empty
? { required: true, description: 'No body needed: send an empty object.', content: { 'application/json': { schema } } }
: { required: true, content: { 'application/json': { schema } } };
}
export function buildOpenApi(opts: { version: string; serverUrl?: string; list?: AnyRoute[] }) {
const list = opts.list ?? allRoutes;
const paths: Record<string, Record<string, unknown>> = {};
@@ -73,11 +83,11 @@ export function buildOpenApi(opts: { version: string; serverUrl?: string; list?:
parameters: parameters(def),
...(def.upload
? { requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: [def.upload.field], properties: { [def.upload.field]: { type: 'string', format: 'binary', description: def.upload.description } } } } } } }
: def.body ? { requestBody: { required: true, content: { 'application/json': { schema: toSchema(def.body, 'input') } } } } : {}),
: (def.documented?.body || def.body) ? { requestBody: requestBody(toSchema((def.documented?.body || def.body)!, 'input')) } : {}),
responses: {
[String(def.status ?? 200)]: def.file
? { description: 'The file', content: { [def.file.contentType]: { schema: { type: 'string' } } } }
: { description: 'OK', content: { 'application/json': { schema: toSchema(def.response, 'output') } } },
: { description: 'OK', content: { 'application/json': { schema: toSchema(def.documented?.response ? z.object({ data: def.documented.response }) : def.response, 'output') } } },
...(def.params || def.query || def.body ? { 400: errorResponse('Validation failed (VALIDATION_FAILED)') } : {}),
...(secured ? { 401: errorResponse('Not signed in (UNAUTHENTICATED)') } : {}),
...(secured && def.access !== 'user' ? { 403: errorResponse('Not allowed for this role (FORBIDDEN)') } : {}),
+104 -68
View File
@@ -8,6 +8,7 @@
import { z } from 'zod';
import { idParams } from '../schemas';
import { v1Route } from '../viaV1';
import * as D from '../docs';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const rateLimit = require('express-rate-limit') as (opts: object) => any;
@@ -26,101 +27,136 @@ const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.m
const tokenParams = z.object({ token: z.string().min(1).max(500) });
const exportLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false });
// ─── Documented shapes (docs.ts) ─────────────────────────────────────────────
const news = D.obj({ email: D.bool, whatsapp: D.bool });
const dataRequest = D.obj({
id: D.str, type: z.enum(['access', 'correction', 'deletion']), status: z.enum(['open', 'completed', 'rejected', 'cancelled']),
userId: D.optStr, name: D.str.optional(), email: D.str.optional(), details: D.optStr, response: D.optStr, dueAt: D.optDate.describe('30 days after it was made'),
closedAt: D.optDate, createdAt: D.date,
});
const noticeSection = D.obj({ key: D.str.optional(), title: D.str, body: D.str.describe('Text; {placeholders} are filled in') });
const operatorInput = D.obj({ name: D.str, purpose: D.str.describe('What they do for the church'), dataShared: D.optStr, location: D.optStr.describe('Country the data is kept in'), website: D.optStr, active: D.optBool, order: D.optNum });
const operator = operatorInput.extend({ id: D.str, active: D.bool, order: D.num, createdAt: D.date, updatedAt: D.date });
const logPage = <T extends z.ZodType>(row: T) => D.obj({ total: D.num, pageSize: D.num, rows: z.array(row) });
const breachInput = D.obj({
title: D.str, description: D.str, dataAffected: D.str, peopleAffected: D.numeric.nullish(), whoAffected: D.optStr, consequences: D.optStr,
actionsTaken: D.optStr, adviceToPeople: D.optStr, causedBy: D.optStr, status: D.optStr.describe('open or closed'),
discoveredAt: D.date, occurredAt: D.optDate, regulatorNotifiedAt: D.optDate.describe('When the Information Regulator was told'), peopleNotifiedAt: D.optDate,
});
const breach = breachInput.extend({ id: D.str, status: D.str, createdAt: D.date, updatedAt: D.date, createdBy: D.obj({ name: D.str }).nullish() });
const retentionState = D.obj({
policies: D.anyObj.describe('Per kind of information: how long it is kept'), definitions: D.anyObj, preview: D.anyObj.describe('What the rules would remove now'),
lastRun: D.anyObj.nullish(), related: D.obj({ medicalDays: D.num, backupCount: D.num, analyticsDays: D.num }),
});
const hubStatus = D.obj({
state: D.str.describe('active, grace, suspended, revoked, none…'), readOnly: D.bool, daysLeft: D.optNum, endsAt: D.optDate, graceEndsAt: D.optDate,
licence: D.obj({ customer: D.optStr, plan: D.optStr, status: D.optStr, licenceExpiresAt: D.optDate, graceDays: D.optNum, issuedAt: D.optDate, validUntil: D.optDate, limits: D.anyObj }).nullish(),
connected: D.bool, keyConfigured: D.bool, hubUrl: D.optStr, lastHeartbeatAt: D.optDate, lastError: D.optStr,
version: D.str.describe('This site\'s version'), latestVersion: D.optStr, updateAvailable: D.bool, urgentUpdate: D.bool, updateSnoozed: D.optBool,
releases: z.array(D.anyObj).optional().describe('What\'s new in each version since this one'), updateRequest: D.anyObj.nullish(), notices: z.array(D.anyObj).optional(),
});
const webhook = D.obj({
id: D.str, name: D.str, url: D.str, events: z.array(D.str).describe('Event types it gets'), isActive: D.bool, operatorId: D.optStr.describe('Its entry in the operator register'),
lastSuccessAt: D.optDate, lastFailureAt: D.optDate, createdAt: D.date,
});
const delivered = D.obj({ status: D.optStr.describe('succeeded, failed, retrying…'), statusCode: D.optNum, error: D.optStr });
// ─── My privacy ──────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/me/privacy', tag: 'Privacy', summary: 'My privacy settings', description: 'News consent, open requests, and whether there\'s a privacy notice to accept.', access: 'user', v1: privacy.getMyPrivacy });
v1Route({ method: 'put', path: '/me/privacy/news', tag: 'Privacy', summary: 'Choose whether to get news', access: 'user', body: loose, v1: privacy.updateMyNews });
v1Route({ method: 'get', path: '/me/privacy', tag: 'Privacy', summary: 'My privacy settings', description: 'News consent, open requests, and whether there\'s a privacy notice to accept.', access: 'user', documented: { response: D.obj({ news, newsAskedAt: D.optDate, canWhatsApp: D.bool, notificationPreference: D.optStr, history: z.array(D.anyObj).describe('Every change to my news consent'), requests: z.array(dataRequest) }) }, v1: privacy.getMyPrivacy });
v1Route({ method: 'put', path: '/me/privacy/news', tag: 'Privacy', summary: 'Choose whether to get news', access: 'user', body: loose, documented: { body: D.obj({ email: D.optBool, whatsapp: D.optBool, source: D.optStr.describe('Where it was changed, for the consent log') }), response: D.obj({ news, newsAskedAt: D.optDate }) }, v1: privacy.updateMyNews });
v1Route({ method: 'get', path: '/me/privacy/export', tag: 'Privacy', summary: 'Download everything held about me', description: '`format=pdf` or JSON (the default).', access: 'user', middleware: [exportLimiter], query: filters('format'), file: { contentType: 'application/json' }, v1: privacy.exportMyData });
v1Route({ method: 'post', path: '/me/privacy/requests', tag: 'Privacy', summary: 'Ask for my data to be corrected or deleted', access: 'user', status: 201, body: loose, v1: privacy.createMyRequest });
v1Route({ method: 'post', path: '/me/privacy/notice/accept', tag: 'Privacy', summary: 'Accept the privacy notice', access: 'user', body: loose, v1: privacy.acceptMyNotice });
v1Route({ method: 'get', path: '/privacy/notice', tag: 'Privacy', summary: 'The published privacy notice', access: 'public', query: filters('version'), v1: privacy.getNotice });
v1Route({ method: 'get', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'What an unsubscribe link is for', description: 'The token in the link is the credential.', access: 'public', params: tokenParams, v1: privacy.getUnsubscribe });
v1Route({ method: 'post', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'Unsubscribe with a link', access: 'public', params: tokenParams, body: loose, v1: privacy.postUnsubscribe });
v1Route({ method: 'post', path: '/me/privacy/requests', tag: 'Privacy', summary: 'Ask for my data to be corrected or deleted', access: 'user', status: 201, body: loose, documented: { body: D.obj({ type: z.enum(['correction', 'deletion']), details: D.optStr.describe('A correction: what to change'), password: D.optStr.describe('A deletion: closes the account too') }), response: D.obj({ request: dataRequest, message: D.str }) }, v1: privacy.createMyRequest });
v1Route({ method: 'post', path: '/me/privacy/notice/accept', tag: 'Privacy', summary: 'Accept the privacy notice', access: 'user', body: loose, documented: { response: D.obj({ version: D.num.describe('The version accepted') }) }, v1: privacy.acceptMyNotice });
v1Route({ method: 'get', path: '/privacy/notice', tag: 'Privacy', summary: 'The published privacy notice', access: 'public', query: filters('version'), documented: { response: D.obj({ version: D.num.describe('0: the built-in text'), builtIn: D.optBool, current: D.optBool, publishedAt: D.optDate, effectiveDate: D.optStr, changeSummary: D.optStr, sections: z.array(D.anyObj).describe('Rendered sections'), versions: z.array(D.anyObj).describe('Every published version') }) }, v1: privacy.getNotice });
v1Route({ method: 'get', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'What an unsubscribe link is for', description: 'The token in the link is the credential.', access: 'public', params: tokenParams, documented: { response: D.obj({ kind: z.enum(['account', 'address']), email: D.optStr.describe('Partly hidden'), hasPhone: D.optBool, news: D.obj({ email: D.bool, whatsapp: D.bool }).optional(), address: D.optStr.describe('kind address: the email or phone, partly hidden') }) }, v1: privacy.getUnsubscribe });
v1Route({ method: 'post', path: '/unsubscribe/:token', tag: 'Privacy', summary: 'Unsubscribe with a link', access: 'public', params: tokenParams, body: loose, documented: { body: D.obj({ channels: z.array(z.enum(['email', 'whatsapp'])).optional().describe('Default: the link\'s channel') }), response: D.message }, v1: privacy.postUnsubscribe });
// ─── Privacy admin ───────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/privacy/requests', tag: 'Privacy', summary: 'Data requests', description: '`status=open` (default), `closed` or `all`.', access: 'admin', query: filters('status', 'search', 'page', 'limit'), v1: privacy.listRequests });
v1Route({ method: 'post', path: '/privacy/requests/:id/complete', tag: 'Privacy', summary: 'Mark a data request done', access: 'admin', params: idParams, body: loose, v1: privacy.completeRequest });
v1Route({ method: 'post', path: '/privacy/requests/:id/reject', tag: 'Privacy', summary: 'Turn down a data request', access: 'admin', params: idParams, body: loose, v1: privacy.rejectRequest });
v1Route({ method: 'get', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'The privacy notice being drafted', access: 'admin', v1: privacy.getNoticeDraft });
v1Route({ method: 'put', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Save the draft notice', access: 'admin', body: loose, v1: privacy.saveNoticeDraft });
v1Route({ method: 'delete', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Throw the draft away', access: 'admin', v1: privacy.discardNoticeDraft });
v1Route({ method: 'post', path: '/privacy/notice/publish', tag: 'Privacy', summary: 'Publish the draft notice', description: 'Everyone is asked to accept it at their next sign-in.', access: 'admin', body: loose, v1: privacy.publishNotice });
v1Route({ method: 'post', path: '/privacy/notice/builtin-reviewed', tag: 'Privacy', summary: 'Mark the built-in notice as reviewed', access: 'admin', body: loose, v1: privacy.markBuiltInReviewed });
v1Route({ method: 'get', path: '/privacy/operators', tag: 'Privacy', summary: 'Who processes personal information for the church', access: 'admin', v1: privacy.listOperators });
v1Route({ method: 'post', path: '/privacy/operators', tag: 'Privacy', summary: 'Add an operator', access: 'admin', status: 201, body: loose, v1: privacy.createOperator });
v1Route({ method: 'put', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Change an operator', access: 'admin', params: idParams, body: loose, v1: privacy.updateOperator });
v1Route({ method: 'delete', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Remove an operator', access: 'admin', params: idParams, v1: privacy.deleteOperator });
v1Route({ method: 'get', path: '/privacy/consent-log', tag: 'Privacy', summary: 'Consent given and withdrawn', access: 'admin', query: filters('type', 'page', 'q'), v1: privacy.consentLog });
v1Route({ method: 'get', path: '/privacy/access-log', tag: 'Privacy', summary: 'Who looked at sensitive information', access: 'admin', query: filters('page', 'action', 'sensitive', 'actorId', 'from', 'to'), v1: privacy.accessLog });
v1Route({ method: 'get', path: '/privacy/readiness', tag: 'Privacy', summary: 'POPIA checklist', access: 'admin', v1: privacy.getReadiness });
v1Route({ method: 'get', path: '/privacy/retention', tag: 'Privacy', summary: 'How long information is kept', access: 'admin', v1: privacy.getRetention });
v1Route({ method: 'put', path: '/privacy/retention', tag: 'Privacy', summary: 'Change how long information is kept', access: 'admin', body: loose, v1: privacy.saveRetention });
v1Route({ method: 'post', path: '/privacy/retention/run', tag: 'Privacy', summary: 'Apply the retention rules now', access: 'admin', body: loose, v1: privacy.runRetentionNow });
v1Route({ method: 'get', path: '/privacy/breaches', tag: 'Privacy', summary: 'Recorded data breaches', access: 'admin', v1: privacy.listBreaches });
v1Route({ method: 'post', path: '/privacy/breaches', tag: 'Privacy', summary: 'Record a data breach', access: 'admin', status: 201, body: loose, v1: privacy.createBreach });
v1Route({ method: 'put', path: '/privacy/breaches/:id', tag: 'Privacy', summary: 'Update a data breach', access: 'admin', params: idParams, body: loose, v1: privacy.updateBreach });
v1Route({ method: 'get', path: '/privacy/requests', tag: 'Privacy', summary: 'Data requests', description: '`status=open` (default), `closed` or `all`.', access: 'admin', query: filters('status', 'search', 'page', 'limit'), documented: { response: D.obj({ requests: z.array(dataRequest.extend({ overdue: D.optBool, blockers: z.array(D.anyObj).optional().describe('Upcoming registrations that stop a deletion') })), openCount: D.num, total: D.num, page: D.num, limit: D.num, pages: D.num }) }, v1: privacy.listRequests });
v1Route({ method: 'post', path: '/privacy/requests/:id/complete', tag: 'Privacy', summary: 'Mark a data request done', access: 'admin', params: idParams, body: loose, documented: { body: D.obj({ response: D.optStr.describe('Emailed to them; needed for a correction') }), response: D.obj({ request: dataRequest, anonymised: D.anyObj.nullish().describe('A deletion: what was removed') }) }, v1: privacy.completeRequest });
v1Route({ method: 'post', path: '/privacy/requests/:id/reject', tag: 'Privacy', summary: 'Turn down a data request', access: 'admin', params: idParams, body: loose, documented: { body: D.obj({ response: D.str.describe('The reason, emailed to them'), cancelled: D.optBool.describe('They withdrew it') }), response: D.obj({ request: dataRequest }) }, v1: privacy.rejectRequest });
v1Route({ method: 'get', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'The privacy notice being drafted', access: 'admin', documented: { response: D.obj({ sections: z.array(noticeSection), isDraft: D.optBool, rendered: z.array(D.anyObj).describe('The sections with placeholders filled in'), placeholders: z.array(D.str).describe('The placeholders the text can use'), versions: z.array(D.anyObj).describe('Published versions'), builtIn: z.array(noticeSection), builtInUpdates: z.array(D.anyObj).describe('Changes to the built-in text not reviewed yet') }) }, v1: privacy.getNoticeDraft });
v1Route({ method: 'put', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Save the draft notice', access: 'admin', body: loose, documented: { body: D.obj({ sections: z.array(noticeSection) }), response: D.obj({ sections: z.array(noticeSection), rendered: z.array(D.anyObj), isDraft: z.literal(true), savedAt: D.date }) }, v1: privacy.saveNoticeDraft });
v1Route({ method: 'delete', path: '/privacy/notice/draft', tag: 'Privacy', summary: 'Throw the draft away', access: 'admin', documented: { response: D.message }, v1: privacy.discardNoticeDraft });
v1Route({ method: 'post', path: '/privacy/notice/publish', tag: 'Privacy', summary: 'Publish the draft notice', description: 'Everyone is asked to accept it at their next sign-in.', access: 'admin', body: loose, documented: { body: D.obj({ sections: z.array(noticeSection).optional(), changeSummary: D.str.describe('Shown to people asked to accept it'), requiresAcceptance: D.optBool }), response: D.obj({ version: D.num, publishedAt: D.date, requiresAcceptance: D.bool }) }, v1: privacy.publishNotice });
v1Route({ method: 'post', path: '/privacy/notice/builtin-reviewed', tag: 'Privacy', summary: 'Mark the built-in notice as reviewed', access: 'admin', body: loose, documented: { response: D.obj({ builtInUpdates: z.array(D.anyObj) }) }, v1: privacy.markBuiltInReviewed });
v1Route({ method: 'get', path: '/privacy/operators', tag: 'Privacy', summary: 'Who processes personal information for the church', access: 'admin', documented: { response: z.array(operator) }, v1: privacy.listOperators });
v1Route({ method: 'post', path: '/privacy/operators', tag: 'Privacy', summary: 'Add an operator', access: 'admin', status: 201, body: loose, documented: { body: operatorInput, response: operator }, v1: privacy.createOperator });
v1Route({ method: 'put', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Change an operator', access: 'admin', params: idParams, body: loose, documented: { body: operatorInput.partial(), response: operator }, v1: privacy.updateOperator });
v1Route({ method: 'delete', path: '/privacy/operators/:id', tag: 'Privacy', summary: 'Remove an operator', access: 'admin', params: idParams, documented: { response: D.message }, v1: privacy.deleteOperator });
v1Route({ method: 'get', path: '/privacy/consent-log', tag: 'Privacy', summary: 'Consent given and withdrawn', access: 'admin', query: filters('type', 'page', 'q'), documented: { response: logPage(D.obj({ id: D.str, at: D.optDate, name: D.optStr })).describe('`type` news: email, channel, granted, source, ip; notice: email, version, source, ip; parental: child, event, status, signedName') }, v1: privacy.consentLog });
v1Route({ method: 'get', path: '/privacy/access-log', tag: 'Privacy', summary: 'Who looked at sensitive information', access: 'admin', query: filters('page', 'action', 'sensitive', 'actorId', 'from', 'to'), documented: { response: logPage(D.obj({ id: D.str, at: D.date, actor: D.str, actorRole: D.optStr, action: D.str, what: D.optStr, records: D.optNum, eventId: D.optStr, ip: D.optStr, sensitive: D.bool })).extend({ actions: z.array(D.str), actors: z.array(D.obj({ id: D.str, name: D.str })) }) }, v1: privacy.accessLog });
v1Route({ method: 'get', path: '/privacy/readiness', tag: 'Privacy', summary: 'POPIA checklist', access: 'admin', documented: { response: D.obj({ score: D.num.describe('%'), passed: D.num, total: D.num, checks: z.array(D.obj({ key: D.str, label: D.str, ok: D.bool, detail: D.optStr, href: D.optStr, applies: D.bool })) }) }, v1: privacy.getReadiness });
v1Route({ method: 'get', path: '/privacy/retention', tag: 'Privacy', summary: 'How long information is kept', access: 'admin', documented: { response: retentionState }, v1: privacy.getRetention });
v1Route({ method: 'put', path: '/privacy/retention', tag: 'Privacy', summary: 'Change how long information is kept', access: 'admin', body: loose, documented: { body: D.obj({ policies: D.anyObj.describe('Per kind of information: { enabled, months }') }), response: retentionState }, v1: privacy.saveRetention });
v1Route({ method: 'post', path: '/privacy/retention/run', tag: 'Privacy', summary: 'Apply the retention rules now', access: 'admin', body: loose, documented: { response: D.obj({ result: D.anyObj.describe('What was removed or anonymised'), state: retentionState }) }, v1: privacy.runRetentionNow });
v1Route({ method: 'get', path: '/privacy/breaches', tag: 'Privacy', summary: 'Recorded data breaches', access: 'admin', documented: { response: z.array(breach) }, v1: privacy.listBreaches });
v1Route({ method: 'post', path: '/privacy/breaches', tag: 'Privacy', summary: 'Record a data breach', access: 'admin', status: 201, body: loose, documented: { body: breachInput, response: breach }, v1: privacy.createBreach });
v1Route({ method: 'put', path: '/privacy/breaches/:id', tag: 'Privacy', summary: 'Update a data breach', access: 'admin', params: idParams, body: loose, documented: { body: breachInput.partial(), response: breach }, v1: privacy.updateBreach });
// ─── Admin ───────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/admin/audit-log', tag: 'Admin', summary: 'The audit log', access: 'admin', query: filters('page', 'limit', 'actorId', 'action', 'role', 'from', 'to', 'search'), v1: audit.listAuditLog });
v1Route({ method: 'get', path: '/admin/jobs', tag: 'Admin', summary: 'Background jobs', access: 'admin', v1: jobs.listJobs });
v1Route({ method: 'post', path: '/admin/jobs/:name/run', tag: 'Admin', summary: 'Run a background job now', access: 'admin', params: z.object({ name: z.string().min(1).max(100) }), body: loose, v1: jobs.runJobNow });
v1Route({ method: 'get', path: '/admin/impersonation-sessions', tag: 'Admin', summary: 'Times admins viewed the site as someone else', access: 'admin', query: filters('limit'), v1: imp.listSessions });
v1Route({ method: 'post', path: '/admin/impersonate/:userId', tag: 'Admin', summary: 'View the site as someone (read-only)', description: 'Answers a token for a read-only session, logged.', access: 'admin', params: z.object({ userId: z.string().min(1).max(64) }), body: loose, v1: imp.startImpersonation });
v1Route({ method: 'get', path: '/me/impersonation', tag: 'Admin', summary: 'The viewing-as session I\'m in, if any', access: 'user', v1: imp.currentImpersonation });
v1Route({ method: 'post', path: '/me/impersonation/end', tag: 'Admin', summary: 'Stop viewing as someone', access: 'user', body: loose, v1: imp.endImpersonation });
v1Route({ method: 'post', path: '/me/impersonation/log', tag: 'Admin', summary: 'Record a page viewed while viewing as someone', access: 'user', body: loose, v1: imp.logPageView });
v1Route({ method: 'get', path: '/admin/audit-log', tag: 'Admin', summary: 'The audit log', access: 'admin', query: filters('page', 'limit', 'actorId', 'action', 'role', 'from', 'to', 'search'), documented: { response: D.obj({ rows: z.array(D.auditEntry), total: D.num, page: D.num, limit: D.num }) }, v1: audit.listAuditLog });
v1Route({ method: 'get', path: '/admin/jobs', tag: 'Admin', summary: 'Background jobs', access: 'admin', documented: { response: D.obj({ mode: D.str.describe('on, off or timers'), timezone: D.str, jobs: z.array(D.obj({ name: D.str, label: D.str, cron: D.optStr, enabled: D.bool, envFlag: D.optStr, nextRun: D.optDate, recent: z.array(D.anyObj).describe('Recent runs') })) }) }, v1: jobs.listJobs });
v1Route({ method: 'post', path: '/admin/jobs/:name/run', tag: 'Admin', summary: 'Run a background job now', access: 'admin', params: z.object({ name: z.string().min(1).max(100) }), body: loose, documented: { response: D.obj({ queued: D.bool, id: D.optStr, output: z.unknown().optional().describe('When it ran straight away') }) }, v1: jobs.runJobNow });
v1Route({ method: 'get', path: '/admin/impersonation-sessions', tag: 'Admin', summary: 'Times admins viewed the site as someone else', access: 'admin', query: filters('limit'), documented: { response: z.array(D.obj({ id: D.str, reason: D.optStr, startedAt: D.optDate, endedAt: D.optDate, expiresAt: D.optDate, admin: D.obj({ name: D.str, email: D.optStr }).nullish(), target: D.obj({ name: D.str, email: D.optStr }).nullish(), requests: z.array(D.obj({ method: D.str, path: D.str, blocked: D.bool, createdAt: D.date })).describe('What they did (up to 500)') })) }, v1: imp.listSessions });
v1Route({ method: 'post', path: '/admin/impersonate/:userId', tag: 'Admin', summary: 'View the site as someone (read-only)', description: 'Answers a token for a read-only session, logged.', access: 'admin', params: z.object({ userId: z.string().min(1).max(64) }), body: loose, documented: { body: D.obj({ reason: D.str.describe('Why — kept in the log') }), response: D.obj({ token: D.str.describe('A read-only sign-in token'), session: D.obj({ id: D.str, expiresAt: D.date, target: D.obj({ id: D.str, name: D.str, email: D.str, role: D.str }) }) }) }, v1: imp.startImpersonation });
v1Route({ method: 'get', path: '/me/impersonation', tag: 'Admin', summary: 'The viewing-as session I\'m in, if any', access: 'user', documented: { response: D.obj({ id: D.str, expiresAt: D.date, target: D.userRef }).nullable() }, v1: imp.currentImpersonation });
v1Route({ method: 'post', path: '/me/impersonation/end', tag: 'Admin', summary: 'Stop viewing as someone', access: 'user', body: loose, documented: { response: D.message }, v1: imp.endImpersonation });
v1Route({ method: 'post', path: '/me/impersonation/log', tag: 'Admin', summary: 'Record a page viewed while viewing as someone', access: 'user', body: loose, documented: { body: D.obj({ path: D.str }), response: z.null().describe('Always 204, no content') }, v1: imp.logPageView });
// ─── Settings and first-run set-up ───────────────────────────────────────────
v1Route({ method: 'get', path: '/settings', tag: 'Site', summary: 'Public site settings', description: 'Name, branding, contact details and what\'s switched on.', access: 'public', v1: settings.getSettings });
v1Route({ method: 'get', path: '/settings/all', tag: 'Site', summary: 'Every setting', description: 'Admins. Secrets are masked.', access: 'admin', v1: settings.getAllSettings });
v1Route({ method: 'put', path: '/settings', tag: 'Site', summary: 'Change settings', access: 'admin', body: loose, v1: settings.updateSettings });
v1Route({ method: 'post', path: '/settings/test-smtp', tag: 'Site', summary: 'Send a test email', access: 'admin', body: loose, v1: settings.testSmtp });
v1Route({ method: 'get', path: '/setup/needed', tag: 'Site', summary: 'Does the site still need setting up?', access: 'public', v1: settings.needsSetup });
v1Route({ method: 'post', path: '/setup/register', tag: 'Site', summary: 'Create the first admin', description: 'Only while no account exists.', access: 'public', body: loose, v1: settings.setupRegister });
v1Route({ method: 'post', path: '/setup', tag: 'Site', summary: 'Finish first-run set-up', access: 'admin', body: loose, v1: settings.runSetup });
v1Route({ method: 'get', path: '/settings', tag: 'Site', summary: 'Public site settings', description: 'Name, branding, contact details and what\'s switched on.', access: 'public', documented: { response: z.record(z.string(), z.string()).describe('Setting key → value: name, logo, colours, contact details, what\'s switched on') }, v1: settings.getSettings });
v1Route({ method: 'get', path: '/settings/all', tag: 'Site', summary: 'Every setting', description: 'Admins. Secrets are masked.', access: 'admin', documented: { response: z.record(z.string(), z.unknown()).describe('Setting key → value; secrets masked') }, v1: settings.getAllSettings });
v1Route({ method: 'put', path: '/settings', tag: 'Site', summary: 'Change settings', access: 'admin', body: loose, documented: { body: z.record(z.string(), z.unknown()).describe('Setting key → new value'), response: D.message }, v1: settings.updateSettings });
v1Route({ method: 'post', path: '/settings/test-smtp', tag: 'Site', summary: 'Send a test email', access: 'admin', body: loose, documented: { body: D.obj({ host: D.optStr, port: D.numeric.optional(), secure: z.union([D.bool, D.str]).optional(), user: D.optStr, pass: D.optStr, from: D.optStr }).describe('Unsaved SMTP details to try (default: the saved ones)'), response: D.message }, v1: settings.testSmtp });
v1Route({ method: 'get', path: '/setup/needed', tag: 'Site', summary: 'Does the site still need setting up?', access: 'public', documented: { response: D.obj({ needsSetup: D.bool }) }, v1: settings.needsSetup });
v1Route({ method: 'post', path: '/setup/register', tag: 'Site', summary: 'Create the first admin', description: 'Only while no account exists.', access: 'public', body: loose, documented: { body: D.obj({ adminName: D.str, adminEmail: D.str, adminPassword: D.str.describe('At least 8 characters') }), response: D.obj({ token: D.str, name: D.str, email: D.str }) }, v1: settings.setupRegister });
v1Route({ method: 'post', path: '/setup', tag: 'Site', summary: 'Finish first-run set-up', access: 'admin', body: loose, documented: { body: D.obj({ settings: z.record(z.string(), z.unknown()).describe('Setting key → value') }), response: D.message }, v1: settings.runSetup });
// ─── Hub ─────────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/hub/status', tag: 'Site', summary: 'The connection to the provider\'s Hub', access: 'admin', v1: hub.getStatus });
v1Route({ method: 'post', path: '/hub/connect', tag: 'Site', summary: 'Connect to the Hub', access: 'admin', body: loose, v1: hub.connect });
v1Route({ method: 'post', path: '/hub/heartbeat', tag: 'Site', summary: 'Check in with the Hub now', access: 'admin', body: loose, v1: hub.heartbeatNow });
v1Route({ method: 'post', path: '/hub/update-request', tag: 'Site', summary: 'Ask for the update to be installed', access: 'admin', body: loose, v1: hub.requestUpdate });
v1Route({ method: 'post', path: '/hub/update-request/cancel', tag: 'Site', summary: 'Withdraw the update request', access: 'admin', body: loose, v1: hub.cancelUpdateRequest });
v1Route({ method: 'post', path: '/hub/update-snooze', tag: 'Site', summary: 'Remind me about the update later', access: 'admin', body: loose, v1: hub.snoozeUpdate });
v1Route({ method: 'get', path: '/hub/status', tag: 'Site', summary: 'The connection to the provider\'s Hub', access: 'admin', documented: { response: hubStatus }, v1: hub.getStatus });
v1Route({ method: 'post', path: '/hub/connect', tag: 'Site', summary: 'Connect to the Hub', access: 'admin', body: loose, documented: { body: D.obj({ licenceKey: D.str }), response: hubStatus }, v1: hub.connect });
v1Route({ method: 'post', path: '/hub/heartbeat', tag: 'Site', summary: 'Check in with the Hub now', access: 'admin', body: loose, documented: { response: hubStatus }, v1: hub.heartbeatNow });
v1Route({ method: 'post', path: '/hub/update-request', tag: 'Site', summary: 'Ask for the update to be installed', access: 'admin', body: loose, documented: { response: hubStatus }, v1: hub.requestUpdate });
v1Route({ method: 'post', path: '/hub/update-request/cancel', tag: 'Site', summary: 'Withdraw the update request', access: 'admin', body: loose, documented: { response: hubStatus }, v1: hub.cancelUpdateRequest });
v1Route({ method: 'post', path: '/hub/update-snooze', tag: 'Site', summary: 'Remind me about the update later', access: 'admin', body: loose, documented: { body: D.obj({ snooze: D.optBool.describe('false: stop putting it off') }), response: hubStatus }, v1: hub.snoozeUpdate });
v1Route({
method: 'get', path: '/hub/notices', tag: 'Site', summary: 'Notices from the provider',
access: 'supervisor', campusScope: 'campusIndependent (notices from the provider are for the whole site)', middleware: [campusIndependent('notices from the provider are for the whole site')], v1: hub.getNotices,
access: 'supervisor', campusScope: 'campusIndependent (notices from the provider are for the whole site)', middleware: [campusIndependent('notices from the provider are for the whole site')], documented: { response: z.array(D.obj({ id: D.str, level: D.str.describe('info, warning, critical'), title: D.str, message: D.str, startsAt: D.optDate, endsAt: D.optDate, updatedAt: D.optDate })) }, v1: hub.getNotices,
});
// ─── Outbound webhooks ───────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Webhooks the site sends', access: 'admin', v1: hooks.list });
v1Route({ method: 'post', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Add a webhook', description: 'Answers its signing secret once.', access: 'admin', status: 201, body: loose, v1: hooks.create });
v1Route({ method: 'post', path: '/outbound-webhook-deliveries/:id/resend', tag: 'Webhooks', summary: 'Send a delivery again', access: 'admin', params: idParams, body: loose, v1: hooks.resend });
v1Route({ method: 'patch', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Change a webhook', access: 'admin', params: idParams, body: loose, v1: hooks.update });
v1Route({ method: 'delete', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Remove a webhook', access: 'admin', params: idParams, v1: hooks.remove });
v1Route({ method: 'post', path: '/outbound-webhooks/:id/rotate-secret', tag: 'Webhooks', summary: 'Give a webhook a new signing secret', access: 'admin', params: idParams, body: loose, v1: hooks.rotateSecret });
v1Route({ method: 'post', path: '/outbound-webhooks/:id/test', tag: 'Webhooks', summary: 'Send a test delivery', access: 'admin', params: idParams, body: loose, v1: hooks.test });
v1Route({ method: 'get', path: '/outbound-webhooks/:id/deliveries', tag: 'Webhooks', summary: 'A webhook\'s recent deliveries', access: 'admin', params: idParams, query: filters('page', 'limit'), v1: hooks.deliveries });
v1Route({ method: 'get', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Webhooks the site sends', access: 'admin', documented: { response: D.obj({ eventTypes: z.array(D.anyObj).describe('Every event type, with an example body'), keepDays: D.num.describe('How long deliveries are kept'), endpoints: z.array(webhook.extend({ lastWeek: z.record(z.string(), z.number()).describe('Status → deliveries in the last 7 days') })) }) }, v1: hooks.list });
v1Route({ method: 'post', path: '/outbound-webhooks', tag: 'Webhooks', summary: 'Add a webhook', description: 'Answers its signing secret once.', access: 'admin', status: 201, body: loose, documented: { body: D.obj({ name: D.str, url: D.str.describe('https:// address'), events: z.array(D.str) }), response: webhook.extend({ secret: D.str.describe('For checking signatures — shown only now') }) }, v1: hooks.create });
v1Route({ method: 'post', path: '/outbound-webhook-deliveries/:id/resend', tag: 'Webhooks', summary: 'Send a delivery again', access: 'admin', params: idParams, body: loose, documented: { response: delivered }, v1: hooks.resend });
v1Route({ method: 'patch', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Change a webhook', access: 'admin', params: idParams, body: loose, documented: { body: D.obj({ name: D.optStr, url: D.optStr, events: z.array(D.str).optional(), isActive: D.optBool }), response: webhook }, v1: hooks.update });
v1Route({ method: 'delete', path: '/outbound-webhooks/:id', tag: 'Webhooks', summary: 'Remove a webhook', access: 'admin', params: idParams, documented: { response: D.ok }, v1: hooks.remove });
v1Route({ method: 'post', path: '/outbound-webhooks/:id/rotate-secret', tag: 'Webhooks', summary: 'Give a webhook a new signing secret', access: 'admin', params: idParams, body: loose, documented: { response: D.obj({ secret: D.str.describe('Shown only now; the old one stops working') }) }, v1: hooks.rotateSecret });
v1Route({ method: 'post', path: '/outbound-webhooks/:id/test', tag: 'Webhooks', summary: 'Send a test delivery', access: 'admin', params: idParams, body: loose, documented: { response: delivered }, v1: hooks.test });
v1Route({ method: 'get', path: '/outbound-webhooks/:id/deliveries', tag: 'Webhooks', summary: 'A webhook\'s recent deliveries', access: 'admin', params: idParams, query: filters('page', 'limit', 'status', 'before'), documented: { response: z.array(D.obj({ id: D.str, eventId: D.str, type: D.str.describe('The event type'), payload: z.unknown(), status: D.str.describe('pending, sending, retrying, succeeded, failed'), attempts: D.num, nextAttemptAt: D.optDate, lastStatusCode: D.optNum, lastError: D.optStr, createdAt: D.date, deliveredAt: D.optDate })) }, v1: hooks.deliveries });
// ─── Backups ─────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/backups', tag: 'Admin', summary: 'Database backups', access: 'admin', v1: backups.getBackups });
v1Route({ method: 'post', path: '/backups/run', tag: 'Admin', summary: 'Back up the database now', access: 'admin', body: loose, v1: backups.triggerBackup });
v1Route({ method: 'get', path: '/backups', tag: 'Admin', summary: 'Database backups', access: 'admin', documented: { response: z.array(D.obj({ filename: D.str, size: D.num.describe('Bytes'), createdAt: D.date })) }, v1: backups.getBackups });
v1Route({ method: 'post', path: '/backups/run', tag: 'Admin', summary: 'Back up the database now', access: 'admin', body: loose, documented: { response: D.anyObj.describe('The backup made') }, v1: backups.triggerBackup });
v1Route({ method: 'get', path: '/backups/:filename/download', tag: 'Admin', summary: 'Download a backup', access: 'admin', params: z.object({ filename: z.string().min(1).max(200) }), file: { contentType: 'application/octet-stream' }, v1: backups.downloadBackup });
// ─── Yoco and PayFast settings (admins; their incoming webhooks are separate) ─
const yoco = require('../../controllers/yocoController');
const payfast = require('../../controllers/payfastController');
v1Route({ method: 'get', path: '/payments/yoco/config', tag: 'Payments', summary: 'Yoco settings', description: 'Keys are masked.', access: 'admin', v1: yoco.getConfig });
v1Route({ method: 'put', path: '/payments/yoco/config', tag: 'Payments', summary: 'Change Yoco settings', access: 'admin', body: loose, v1: yoco.updateConfig });
v1Route({ method: 'get', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Webhooks registered with Yoco', access: 'admin', v1: yoco.getWebhooks });
v1Route({ method: 'post', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Register a webhook with Yoco', access: 'admin', body: loose, v1: yoco.createWebhook });
v1Route({ method: 'delete', path: '/payments/yoco/webhooks/:id', tag: 'Payments', summary: 'Remove a webhook from Yoco', access: 'admin', params: z.object({ id: z.string().min(1).max(200) }), v1: yoco.removeWebhook });
v1Route({ method: 'get', path: '/payments/payfast/config', tag: 'Payments', summary: 'PayFast settings', description: 'The passphrase is masked.', access: 'admin', v1: payfast.getConfig });
v1Route({ method: 'put', path: '/payments/payfast/config', tag: 'Payments', summary: 'Change PayFast settings', access: 'admin', body: loose, v1: payfast.updateConfig });
v1Route({ method: 'post', path: '/payments/payfast/test', tag: 'Payments', summary: 'Check the PayFast details work', access: 'admin', body: loose, v1: payfast.testConnection });
v1Route({ method: 'get', path: '/payments/yoco/config', tag: 'Payments', summary: 'Yoco settings', description: 'Keys are masked.', access: 'admin', documented: { response: D.obj({ secretKeyMasked: D.str, secretKeySource: D.optStr.describe('settings or env'), mode: D.optStr.describe('test or live'), webhookSecretMasked: D.str, webhookSecretSource: D.optStr, webhookId: D.str, webhookUrl: D.str, configured: D.bool }) }, v1: yoco.getConfig });
v1Route({ method: 'put', path: '/payments/yoco/config', tag: 'Payments', summary: 'Change Yoco settings', access: 'admin', body: loose, documented: { body: D.obj({ secretKey: D.optStr.describe('sk_test_… or sk_live_…'), webhookSecret: D.optStr.describe('whsec_…') }), response: D.message }, v1: yoco.updateConfig });
v1Route({ method: 'get', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Webhooks registered with Yoco', access: 'admin', documented: { response: D.obj({ webhooks: z.array(D.obj({ id: D.str, name: D.optStr, url: D.str, mode: D.optStr, isActive: D.bool, pointsHere: D.bool })), defaultUrl: D.str }) }, v1: yoco.getWebhooks });
v1Route({ method: 'post', path: '/payments/yoco/webhooks', tag: 'Payments', summary: 'Register a webhook with Yoco', access: 'admin', body: loose, documented: { body: D.obj({ name: D.optStr, url: D.optStr.describe('Default: this site\'s webhook address') }), response: D.obj({ message: D.str, webhook: D.obj({ id: D.str, name: D.optStr, url: D.str, mode: D.optStr }), webhookSecretMasked: D.str }) }, v1: yoco.createWebhook });
v1Route({ method: 'delete', path: '/payments/yoco/webhooks/:id', tag: 'Payments', summary: 'Remove a webhook from Yoco', access: 'admin', params: z.object({ id: z.string().min(1).max(200) }), documented: { response: D.obj({ message: D.str, clearedSecret: D.bool }) }, v1: yoco.removeWebhook });
v1Route({ method: 'get', path: '/payments/payfast/config', tag: 'Payments', summary: 'PayFast settings', description: 'The passphrase is masked.', access: 'admin', documented: { response: D.obj({ merchantId: D.str, merchantKey: D.str, passphraseMasked: D.str, hasPassphrase: D.bool, mode: z.enum(['sandbox', 'live']), notifyUrl: D.str, notifyUrlProblem: D.optStr.describe('Why PayFast couldn\'t reach it'), configured: D.bool }) }, v1: payfast.getConfig });
v1Route({ method: 'put', path: '/payments/payfast/config', tag: 'Payments', summary: 'Change PayFast settings', access: 'admin', body: loose, documented: { body: D.obj({ merchantId: D.optStr, merchantKey: D.optStr, passphrase: D.optStr.describe('At most 32 characters'), mode: z.enum(['sandbox', 'live']).optional() }), response: D.message }, v1: payfast.updateConfig });
v1Route({ method: 'post', path: '/payments/payfast/test', tag: 'Payments', summary: 'Check the PayFast details work', access: 'admin', body: loose, documented: { response: D.message }, v1: payfast.testConnection });
+80 -45
View File
@@ -7,6 +7,7 @@
import { z } from 'zod';
import { id, idParams } from '../schemas';
import { v1Route } from '../viaV1';
import * as D from '../docs';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const cs = require('../../utils/campusScope') as {
@@ -25,114 +26,148 @@ const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.param
const loose = z.looseObject({});
const tokenParams = z.object({ token: z.string().min(1).max(200) });
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
// ─── Documented shapes (docs.ts) ─────────────────────────────────────────────
const usage = D.obj({ id: D.str.optional(), scannedAt: D.date, quantityRedeemed: D.num, sessionId: D.optStr, manual: D.optBool.describe('Ticked present by hand') });
const ticketFull = D.ticket.extend({
usages: z.array(usage).optional().describe('Check-ins'),
event: D.event.partial().nullish(),
user: D.userSummary.nullish().describe('The payer'),
attendee: D.obj({ name: D.str }).nullish(),
registrationOption: D.obj({ eventOption: D.eventOption.partial().nullish(), variant: D.obj({ name: D.str }).nullish(), registration: D.registration.partial().nullish() }).nullish(),
});
const sent = D.obj({ message: D.str, ticketIds: D.ids.optional().describe('The tickets sent') });
const lockView = D.obj({
id: D.str, eventId: D.str, sectionId: D.optStr, sectionName: D.optStr, deviceId: D.str, deviceName: D.optStr,
user: D.userRef.nullish().describe('Who holds it'), lockedAt: D.date, snapshotAt: D.optDate, lastSyncAt: D.optDate, releasedAt: D.optDate,
});
const transferInput = D.obj({
name: D.str.describe('Who it goes to'), email: D.optStr, phone: D.optStr.describe('Email or phone needed'),
dateOfBirth: D.optDate.describe('Youth events'), gender: D.optStr, parentName: D.optStr, parentEmail: D.optStr, parentPhone: D.optStr,
});
const certificate = D.obj({ id: D.str, code: D.str.describe('For checking it at /verify'), name: D.str, issuedAt: D.date, sessionsAttended: D.optNum, totalSessions: D.optNum, event: D.eventRef.partial().nullish() });
const registerRow = D.obj({
ticketId: D.str, bookedSessions: D.ids.nullable().describe('Picked/ticked sessions (null: all)'), sessionTickets: z.record(z.string(), z.string()).nullable(),
name: D.str, bookedBy: D.str, email: D.str, phone: D.str, option: D.str, variant: D.optStr, quantity: D.num,
checkIns: z.record(z.string(), z.number()).describe('Session id (or `event`) → people checked in'),
attended: D.num, total: D.num, completed: D.bool.describe('Meets the certificate rule'), certificate: D.anyObj.nullish(),
});
const groupInput = D.obj({
name: D.optStr, capacity: D.numeric.nullish(), gender: z.enum(['male', 'female', 'mixed']).nullish(), leaderUserId: D.optStr.describe('A staff member'),
minAge: D.numeric.nullish(), maxAge: D.numeric.nullish(), ratio: D.numeric.nullish().describe('Children per leader'),
});
const group = D.obj({ id: D.str, setId: D.str, name: D.str, capacity: D.optNum, gender: D.optStr, leaderUserId: D.optStr, minAge: D.optNum, maxAge: D.optNum, ratio: D.optNum, order: D.num });
const allocationSet = D.obj({ id: D.str, eventId: D.str, name: D.str, showOnTicket: D.bool, order: D.num });
const member = D.obj({ attendeeId: D.str.optional(), name: D.str, gender: D.optStr, dateOfBirth: D.optDate, groupId: D.optStr });
const ticketOwner = cs.scopeEvent({ resolve: 'ticket', ownerOk: true });
const qrScope = cs.scopeEvent({ resolve: 'ticketQr', from: (req: any) => req.params.qrCode, nameCampus: true });
// ─── Tickets ─────────────────────────────────────────────────────────────────
v1Route({ method: 'get', path: '/me/tickets', tag: 'Tickets', summary: 'My tickets', description: 'Including ones transferred to me.', access: 'user', v1: t.getUserTickets });
v1Route({ method: 'get', path: '/me/tickets', tag: 'Tickets', summary: 'My tickets', description: 'Including ones transferred to me.', access: 'user', documented: { response: z.array(ticketFull) }, v1: t.getUserTickets });
v1Route({
method: 'post', path: '/me/tickets/email', tag: 'Tickets', summary: 'Send me my tickets',
description: 'For a registration (`registrationId`) or chosen tickets (`ticketIds`), by email and/or WhatsApp (`channel`).',
access: 'user', body: z.looseObject({ registrationId: id.optional(), ticketIds: z.array(id).max(500).optional(), channel: z.string().max(20).optional() }), v1: t.emailTickets,
access: 'user', body: z.looseObject({ registrationId: id.optional(), ticketIds: z.array(id).max(500).optional(), channel: z.string().max(20).optional() }), documented: { response: sent }, v1: t.emailTickets,
});
v1Route({
method: 'get', path: '/tickets/scans/recent', tag: 'Tickets', summary: 'Recent scans', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'limit'), v1: t.getRecentScans,
method: 'get', path: '/tickets/scans/recent', tag: 'Tickets', summary: 'Recent scans', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'limit'), documented: { response: z.array(D.obj({ id: D.str, scannedAt: D.date, quantityRedeemed: D.num, scannedBy: D.userRef.nullish(), ticket: D.obj({ id: D.str, event: D.obj({ title: D.str }), registrationOption: D.anyObj.nullish() }) })) }, v1: t.getRecentScans,
});
v1Route({
method: 'get', path: '/tickets/scans/stats', tag: 'Tickets', summary: 'Scan counts', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId'), v1: t.getScanStats,
method: 'get', path: '/tickets/scans/stats', tag: 'Tickets', summary: 'Scan counts', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId'), documented: { response: D.obj({ totalToday: D.num, myToday: D.num, lastHour: D.num, byStaff: z.array(D.obj({ scannedById: D.str, name: D.str, count: D.num })) }) }, v1: t.getScanStats,
});
v1Route({
method: 'post', path: '/tickets/send', tag: 'Tickets', summary: 'Send tickets to someone',
description: 'Staff: a registration\'s tickets (or chosen ones) to an email address or phone number.',
access: 'staff', campusScope: 'scopeBody(registrationId, ticketIds)', middleware: [cs.scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']])], body: loose, v1: t.sendTicketsTo,
access: 'staff', campusScope: 'scopeBody(registrationId, ticketIds)', middleware: [cs.scopeBody([['registrationId', 'registration'], ['ticketIds', 'ticket']])], body: loose, documented: { body: D.obj({ registrationId: D.optStr, ticketIds: D.ids.optional().describe('Or registrationId'), channel: D.channel, overrideEmail: D.optStr.describe('Send here instead'), overridePhone: D.optStr }), response: sent }, v1: t.sendTicketsTo,
});
v1Route({
method: 'post', path: '/tickets/generate', tag: 'Tickets', summary: 'Issue a registration\'s tickets',
access: 'supervisor', campusScope: 'scopeBody(registrationId)', middleware: [cs.scopeBody([['registrationId', 'registration']])], body: z.looseObject({ registrationId: id }), v1: t.generateTickets,
access: 'supervisor', campusScope: 'scopeBody(registrationId)', middleware: [cs.scopeBody([['registrationId', 'registration']])], body: z.looseObject({ registrationId: id }), documented: { response: D.obj({ message: D.str, tickets: z.array(ticketFull) }) }, v1: t.generateTickets,
});
v1Route({
method: 'post', path: '/tickets/email-sent', tag: 'Tickets', summary: 'Mark tickets as emailed', access: 'admin', body: loose, v1: t.markTicketsAsEmailSent,
method: 'post', path: '/tickets/email-sent', tag: 'Tickets', summary: 'Mark tickets as emailed', access: 'admin', body: loose, documented: { body: D.obj({ ticketIds: D.ids }), response: D.message }, v1: t.markTicketsAsEmailSent,
});
v1Route({
method: 'get', path: '/tickets', tag: 'Tickets', summary: 'Every ticket', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'page', 'limit'), v1: t.getTickets,
method: 'get', path: '/tickets', tag: 'Tickets', summary: 'Every ticket', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'page', 'limit'), documented: { response: D.v1Page(ticketFull) }, v1: t.getTickets,
});
v1Route({
method: 'get', path: '/tickets/qr/:qrCode', tag: 'Tickets', summary: 'The ticket for a QR code',
description: 'Without checking it in (that\'s POST /checkin/{qrCode}).',
access: 'staff', campusScope: 'scopeEvent(ticketQr)', middleware: [qrScope], params: z.object({ qrCode: z.string().min(1).max(200) }), v1: t.getTicketByQrCode,
access: 'staff', campusScope: 'scopeEvent(ticketQr)', middleware: [qrScope], params: z.object({ qrCode: z.string().min(1).max(200) }), documented: { response: ticketFull }, v1: t.getTicketByQrCode,
});
v1Route({
method: 'get', path: '/events/:id/tickets', tag: 'Tickets', summary: 'An event\'s tickets',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: t.getTicketsByEvent,
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: z.array(ticketFull) }, v1: t.getTicketsByEvent,
});
v1Route({
method: 'get', path: '/ticket-transfers/:token', tag: 'Tickets', summary: 'A ticket someone is giving you',
description: 'What the transfer link shows (the token is the credential).', access: 'public', params: tokenParams, v1: t.getTransferOffer,
description: 'What the transfer link shows (the token is the credential).', access: 'public', params: tokenParams, documented: { response: D.obj({ status: D.str.describe('pending, accepted, declined, cancelled, expired'), toName: D.str, expiresAt: D.date, fromName: D.str, event: D.event.partial(), option: D.str }) }, v1: t.getTransferOffer,
});
v1Route({ method: 'post', path: '/ticket-transfers/:token/accept', tag: 'Tickets', summary: 'Accept a ticket', access: 'public', params: tokenParams, body: loose, v1: t.acceptTransferOffer });
v1Route({ method: 'post', path: '/ticket-transfers/:token/decline', tag: 'Tickets', summary: 'Decline a ticket', access: 'public', params: tokenParams, body: loose, v1: t.declineTransferOffer });
v1Route({ method: 'post', path: '/ticket-transfers/:token/accept', tag: 'Tickets', summary: 'Accept a ticket', access: 'public', params: tokenParams, body: loose, documented: { response: D.obj({ message: D.str, ticketId: D.str }) }, v1: t.acceptTransferOffer });
v1Route({ method: 'post', path: '/ticket-transfers/:token/decline', tag: 'Tickets', summary: 'Decline a ticket', access: 'public', params: tokenParams, body: loose, documented: { response: D.message }, v1: t.declineTransferOffer });
v1Route({
method: 'get', path: '/tickets/:id', tag: 'Tickets', summary: 'One ticket', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, v1: t.getTicketById,
method: 'get', path: '/tickets/:id', tag: 'Tickets', summary: 'One ticket', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, documented: { response: ticketFull }, v1: t.getTicketById,
});
v1Route({
method: 'post', path: '/tickets/:id/transfer', tag: 'Tickets', summary: 'Give a ticket to someone',
description: 'Sends them a link to accept it; the ticket is theirs once they do.',
access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, body: loose, v1: t.transferTicketHandler,
access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, body: loose, documented: { body: transferInput, response: D.obj({ message: D.str, requestId: D.str, expiresAt: D.date }) }, v1: t.transferTicketHandler,
});
v1Route({
method: 'delete', path: '/tickets/:id/transfer', tag: 'Tickets', summary: 'Cancel a ticket transfer', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, v1: t.cancelTransferHandler,
method: 'delete', path: '/tickets/:id/transfer', tag: 'Tickets', summary: 'Cancel a ticket transfer', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, documented: { response: D.message }, v1: t.cancelTransferHandler,
});
v1Route({
method: 'get', path: '/tickets/:id/transfers', tag: 'Tickets', summary: 'A ticket\'s transfers', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, v1: t.getTicketTransfers,
method: 'get', path: '/tickets/:id/transfers', tag: 'Tickets', summary: 'A ticket\'s transfers', access: 'user', campusScope: 'scopeEvent(ticket, owner-ok)', middleware: [ticketOwner], params: idParams, documented: { response: z.array(D.obj({ id: D.str, ticketId: D.str, fromUserId: D.optStr, toUserId: D.optStr, createdAt: D.date })) }, v1: t.getTicketTransfers,
});
// ─── Scan locks (offline scanning) ───────────────────────────────────────────
v1Route({
method: 'get', path: '/scan-locks', tag: 'Check-in', summary: 'Devices scanning offline for an event',
access: 'staff', campusScope: 'scopeEvent(query.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.query.eventId })], query: z.looseObject({ eventId: id }), v1: locks.listLocks,
access: 'staff', campusScope: 'scopeEvent(query.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.query.eventId })], query: z.looseObject({ eventId: id }), documented: { response: z.array(lockView) }, v1: locks.listLocks,
});
v1Route({
method: 'post', path: '/scan-locks', tag: 'Check-in', summary: 'Take tickets offline on this device',
description: 'Locks the chosen tickets to this device until it syncs or releases them.',
access: 'staff', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], status: 201, body: z.looseObject({ eventId: id }), v1: locks.createLock,
access: 'staff', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], status: 201, body: z.looseObject({ eventId: id }), documented: { body: D.obj({ eventId: D.str, sectionId: D.optStr.describe('Just this section (default: the whole event)'), deviceId: D.str, deviceName: D.optStr }), response: lockView }, v1: locks.createLock,
});
v1Route({
method: 'get', path: '/scan-locks/reports', tag: 'Check-in', summary: 'Offline sync reports to review',
access: 'supervisor', campusScope: 'scopeEvent(query.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.query.eventId })], query: z.looseObject({ eventId: id, unreviewed: z.string().max(10).optional() }), v1: locks.listReports,
access: 'supervisor', campusScope: 'scopeEvent(query.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.query.eventId })], query: z.looseObject({ eventId: id, unreviewed: z.string().max(10).optional() }), documented: { response: z.array(D.obj({ id: D.str, accepted: D.optNum, duplicates: D.optNum, conflicts: z.unknown().optional(), reviewedAt: D.optDate, createdAt: D.date, scanLock: lockView })) }, v1: locks.listReports,
});
v1Route({
method: 'post', path: '/scan-locks/reports/:id/review', tag: 'Check-in', summary: 'Mark a sync report reviewed',
access: 'supervisor', campusScope: 'scopeEvent(syncReport)', middleware: [cs.scopeEvent({ resolve: 'syncReport' })], params: idParams, body: loose, v1: locks.reviewReport,
access: 'supervisor', campusScope: 'scopeEvent(syncReport)', middleware: [cs.scopeEvent({ resolve: 'syncReport' })], params: idParams, body: loose, documented: { response: D.obj({ id: D.str, reviewedAt: D.date, reviewedById: D.str }) }, v1: locks.reviewReport,
});
v1Route({
method: 'get', path: '/scan-locks/:id/snapshot', tag: 'Check-in', summary: 'The tickets a device took offline',
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, query: filters('deviceId'), v1: locks.getSnapshot,
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, query: filters('deviceId'), documented: { response: D.obj({ lock: lockView, snapshotAt: D.date, session: D.obj({ id: D.str, label: D.str }).nullable(), noSessionToday: D.bool, tickets: z.array(D.obj({ id: D.str, qrCode: D.str, holder: D.str, bookedBy: D.str, option: D.str, variant: D.optStr, quantity: D.num, remaining: D.num, consentStatus: D.str })) }) }, v1: locks.getSnapshot,
});
v1Route({
method: 'post', path: '/scan-locks/:id/sync', tag: 'Check-in', summary: 'Upload scans made offline',
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: loose, v1: locks.syncLock,
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: loose, documented: { body: D.obj({ deviceId: D.str, scans: z.array(D.obj({ clientScanId: D.str.describe('The device\'s id for the scan (repeats are ignored)'), qrCode: D.str, qty: D.numeric.optional(), scannedAt: D.optDate, sessionId: D.optStr })).max(2000) }), response: D.obj({ accepted: D.num, duplicates: D.num, conflicts: z.array(D.anyObj).describe('Scans that clashed with online check-ins'), reportId: D.str, released: D.bool }) }, v1: locks.syncLock,
});
v1Route({
method: 'post', path: '/scan-locks/:id/force-release', tag: 'Check-in', summary: 'Release a lost device\'s tickets',
access: 'supervisor', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: loose, v1: locks.forceRelease,
access: 'supervisor', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: loose, documented: { response: D.message }, v1: locks.forceRelease,
});
v1Route({
method: 'delete', path: '/scan-locks/:id', tag: 'Check-in', summary: 'Release tickets back online',
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: z.looseObject({ deviceId: z.string().max(200).optional() }), v1: locks.releaseLock,
access: 'staff', campusScope: 'scopeEvent(scanLock)', middleware: [cs.scopeEvent({ resolve: 'scanLock' })], params: idParams, body: z.looseObject({ deviceId: z.string().max(200).optional() }), documented: { response: D.message }, v1: locks.releaseLock,
});
// ─── Attendance and certificates ─────────────────────────────────────────────
v1Route({
method: 'get', path: '/certificates/verify/:code', tag: 'Attendance', summary: 'Check a certificate is genuine',
access: 'public', params: z.object({ code: z.string().min(1).max(100) }), v1: att.verifyCertificate,
access: 'public', params: z.object({ code: z.string().min(1).max(100) }), documented: { response: D.obj({ valid: D.bool, revoked: D.optBool, name: D.optStr, event: D.optStr, completedOn: D.optDate, issuedAt: D.optDate }) }, v1: att.verifyCertificate,
});
v1Route({ method: 'get', path: '/me/certificates', tag: 'Me', summary: 'My certificates', access: 'user', v1: att.myCertificates });
v1Route({ method: 'get', path: '/me/certificates', tag: 'Me', summary: 'My certificates', access: 'user', documented: { response: z.array(certificate) }, v1: att.myCertificates });
v1Route({
method: 'post', path: '/certificates/preview', tag: 'Attendance', summary: 'Preview a certificate template',
access: 'supervisor', campusScope: 'campusIndependent (previews a template from the request body)', middleware: [cs.campusIndependent('previews a certificate template from the request body')], body: loose, file: { contentType: 'application/pdf' }, v1: att.previewCertificate,
access: 'supervisor', campusScope: 'campusIndependent (previews a template from the request body)', middleware: [cs.campusIndependent('previews a certificate template from the request body')], body: loose, file: { contentType: 'application/pdf' }, documented: { body: D.obj({ title: D.optStr.describe('Event title to show'), config: D.obj({ template: D.optStr.describe('classic, modern, elegant, simple'), title: D.optStr, body: D.optStr.describe('{name} and {event} are filled in') }).optional() }) }, v1: att.previewCertificate,
});
v1Route({
method: 'get', path: '/certificates/:id/pdf', tag: 'Attendance', summary: 'Download a certificate',
@@ -140,11 +175,11 @@ v1Route({
});
v1Route({
method: 'post', path: '/certificates/:id/revoke', tag: 'Attendance', summary: 'Withdraw a certificate',
access: 'supervisor', campusScope: 'scopeEvent(certificate)', middleware: [cs.scopeEvent({ resolve: 'certificate' })], params: idParams, body: loose, v1: att.revokeCertificate,
access: 'supervisor', campusScope: 'scopeEvent(certificate)', middleware: [cs.scopeEvent({ resolve: 'certificate' })], params: idParams, body: loose, documented: { response: D.ok }, v1: att.revokeCertificate,
});
v1Route({
method: 'get', path: '/events/:id/attendance', tag: 'Attendance', summary: 'An event\'s attendance register',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: att.getRegister,
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: D.obj({ event: D.event.partial().extend({ certificatesEnabled: D.optBool, certificateConfig: D.anyObj.nullish() }), columns: z.array(D.obj({ id: D.str.describe('Session id, or `event`'), label: D.str })), rows: z.array(registerRow), totals: z.record(z.string(), z.number()) }) }, v1: att.getRegister,
});
v1Route({
method: 'get', path: '/events/:id/attendance/export', tag: 'Attendance', summary: 'The register as a file',
@@ -153,15 +188,15 @@ v1Route({
});
v1Route({
method: 'post', path: '/events/:id/attendance', tag: 'Attendance', summary: 'Mark attendance',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, v1: att.markAttendance,
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, documented: { body: D.obj({ ticketId: D.str, sessionId: D.str.describe('A session id, or `event` on a single-date event'), present: D.bool }), response: D.ok }, v1: att.markAttendance,
});
v1Route({
method: 'post', path: '/events/:id/certificates', tag: 'Attendance', summary: 'Issue certificates',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, v1: att.issueCertificates,
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, documented: { body: D.obj({ ticketId: D.optStr.describe('Just this person (default: everyone who qualifies)') }), response: D.obj({ issued: D.num }) }, v1: att.issueCertificates,
});
v1Route({
method: 'post', path: '/events/:id/certificates/send', tag: 'Attendance', summary: 'Send certificates',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, v1: att.sendCertificatesHandler,
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: loose, map: asEvent, documented: { body: D.obj({ certificateId: D.optStr.describe('Just this one (default: every one not sent yet)') }), response: D.obj({ sent: D.num, skipped: D.num }) }, v1: att.sendCertificatesHandler,
});
v1Route({
method: 'get', path: '/events/:id/certificates.pdf', tag: 'Attendance', summary: 'Every certificate for an event, as one PDF',
@@ -172,24 +207,24 @@ v1Route({
v1Route({
method: 'get', path: '/me/allocation-groups', tag: 'Allocations', summary: 'Groups I lead',
access: 'staff', campusScope: 'campusIndependent (only the groups the signed-in person leads)', middleware: [cs.campusIndependent('only the groups the signed-in person leads')], v1: alloc.getMyGroups,
access: 'staff', campusScope: 'campusIndependent (only the groups the signed-in person leads)', middleware: [cs.campusIndependent('only the groups the signed-in person leads')], documented: { response: D.obj({ medicalShown: D.bool, groups: z.array(D.obj({ id: D.str, name: D.str, capacity: D.optNum, gender: D.optStr, setName: D.str, event: D.eventRef.partial(), members: z.array(member) })) }) }, v1: alloc.getMyGroups,
});
v1Route({
method: 'get', path: '/events/:id/allocations', tag: 'Allocations', summary: 'An event\'s allocation board',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: alloc.getBoard,
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: D.obj({ event: D.anyObj, sets: z.array(allocationSet.extend({ groups: z.array(group), assignments: z.array(D.obj({ attendeeId: D.str, groupId: D.str })) })), attendees: z.array(D.obj({ id: D.str, name: D.str, gender: D.optStr, dateOfBirth: D.optDate, registrationId: D.str, bookedBy: D.str })), leaders: z.array(D.userRef), unnamedPlaces: D.num.describe('Places with no name yet') }) }, v1: alloc.getBoard,
});
v1Route({
method: 'post', path: '/allocation-sets', tag: 'Allocations', summary: 'Start a set of groups',
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], status: 201, body: z.looseObject({ eventId: id }), v1: alloc.createSet,
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], status: 201, body: z.looseObject({ eventId: id }), documented: { body: D.obj({ eventId: D.str, name: D.str.describe('e.g. Cabins, Buses'), showOnTicket: D.optBool }), response: allocationSet }, v1: alloc.createSet,
});
const setScope = cs.scopeEvent({ resolve: 'allocationSet' });
const groupScope = cs.scopeEvent({ resolve: 'allocationGroup' });
v1Route({ method: 'patch', path: '/allocation-sets/:id', tag: 'Allocations', summary: 'Change a set', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, v1: alloc.updateSet });
v1Route({ method: 'delete', path: '/allocation-sets/:id', tag: 'Allocations', summary: 'Delete a set', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, v1: alloc.deleteSet });
v1Route({ method: 'get', path: '/allocation-sets/:id/lists', tag: 'Allocations', summary: 'A set\'s lists (who is in each group)', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, query: filters('medical'), v1: alloc.getSetLists });
v1Route({ method: 'post', path: '/allocation-sets/:id/groups', tag: 'Allocations', summary: 'Add a group', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], status: 201, params: idParams, body: loose, v1: alloc.createGroup });
v1Route({ method: 'put', path: '/allocation-sets/:id/assignments', tag: 'Allocations', summary: 'Put people in groups', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, v1: alloc.assign });
v1Route({ method: 'delete', path: '/allocation-sets/:id/assignments', tag: 'Allocations', summary: 'Empty every group', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, v1: alloc.clearAssignments });
v1Route({ method: 'post', path: '/allocation-sets/:id/auto', tag: 'Allocations', summary: 'Fill the groups automatically', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, v1: alloc.autoAssign });
v1Route({ method: 'patch', path: '/allocation-groups/:id', tag: 'Allocations', summary: 'Change a group', access: 'supervisor', campusScope: 'scopeEvent(allocationGroup)', middleware: [groupScope], params: idParams, body: loose, v1: alloc.updateGroup });
v1Route({ method: 'delete', path: '/allocation-groups/:id', tag: 'Allocations', summary: 'Delete a group', access: 'supervisor', campusScope: 'scopeEvent(allocationGroup)', middleware: [groupScope], params: idParams, v1: alloc.deleteGroup });
v1Route({ method: 'patch', path: '/allocation-sets/:id', tag: 'Allocations', summary: 'Change a set', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, documented: { body: D.obj({ name: D.optStr, showOnTicket: D.optBool }), response: allocationSet }, v1: alloc.updateSet });
v1Route({ method: 'delete', path: '/allocation-sets/:id', tag: 'Allocations', summary: 'Delete a set', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, documented: { response: D.message }, v1: alloc.deleteSet });
v1Route({ method: 'get', path: '/allocation-sets/:id/lists', tag: 'Allocations', summary: 'A set\'s lists (who is in each group)', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, query: filters('medical'), documented: { response: D.obj({ set: D.obj({ id: D.str, name: D.str }), event: D.anyObj, groups: z.array(D.obj({ id: D.str, name: D.str, capacity: D.optNum, gender: D.optStr, leader: D.optStr, members: z.array(member) })) }) }, v1: alloc.getSetLists });
v1Route({ method: 'post', path: '/allocation-sets/:id/groups', tag: 'Allocations', summary: 'Add a group', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], status: 201, params: idParams, body: loose, documented: { body: groupInput, response: group }, v1: alloc.createGroup });
v1Route({ method: 'put', path: '/allocation-sets/:id/assignments', tag: 'Allocations', summary: 'Put people in groups', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, documented: { body: D.obj({ attendeeId: D.str, groupId: D.optStr.describe('null takes them out') }), response: D.message }, v1: alloc.assign });
v1Route({ method: 'delete', path: '/allocation-sets/:id/assignments', tag: 'Allocations', summary: 'Empty every group', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, documented: { response: D.message }, v1: alloc.clearAssignments });
v1Route({ method: 'post', path: '/allocation-sets/:id/auto', tag: 'Allocations', summary: 'Fill the groups automatically', access: 'supervisor', campusScope: 'scopeEvent(allocationSet)', middleware: [setScope], params: idParams, body: loose, documented: { body: D.obj({ mode: z.enum(['together', 'split']).optional().describe('Keep each booking together, or spread them') }), response: D.obj({ placed: D.num, unplaced: D.num, message: D.str }) }, v1: alloc.autoAssign });
v1Route({ method: 'patch', path: '/allocation-groups/:id', tag: 'Allocations', summary: 'Change a group', access: 'supervisor', campusScope: 'scopeEvent(allocationGroup)', middleware: [groupScope], params: idParams, body: loose, documented: { body: groupInput, response: group }, v1: alloc.updateGroup });
v1Route({ method: 'delete', path: '/allocation-groups/:id', tag: 'Allocations', summary: 'Delete a group', access: 'supervisor', campusScope: 'scopeEvent(allocationGroup)', middleware: [groupScope], params: idParams, documented: { response: D.message }, v1: alloc.deleteGroup });
+2
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import * as D from '../docs';
import type { Request } from 'express';
import { defineRoute, type ApiUser } from '../route';
import { single } from '../paging';
@@ -148,6 +149,7 @@ export const copyFrom = defineRoute({
middleware: [campusScope.scopeEvent({ need: 'manage' }), campusScope.scopeBody([['fromEventId', 'event']])],
params: idParams,
body: z.object({ fromEventId: id }),
documented: { response: D.obj({ sets: D.num.describe('Allocation sets copied'), groups: D.num.describe('Groups copied'), costs: D.num.describe('Costs copied (admins)') }) },
response: single(z.looseObject({})),
async handler({ params, body, user }) {
return { data: JSON.parse(JSON.stringify(await copySetupFrom(params.id, body.fromEventId, actorOf(user)) ?? {})) };
+102 -53
View File
@@ -5,6 +5,7 @@
import { z } from 'zod';
import { id, idParams } from '../schemas';
import { v1Route } from '../viaV1';
import * as D from '../docs';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const cs = require('../../utils/campusScope') as {
@@ -22,71 +23,119 @@ 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' })] };
// ─── Documented shapes (docs.ts) ─────────────────────────────────────────────
const child = D.obj({
id: D.str, firstName: D.str, lastName: D.str, name: D.str, dateOfBirth: D.optDate, age: D.optNum, gender: D.optStr,
allergies: D.optStr, hasMedical: D.bool, hasCustody: D.bool.describe('There are custody notes'),
medicalNotes: D.optStr.describe('Only where notes may be shown'), custodyNotes: D.optStr, lastCheckInAt: D.optDate,
});
const childInput = D.obj({
firstName: D.str, lastName: D.optStr, dateOfBirth: D.optDate, gender: D.optStr.describe('male or female (anything else: not given)'),
allergies: D.optStr, medicalNotes: D.optStr, custodyNotes: D.optStr.describe('Kept encrypted, like the medical notes'),
});
const adultInput = D.obj({ name: D.str, phone: D.optStr, relationship: D.optStr });
const adult = adultInput.extend({ id: D.str });
const family = D.obj({
id: D.str, name: D.str,
parents: z.array(D.obj({ userId: D.str, name: D.str, phone: D.optStr, email: D.optStr })),
children: z.array(child), adults: z.array(adult).describe('Others who may collect the children'),
});
const myFamily = D.obj({
family: family.nullable(), qrToken: D.optStr.describe('The family QR code (shown at the desk)'),
codes: z.array(D.anyObj).describe('Collection codes for children checked in now'), pages: z.array(D.anyObj).describe('Recent "come to the room" messages'),
});
const parentInput = D.obj({ name: D.str, email: D.optStr, phone: D.optStr.describe('Email or phone needed: the collection code is sent there') });
const room = D.obj({ id: D.str, name: D.str, minAge: D.optNum, maxAge: D.optNum, capacity: D.optNum, ratio: D.optNum, order: D.optNum, leaderNames: z.array(D.str).optional(), leaderIds: D.ids.optional() });
const board = D.obj({
event: D.anyObj, occasion: D.str.describe('The day or session it\'s for'), sessionId: D.optStr, sessionLabel: D.optStr,
rooms: z.array(room), checkIns: z.array(D.anyObj).describe('Children checked in, with room and code'),
});
const roleShift = D.obj({ startsAt: D.date, endsAt: D.date, text: D.str, hours: D.num });
const role = D.obj({
id: D.str, eventId: D.str, sessionId: D.optStr, sessionLabel: D.optStr, name: D.str, description: D.str,
startsAt: D.optDate, endsAt: D.optDate, shift: roleShift, kidsRoom: D.anyObj.nullish(), order: D.num, past: D.bool,
needed: D.optNum, filled: D.optNum,
mine: D.obj({ slotId: D.str, checkedInAt: D.optDate, swapId: D.optStr }).nullable().describe('My place in it'),
coverNeeded: z.array(D.obj({ swapId: D.str, note: D.str })),
volunteers: z.array(D.obj({ slotId: D.str, userId: D.str, name: D.str, phone: D.optStr, email: D.optStr, assignedBy: D.optStr, checkedInAt: D.optDate, checkedInBy: D.optStr, checkInMethod: D.optStr, swapOpen: D.bool, hasTicket: D.bool })).optional().describe('Staff only'),
});
const roster = D.obj({
event: D.anyObj, sessions: z.array(D.obj({ id: D.str, label: D.str, startsAt: D.date, endsAt: D.date })), roles: z.array(role),
totals: D.obj({ needed: D.optNum, filled: D.optNum, fillRate: D.optNum.describe('%') }),
});
const roleInput = D.obj({
name: D.str, description: D.optStr, sessionId: D.optStr.describe('A multi-day event: the session'), startsAt: D.optDate, endsAt: D.optDate.describe('Default: the event\'s or session\'s times'),
needed: D.numeric.nullish().describe('How many people'), kidsRoomId: D.optStr.describe('Serving in a kids room: counts as a leader on duty'), order: D.numeric.optional(),
});
const roleRecord = D.obj({ id: D.str, eventId: D.str, name: D.str, description: D.optStr, sessionId: D.optStr, startsAt: D.optDate, endsAt: D.optDate, needed: D.optNum, kidsRoomId: D.optStr, order: D.num });
const signedUp = D.obj({ slotId: D.str, ticket: D.bool.describe('A free volunteer ticket was issued') });
// ─── 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 });
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', documented: { response: myFamily }, v1: kids.getMine });
v1Route({ method: 'post', path: '/me/family', tag: 'Kids', summary: 'Start my family', access: 'user', status: 201, body: loose, documented: { body: D.obj({ name: D.optStr }), response: myFamily }, v1: kids.createMine });
v1Route({ method: 'patch', path: '/me/family', tag: 'Kids', summary: 'Rename my family', access: 'user', body: loose, documented: { body: D.obj({ name: D.str }), response: family }, v1: kids.renameMine });
v1Route({ method: 'delete', path: '/me/family', tag: 'Kids', summary: 'Leave my family', access: 'user', documented: { response: D.anyObj.describe('What happened: left, or the family was deleted as the last parent') }, v1: kids.leaveMine });
v1Route({ method: 'post', path: '/me/family/children', tag: 'Kids', summary: 'Add a child', access: 'user', status: 201, body: loose, documented: { body: childInput, response: child }, v1: kids.saveMyChild });
v1Route({ method: 'put', path: '/me/family/children/:id', tag: 'Kids', summary: 'Change a child', access: 'user', params: idParams, body: loose, documented: { body: childInput.partial(), response: child }, v1: kids.saveMyChild });
v1Route({ method: 'delete', path: '/me/family/children/:id', tag: 'Kids', summary: 'Remove a child', access: 'user', params: idParams, documented: { response: D.message }, 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, documented: { body: adultInput, response: adult }, v1: kids.saveMyAdult });
v1Route({ method: 'put', path: '/me/family/adults/:id', tag: 'Kids', summary: 'Change an adult', access: 'user', params: idParams, body: loose, documented: { body: adultInput, response: adult }, v1: kids.saveMyAdult });
v1Route({ method: 'delete', path: '/me/family/adults/:id', tag: 'Kids', summary: 'Remove an adult', access: 'user', params: idParams, documented: { response: D.message }, 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 });
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], documented: { response: z.array(D.event.partial().extend({ kidsCheckIn: D.optBool })) }, 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], documented: { response: z.array(D.event.partial()) }, 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, documented: { response: D.obj({ event: D.obj({ id: D.str, title: D.str, scheduleType: D.optStr, kidsCheckIn: D.bool, kidsOptionId: D.optStr, kidsRoomSetId: D.optStr }), options: z.array(D.eventOption.partial()), sets: z.array(D.anyObj).describe('Allocation sets that can be the rooms') }) }, 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, documented: { body: D.obj({ enabled: D.optBool, optionId: D.optStr.describe('The kids ticket (`new` makes a free one; null: none)'), roomSetId: D.optStr.describe('The allocation set used as rooms') }).describe('Only what changes'), response: D.anyObj.describe('The saved set-up, as GET answers') }, 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, documented: { response: board }, 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, documented: { response: z.array(family) }, 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, documented: { body: D.obj({ name: D.optStr, parent: parentInput, children: z.array(childInput).max(12).optional() }), response: family }, 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, documented: { response: family }, 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 });
v1Route({ method: 'get', path: '/events/:id/kids/families/:familyId', tag: 'Kids', summary: 'One family', access: 'staff', ...view, params: familyParams, map: asEvent, documented: { response: family }, 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, documented: { body: parentInput, response: family }, 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, documented: { body: childInput, response: child }, 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, documented: { body: childInput.partial(), response: child }, 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, documented: { body: adultInput, response: family }, 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, documented: { body: D.obj({ familyId: D.str, sessionId: D.optStr, children: z.array(D.obj({ childId: D.str, roomId: D.optStr })).describe('Who, and into which room (default: by age)'), sendToAdultId: D.optStr.describe('Send the code to this adult too') }), response: D.obj({ code: D.str.describe('The collection code'), qr: D.str, occasion: D.str, family: D.obj({ id: D.str, name: D.str }), checkIns: z.array(D.obj({ id: D.str, name: D.str, firstName: D.str, room: D.anyObj.nullish(), already: D.bool, sticker: D.optStr.describe('Write this on the sticker') })) }) }, 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, documented: { body: D.obj({ code: D.str, checkInIds: D.ids.optional().describe('Just these children (default: all on the code)'), collectedBy: D.optStr.describe('Who collected them'), roomId: D.optStr, sessionId: D.optStr }), response: D.obj({ checkedOut: z.array(D.obj({ id: D.str, name: D.str, room: D.optStr })) }) }, 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, documented: { response: D.obj({ code: D.str, found: D.bool, family: D.anyObj.nullish(), children: z.array(D.obj({ id: D.str, name: D.str, room: D.anyObj.nullish(), checkedOutAt: D.optDate, collectedBy: D.optStr })) }) }, 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, documented: { body: D.obj({ roomId: D.str, sessionId: D.optStr, on: D.optBool.describe('false: off duty') }), response: D.obj({ roomId: D.str, on: D.bool }) }, 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, documented: { response: board.extend({ families: z.array(family), codes: z.array(D.anyObj).describe('Codes reserved for this device'), downloadedAt: D.date }) }, 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, documented: { body: D.obj({ deviceId: D.str, sessionId: D.optStr, checkIns: z.array(D.anyObj).optional(), checkOuts: z.array(D.anyObj).optional() }), response: D.obj({ checkedIn: D.num, checkedOut: D.num, conflicts: z.array(D.anyObj) }) }, 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 });
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, documented: { body: D.obj({ reason: D.str, collectedBy: D.str }), response: D.obj({ id: D.str, checkedOutAt: D.date }) }, 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, documented: { body: D.obj({ message: D.optStr }), response: D.obj({ sent: z.array(D.str).describe('Channels used'), text: D.str }) }, 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, documented: { body: D.obj({ roomId: D.str }), response: D.obj({ id: D.str, roomId: D.str }) }, 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, documented: { response: D.obj({ resolved: D.num }) }, 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 });
v1Route({ method: 'get', path: '/me/volunteering', tag: 'Volunteers', summary: 'Where I\'m serving', access: 'user', documented: { response: D.obj({ slots: z.array(D.obj({ slotId: D.str, role: D.anyObj, event: D.anyObj, shift: roleShift, checkedInAt: D.optDate, canCheckIn: D.bool, past: D.bool, swapId: D.optStr, hasTicket: D.bool })), cover: z.array(D.anyObj).describe('Slots others want covered'), events: z.array(D.obj({ id: D.str, title: D.str, startDate: D.date, endDate: D.date, location: D.optStr, roles: D.num })) }) }, 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, documented: { response: roster }, 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, documented: { response: signedUp }, v1: vol.signUp });
v1Route({ method: 'delete', path: '/volunteer-slots/:id', tag: 'Volunteers', summary: 'Stop serving in a slot', access: 'user', params: idParams, documented: { response: D.message }, 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, documented: { body: D.obj({ note: D.optStr }), response: D.obj({ swapId: D.str }) }, v1: vol.requestSwap });
v1Route({ method: 'delete', path: '/volunteer-swaps/:id', tag: 'Volunteers', summary: 'Cancel a swap request', access: 'user', params: idParams, documented: { response: D.obj({ cancelled: z.literal(true) }) }, v1: vol.cancelSwap });
v1Route({ method: 'post', path: '/volunteer-swaps/:id/accept', tag: 'Volunteers', summary: 'Take someone\'s slot', access: 'user', params: idParams, body: loose, documented: { response: D.obj({ slotId: D.str, role: D.str, event: D.str }) }, 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, documented: { body: D.obj({ qr: D.str.describe('What the QR code holds') }), response: D.obj({ event: D.obj({ id: D.str, title: D.str }), checkedIn: z.array(D.obj({ role: D.str, kidsRoom: D.optStr })), already: z.array(D.str) }) }, 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 });
v1Route({ method: 'get', path: '/volunteering/events', tag: 'Volunteers', summary: 'Events with volunteer roles', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('all'), documented: { response: z.array(D.obj({ id: D.str, title: D.str, startDate: D.date, endDate: D.date, scheduleType: D.optStr, campusId: D.optStr, roles: D.num })) }, 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'), documented: { response: z.array(D.obj({ userId: D.str.optional(), name: D.str, hours: D.num, shifts: D.optNum })) }, 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, documented: { response: roster }, 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, documented: { response: D.obj({ token: D.str, qr: D.str.describe('Put this in the QR code') }) }, v1: vol.getQr });
v1Route({ method: 'get', path: '/events/:id/volunteers/settings', tag: 'Volunteers', summary: 'Volunteer set-up', access: 'supervisor', ...view, params: idParams, map: asEvent, documented: { response: D.obj({ event: D.obj({ id: D.str, title: D.str, volunteerOptionId: D.optStr }), options: z.array(D.eventOption.partial()), kidsRooms: z.array(D.anyObj) }) }, 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, documented: { body: D.obj({ optionId: D.optStr.describe('The free volunteer ticket (null: none)') }), response: D.anyObj.describe('The saved set-up, as GET answers') }, 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, documented: { body: roleInput, response: roleRecord }, 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 });
v1Route({ method: 'patch', path: '/volunteer-roles/:id', tag: 'Volunteers', summary: 'Change a role', access: 'supervisor', ...roleManage, params: idParams, body: loose, documented: { body: roleInput.partial(), response: roleRecord }, v1: vol.updateRole });
v1Route({ method: 'delete', path: '/volunteer-roles/:id', tag: 'Volunteers', summary: 'Delete a role', access: 'supervisor', ...roleManage, params: idParams, documented: { response: D.obj({ message: D.str, volunteers: D.num.describe('People who were in it (told by message)') }) }, 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, documented: { body: D.obj({ userId: D.str }), response: signedUp }, 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 });
v1Route({ method: 'post', path: '/volunteer-slots/:id/check-in', tag: 'Volunteers', summary: 'Check a volunteer in', access: 'staff', ...slotScope, params: idParams, body: loose, documented: { body: D.obj({ undo: D.optBool }), response: D.obj({ slotId: D.str, changed: D.bool }) }, 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, documented: { response: D.message }, v1: vol.remove });
+112 -56
View File
@@ -6,6 +6,7 @@
import { z } from 'zod';
import { id, idParams } from '../schemas';
import { listData, v1Route } from '../viaV1';
import * as D from '../docs';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const rateLimit = require('express-rate-limit') as (opts: object) => any;
@@ -33,38 +34,88 @@ const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.param
// Links in messages are credentials, so guessing stays slow (as on v1).
const linkLimiter = () => rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
// ─── Documented shapes (docs.ts) ─────────────────────────────────────────────
const providerRow = D.obj({ id: D.str.describe('yoco, payfast'), label: D.str });
const providerSettings = D.obj({
id: D.str, label: D.str, enabled: D.bool, configured: D.bool.describe('Its keys are set up'),
capabilities: D.anyObj.describe('What the provider can do, e.g. look payments up'),
});
const openCheckout = D.obj({
id: D.str, provider: D.str, providerLabel: D.str, canCheck: D.bool.describe('The provider can be asked whether it was paid'),
amount: D.rand, description: D.str, type: D.optStr.describe('registration, donation, sponsorship, season_pass…'),
registrationId: D.optStr, payer: D.optStr, createdAt: D.date,
});
const payPage = D.obj({
id: D.str, status: z.enum(['open', 'paid']), amount: D.rand, description: D.str,
provider: D.optStr.describe('The provider already chosen, if any'), providers: z.array(providerRow), cancelUrl: D.optStr,
redirectUrl: D.optStr, form: D.anyObj.nullish().describe('A signed form to post (PayFast)'),
});
const paymentFull = D.payment.extend({
registration: D.registration.nullish(),
generatedTickets: z.array(D.ticket).optional().describe('Tickets issued because this paid it in full'),
formsNeeded: D.optNum.describe('Attendee forms still holding the tickets back (1.52.0)'),
donationSplit: D.payment.optional().describe('What was paid over the balance, kept as a donation'),
});
const cashupLineInput = D.obj({
method: D.str.describe('cash, card, eft…'),
bankAmount: D.optRand, onHandAmount: D.optRand.describe('Cash only'), pendingAmount: D.optRand.describe('Not cash'), pendingReference: D.optStr,
writeOffs: z.array(D.obj({ category: D.str, amount: D.optRand, reason: D.optStr })).optional(), notes: D.optStr,
});
const cashupRecord = D.obj({ id: D.str, eventId: D.str, action: D.optStr.describe('close, quick_close, reopen'), notes: D.optStr, createdAt: D.date, performedBy: D.userRef.nullish() });
const providerTransaction = D.obj({
id: D.str, provider: D.str, providerLabel: D.str, externalId: D.str.describe('The provider\'s id'),
amount: D.optNum.describe('Cents'), currency: D.optStr, createdDate: D.optDate, checkoutId: D.optStr, methodType: D.optStr,
reconciled: D.bool, ignored: D.bool, paymentId: D.optStr, note: D.optStr.describe('Why it needs a look'), metadata: D.anyObj.nullish(),
});
const txAnswer = <T extends z.ZodType>(data: T) => D.obj({ success: D.bool, data, message: D.optStr });
const waitlistEntry = D.obj({
id: D.str, eventId: D.str, eventOptionId: D.str, variantId: D.optStr, sessionId: D.optStr, userId: D.str,
quantity: D.num, position: D.num.describe('Place in the queue'), status: D.str.describe('waiting, offered, claimed, expired, removed, left'),
offeredQuantity: D.optNum, offeredAt: D.optDate, offerExpiresAt: D.optDate, claimedAt: D.optDate, registrationId: D.optStr, createdAt: D.date,
});
const consentRow = D.obj({
name: D.str.describe('The young person'), status: D.str.describe('not_requested, pending, given, declined'),
parentName: D.optStr, parentEmail: D.optStr, parentPhone: D.optStr, request: D.anyObj.nullish(),
});
const feedbackField = D.obj({ id: D.str.optional(), type: D.str.describe('rating, multiple_choice, text, yes_no…'), label: D.str, isRequired: D.optBool, helpText: D.optStr, options: z.array(D.str).nullish() });
const feedbackForm = D.obj({
id: D.str, eventId: D.str, sendAt: D.optDate, sentAt: D.optDate, audience: D.str.describe('checked_in or everyone'), anonymous: D.bool, intro: D.optStr,
fields: z.array(feedbackField), invited: D.optNum, responses: z.unknown().optional().describe('Answers per question'),
}).nullable().describe('null when the event has no feedback form');
const UNSCOPED = 'unscopedOnly (admins and unscoped supervisors)';
// ─── Payments ────────────────────────────────────────────────────────────────
v1Route({
method: 'get', path: '/payments/providers', tag: 'Payments', summary: 'The payment providers switched on',
access: 'public', v1: providers.listPaymentProviders,
access: 'public', documented: { response: D.obj({ providers: z.array(providerRow), minimumAmount: D.rand.describe('Smallest online payment') }) }, v1: providers.listPaymentProviders,
});
v1Route({
method: 'get', path: '/payments/providers/settings', tag: 'Payments', summary: 'Payment provider settings',
description: 'Admins. Keys are masked.', access: 'admin', v1: providers.getPaymentProviderSettings,
description: 'Admins. Keys are masked.', access: 'admin', documented: { response: D.obj({ providers: z.array(providerSettings), minimumAmount: D.rand }) }, v1: providers.getPaymentProviderSettings,
});
v1Route({
method: 'put', path: '/payments/providers/settings', tag: 'Payments', summary: 'Change payment provider settings',
access: 'admin', body: z.looseObject({}), v1: providers.updatePaymentProviderSettings,
access: 'admin', body: z.looseObject({}), documented: { body: D.obj({ providers: z.array(D.obj({ id: D.str, enabled: D.bool })).min(1).describe('Every provider, in the order to offer them') }), response: D.obj({ message: D.str, providers: z.array(providerSettings) }) }, v1: providers.updatePaymentProviderSettings,
});
v1Route({
method: 'get', path: '/payments/open-checkouts', tag: 'Payments', summary: 'Checkouts started but not finished',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], v1: providers.listOpenCheckouts,
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], documented: { response: D.obj({ checkouts: z.array(openCheckout), days: D.num.describe('How far back it looks') }) }, v1: providers.listOpenCheckouts,
});
v1Route({
method: 'post', path: '/payments/open-checkouts/check', tag: 'Payments', summary: 'Ask the provider about open checkouts',
description: 'Records any that were paid (for when a webhook was missed).',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], body: z.looseObject({}), v1: providers.checkCheckoutStatus,
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], body: z.looseObject({}), documented: { body: D.obj({ ids: D.ids.optional().describe('Checkouts to ask about (default: every open one), at most 200') }), response: D.obj({ results: z.array(D.anyObj), recorded: D.num.describe('Payments found and recorded'), message: D.str }) }, v1: providers.checkCheckoutStatus,
});
v1Route({
method: 'get', path: '/payments/stats', tag: 'Payments', summary: 'Payment totals', access: 'admin', query: paymentFilters, v1: pay.getPaymentStats,
method: 'get', path: '/payments/stats', tag: 'Payments', summary: 'Payment totals', access: 'admin', query: paymentFilters, documented: { response: D.obj({ totalToday: D.rand, totalWeek: D.rand, totalMonth: D.rand }) }, v1: pay.getPaymentStats,
});
v1Route({
method: 'get', path: '/payments', tag: 'Payments', summary: 'Every payment',
description: 'For the events you may see. `page` and `limit` page it (otherwise everything); see v1 for the filters it takes.',
access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: paymentFilters, v1: pay.getPayments,
access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: paymentFilters, documented: { response: D.v1Page(D.payment.extend({ registration: D.registration.nullish() })) }, v1: pay.getPayments,
});
v1Route({
method: 'post', path: '/payments', tag: 'Payments', summary: 'Record a payment',
@@ -72,129 +123,129 @@ v1Route({
access: 'supervisor', campusScope: 'scopeBody(registrationId, eventId; deny orphans)', status: 201,
middleware: [cs.scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' })],
body: z.looseObject({ registrationId: id.nullish(), eventId: id.nullish(), amount: z.union([z.number(), z.string()]), method: z.string().max(20).optional() }),
v1: pay.createPayment,
documented: { body: D.obj({ registrationId: D.optStr, eventId: D.optStr.describe('A donation: the event'), amount: D.numeric, method: D.str.describe('cash, card, eft or other'), isDonation: D.optBool, paidAt: D.optDate.describe('Backdate it'), userId: D.optStr.describe('A donation: who gave it') }), response: paymentFull }, v1: pay.createPayment,
});
v1Route({
method: 'post', path: '/payments/refunds', tag: 'Payments', summary: 'Refund a payment',
access: 'supervisor', campusScope: 'scopeBody(paymentId, registrationId; manage, deny orphans)', status: 201,
middleware: [cs.scopeBody([['paymentId', 'payment'], ['registrationId', 'registration']], { need: 'manage', orphan: 'deny' })],
body: z.looseObject({ paymentId: id.optional(), registrationId: id.optional() }), v1: pay.createRefund,
body: z.looseObject({ paymentId: id.optional(), registrationId: id.optional() }), documented: { body: D.obj({ paymentId: D.optStr.describe('The payment refunded (or registrationId)'), registrationId: D.optStr, userId: D.str, amount: D.numeric.describe('Positive; stored as a negative payment'), method: D.str, reason: D.optStr }), response: D.payment }, v1: pay.createRefund,
});
v1Route({
method: 'post', path: '/payments/donations/assign', tag: 'Payments', summary: 'Put a donation towards a registration',
access: 'supervisor', campusScope: 'scopeBody(paymentId deny orphans; registrationId)',
middleware: [cs.scopeBody([['paymentId', 'payment']], { orphan: 'deny' }), cs.scopeBody([['registrationId', 'registration']])],
body: z.looseObject({ paymentId: id, registrationId: id }), v1: pay.assignDonationToRegistration,
body: z.looseObject({ paymentId: id, registrationId: id }), documented: { body: D.obj({ paymentId: D.str.describe('The donation'), registrationId: D.str, amount: D.numeric.optional().describe('Default: as much as both allow') }), response: D.obj({ message: D.optStr, updatedRegistration: D.registration.optional(), generatedTickets: z.array(D.ticket).optional() }) }, v1: pay.assignDonationToRegistration,
});
v1Route({
method: 'post', path: '/payments/donations/unassign', tag: 'Payments', summary: 'Take a donation back off a registration',
access: 'supervisor', campusScope: 'scopeBody(legId; deny orphans)', middleware: [cs.scopeBody([['legId', 'payment']], { orphan: 'deny' })],
body: z.looseObject({ legId: id }), v1: pay.unassignDonationFromRegistration,
body: z.looseObject({ legId: id }), documented: { response: D.obj({ message: D.str, updatedRegistration: D.registration, donationId: D.str }) }, v1: pay.unassignDonationFromRegistration,
});
v1Route({
method: 'post', path: '/donations/checkout', tag: 'Payments', summary: 'Give to an event',
description: 'Starts a checkout for a donation of `amount` to `eventId`; answers where to send the person (`redirectUrl`).',
access: 'user', body: z.looseObject({ eventId: id, amount: z.union([z.number(), z.string()]) }), v1: pay.createCheckout,
access: 'user', body: z.looseObject({ eventId: id, amount: z.union([z.number(), z.string()]) }), documented: { body: D.obj({ eventId: D.str, amount: D.numeric, provider: D.optStr, successUrl: D.optStr, cancelUrl: D.optStr, failureUrl: D.optStr }), response: D.checkout.extend({ amount: D.rand, event: D.eventRef.partial() }) }, v1: pay.createCheckout,
});
v1Route({
method: 'get', path: '/me/payments', tag: 'Me', summary: 'My payments', access: 'user', query: paymentFilters, v1: pay.getUserPayments,
method: 'get', path: '/me/payments', tag: 'Me', summary: 'My payments', access: 'user', query: paymentFilters, documented: { response: D.v1Page(D.payment.extend({ registration: D.registration.nullish() })) }, v1: pay.getUserPayments,
});
v1Route({
method: 'get', path: '/payments/:id', tag: 'Payments', summary: 'One payment',
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams, v1: pay.getPaymentById,
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams, documented: { response: D.payment.extend({ registration: D.registration.nullish() }) }, v1: pay.getPaymentById,
});
v1Route({
method: 'post', path: '/payments/:id/receipt', tag: 'Payments', summary: 'Send the receipt again',
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams,
body: z.looseObject({ channel: z.string().max(20).optional() }), v1: pay.sendReceipt,
body: z.looseObject({ channel: z.string().max(20).optional() }), documented: { response: D.message }, v1: pay.sendReceipt,
});
v1Route({
method: 'get', path: '/registrations/:id/payments', tag: 'Payments', summary: 'A registration\'s payments',
access: 'user', campusScope: 'scopeEvent(registration, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'registration', ownerOk: true })], params: idParams,
response: listData, map: ctx => ({ params: { registrationId: ctx.params.id } }), v1: pay.getPaymentsByRegistration,
response: listData, documented: { response: z.array(D.payment) }, map: ctx => ({ params: { registrationId: ctx.params.id } }), v1: pay.getPaymentsByRegistration,
});
v1Route({
method: 'post', path: '/registrations/:id/payment-link', tag: 'Payments', summary: 'Make a link to pay',
description: 'Supervisors: a checkout for `amount` (part of what\'s owed) for someone else to pay. Answers `redirectUrl` (the link) — or `priceUpdated` when an early-bird price ran out.',
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
body: z.looseObject({ amount: z.union([z.number(), z.string()]).optional() }), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), v1: pay.createCheckout,
body: z.looseObject({ amount: z.union([z.number(), z.string()]).optional() }), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), documented: { body: D.obj({ amount: D.numeric.optional(), donationAmount: D.numeric.optional(), provider: D.optStr, successUrl: D.optStr, cancelUrl: D.optStr, failureUrl: D.optStr }), response: D.checkout.extend({ priceUpdated: D.optBool, newTotal: D.optRand }) }, v1: pay.createCheckout,
});
v1Route({
method: 'post', path: '/registrations/:id/payment-link/send', tag: 'Payments', summary: 'Send someone a link to pay',
description: 'By email and/or WhatsApp, for what\'s still owing.',
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
body: z.looseObject({}), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), v1: pay.sendPaymentLink,
body: z.looseObject({}), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), documented: { body: D.obj({ redirectUrl: D.str.describe('The link to send'), channel: z.enum(['email', 'whatsapp']) }), response: D.obj({ sent: z.literal(true), channel: D.str }) }, v1: pay.sendPaymentLink,
});
v1Route({
method: 'get', path: '/events/:id/payments', tag: 'Payments', summary: 'An event\'s payments',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: pay.getPaymentsByEvent,
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: z.array(D.payment) }, v1: pay.getPaymentsByEvent,
});
v1Route({
method: 'get', path: '/payment-links/:id', tag: 'Payments', summary: 'A pay page',
description: 'What a payment link shows: what\'s owed and the providers to pay with. The id in the link is the credential.',
access: 'public', params: idParams, v1: providers.getPayPage,
access: 'public', params: idParams, documented: { response: payPage }, v1: providers.getPayPage,
});
v1Route({
method: 'post', path: '/payment-links/:id', tag: 'Payments', summary: 'Pay from a pay page',
description: 'Starts a checkout with the chosen provider; answers where to send the person.',
access: 'public', params: idParams, body: z.looseObject({ provider: z.string().max(40).optional() }), v1: providers.choosePayProvider,
access: 'public', params: idParams, body: z.looseObject({ provider: z.string().max(40).optional() }), documented: { response: D.obj({ provider: D.str, redirectUrl: D.optStr, form: D.anyObj.nullish() }) }, v1: providers.choosePayProvider,
});
// ─── Cash-ups ────────────────────────────────────────────────────────────────
const cashupBase = '/events/:id/cashup';
v1Route({
method: 'get', path: '/cashups/audit', tag: 'Cash-ups', summary: 'Cash-up history', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'from', 'to'), v1: cashup.getCashupAudit,
method: 'get', path: '/cashups/audit', tag: 'Cash-ups', summary: 'Cash-up history', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'from', 'to'), documented: { response: z.array(cashupRecord) }, v1: cashup.getCashupAudit,
});
v1Route({
method: 'get', path: cashupBase, tag: 'Cash-ups', summary: 'An event\'s cash-up',
description: 'Income by method, costs, what each drawer should hold, and the saved draft.',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: cashup.getEventCashup,
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, documented: { response: D.obj({ event: D.anyObj, paymentsByMethod: D.anyObj.describe('Income per method'), totalRevenue: D.rand, costs: z.array(D.anyObj), totalCosts: D.rand, expectedCashByMethod: D.anyObj, reconciled: D.anyObj.nullish().describe('The last close, line by line'), netProfit: D.rand, totalDonations: D.rand, unallocatedDonationsTotal: D.rand, discountsTotal: D.optRand, salesByOption: z.array(D.anyObj).optional() }) }, v1: cashup.getEventCashup,
});
v1Route({
method: 'get', path: `${cashupBase}/cash-by-user`, tag: 'Cash-ups', summary: 'Cash taken by each person',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: cashup.getCashByRecordedUser,
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, documented: { response: D.obj({ rows: z.array(D.anyObj).describe('Per person: who, cash recorded, cash counted'), cashActualTotal: D.optRand, cashDenominations: D.anyObj.nullish() }) }, v1: cashup.getCashByRecordedUser,
});
v1Route({
method: 'put', path: `${cashupBase}/person-cash/:userId`, tag: 'Cash-ups', summary: 'Count one person\'s cash',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: z.object({ id, userId: id }),
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id, userId: ctx.params.userId } }), v1: cashup.savePersonCash,
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id, userId: ctx.params.userId } }), documented: { body: D.obj({ denominations: z.array(D.obj({ value: D.num.describe('The note or coin, in rand'), count: D.num })), notes: D.optStr }), response: D.anyObj.describe('The saved count') }, v1: cashup.savePersonCash,
});
v1Route({
method: 'put', path: `${cashupBase}/draft`, tag: 'Cash-ups', summary: 'Save the cash-up as a draft',
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.saveEventCashupDraft,
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, documented: { body: D.obj({ lines: z.array(cashupLineInput), notes: D.optStr }), response: D.message }, v1: cashup.saveEventCashupDraft,
});
v1Route({
method: 'post', path: `${cashupBase}/close`, tag: 'Cash-ups', summary: 'Close the event',
description: 'Admins. Nothing about the event\'s money can change until it\'s reopened.',
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.closeEvent,
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, documented: { body: D.obj({ lines: z.array(cashupLineInput).optional().describe('Per payment method: in the bank, on hand, pending, write-offs (leave out for a quick close)'), notes: D.optStr }), response: cashupRecord }, v1: cashup.closeEvent,
});
v1Route({
method: 'post', path: `${cashupBase}/reopen`, tag: 'Cash-ups', summary: 'Reopen a closed event',
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.reopenEvent,
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, documented: { body: D.obj({ notes: D.optStr.describe('Why') }), response: cashupRecord }, v1: cashup.reopenEvent,
});
v1Route({
method: 'post', path: '/cashup-lines/:id/pending', tag: 'Cash-ups', summary: 'Settle a pending cash-up line',
access: 'admin', params: idParams, body: z.looseObject({}), map: ctx => ({ params: { lineId: ctx.params.id } }), v1: cashup.resolvePending,
access: 'admin', params: idParams, body: z.looseObject({}), map: ctx => ({ params: { lineId: ctx.params.id } }), documented: { body: D.obj({ outcome: z.enum(['received', 'written_off']), amount: D.numeric, category: D.optStr.describe('A write-off: what it was for'), notes: D.optStr }), response: D.anyObj.describe('The settlement recorded') }, v1: cashup.resolvePending,
});
// ─── Provider transactions ───────────────────────────────────────────────────
v1Route({
method: 'get', path: '/provider-transactions', tag: 'Payments', summary: 'Transactions from Yoco and PayFast',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], query: filters('provider'), v1: ptx.getAllProviderTransactions,
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], query: filters('provider'), documented: { response: txAnswer(z.array(providerTransaction)) }, v1: ptx.getAllProviderTransactions,
});
v1Route({
method: 'get', path: '/provider-transactions/unreconciled', tag: 'Payments', summary: 'Provider transactions not matched yet',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], v1: ptx.getUnreconciledProviderTransactions,
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], documented: { response: txAnswer(z.array(providerTransaction)) }, v1: ptx.getUnreconciledProviderTransactions,
});
v1Route({
method: 'post', path: '/provider-transactions/:id/reconcile', tag: 'Payments', summary: 'Match a provider transaction to a registration',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), v1: ptx.reconcileProviderTransaction,
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), documented: { body: D.obj({ registrationId: D.optStr, eventId: D.optStr.describe('As a donation to this event instead'), userId: D.optStr }), response: txAnswer(D.obj({ transaction: providerTransaction, payment: D.payment, generatedTickets: z.array(D.ticket).optional() })) }, v1: ptx.reconcileProviderTransaction,
});
v1Route({
method: 'post', path: '/provider-transactions/:id/ignore', tag: 'Payments', summary: 'Ignore a provider transaction',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), v1: ptx.ignoreProviderTransaction,
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), documented: { response: txAnswer(providerTransaction) }, v1: ptx.ignoreProviderTransaction,
});
// ─── Waitlist ────────────────────────────────────────────────────────────────
@@ -202,73 +253,78 @@ v1Route({
v1Route({
method: 'post', path: '/waitlist', tag: 'Waitlist', summary: 'Join a sold-out event\'s waitlist',
description: 'Signed in, or with a name and email or phone. When places free up, people are offered them in order.',
access: 'optional', status: 201, body: z.looseObject({ eventId: id }), v1: waitlist.joinWaitlist,
access: 'optional', status: 201, body: z.looseObject({ eventId: id }), documented: { body: D.obj({ eventId: D.str, eventOptionId: D.str, variantId: D.optStr, sessionId: D.optStr.describe('Per-session tickets: the session'), quantity: D.numeric, guestName: D.optStr.describe('Not signed in'), guestEmail: D.optStr, guestPhone: D.optStr }), response: waitlistEntry.extend({ ahead: D.num.describe('People ahead'), message: D.str }) }, v1: waitlist.joinWaitlist,
});
v1Route({ method: 'get', path: '/me/waitlist', tag: 'Me', summary: 'Waitlists I\'m on', access: 'user', v1: waitlist.myWaitlist });
v1Route({ method: 'get', path: '/me/waitlist', tag: 'Me', summary: 'Waitlists I\'m on', access: 'user', documented: { response: z.array(waitlistEntry) }, v1: waitlist.myWaitlist });
v1Route({
method: 'get', path: '/waitlist/claims/:token', tag: 'Waitlist', summary: 'A waitlist offer',
description: 'What an offer link shows (the token is the credential). Register with `waitlistToken` to take it.',
access: 'public', middleware: [linkLimiter()], params: tokenParams, v1: waitlist.claimInfo,
access: 'public', middleware: [linkLimiter()], params: tokenParams, documented: { response: D.obj({ eventId: D.optStr, eventTitle: D.optStr, eventStart: D.optDate, eventOptionId: D.str, optionName: D.str, variantId: D.optStr, variantName: D.optStr, sessionId: D.optStr, sessionName: D.optStr, sessionStart: D.optDate, lineName: D.str, quantity: D.optNum.describe('Places offered'), requested: D.num, expiresAt: D.optDate, name: D.optStr }) }, v1: waitlist.claimInfo,
});
v1Route({
method: 'get', path: '/events/:id/waitlist', tag: 'Waitlist', summary: 'An event\'s waitlist',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, query: filters('all'), map: asEvent, v1: waitlist.eventWaitlist,
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, query: filters('all'), map: asEvent, documented: { response: z.array(waitlistEntry.extend({ user: D.userSummary.nullish() })) }, v1: waitlist.eventWaitlist,
});
for (const [action, summary, handler] of [['offer', 'Offer someone their places now', waitlist.offerManually], ['move', 'Move someone up or down the waitlist', waitlist.moveEntry], ['remove', 'Take someone off the waitlist', waitlist.removeEntry]] as const) {
v1Route({
method: 'post', path: `/waitlist/:id/${action}`, tag: 'Waitlist', summary,
access: 'supervisor', campusScope: 'scopeEvent(waitlistEntry)', middleware: [cs.scopeEvent({ resolve: 'waitlistEntry' })], params: idParams, body: z.looseObject({}), v1: handler,
documented: {
body: action === 'offer' ? D.obj({ quantity: D.numeric.optional().describe('Places to offer (default: all they asked for)') })
: action === 'move' ? D.obj({ direction: z.enum(['up', 'down']) }) : D.anyObj,
response: D.message,
},
});
}
v1Route({ method: 'delete', path: '/waitlist/:id', tag: 'Waitlist', summary: 'Leave a waitlist', access: 'user', params: idParams, v1: waitlist.leaveWaitlist });
v1Route({ method: 'delete', path: '/waitlist/:id', tag: 'Waitlist', summary: 'Leave a waitlist', access: 'user', params: idParams, documented: { response: D.message }, v1: waitlist.leaveWaitlist });
// ─── Sponsorship ─────────────────────────────────────────────────────────────
v1Route({
method: 'get', path: '/events/:id/sponsorship', tag: 'Sponsorship', summary: 'Sponsoring places at an event',
description: 'Whether it\'s on, and the suggested amounts.', access: 'public', params: idParams, map: asEvent, v1: sponsor.getEventSponsorship,
description: 'Whether it\'s on, and the suggested amounts.', access: 'public', params: idParams, map: asEvent, documented: { response: D.obj({ enabled: D.bool, open: D.optBool, spotPrice: D.optRand, showProgress: D.optBool, goalSpots: D.optNum, spotsFunded: D.optNum, sponsors: z.unknown().optional() }) }, v1: sponsor.getEventSponsorship,
});
v1Route({
method: 'get', path: '/events/:id/sponsorship/pool', tag: 'Sponsorship', summary: 'The sponsorship pool',
description: 'What\'s been given and what\'s been used.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: sponsor.getPool,
description: 'What\'s been given and what\'s been used.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: D.obj({ eventId: D.str, spotPrice: D.rand, goalSpots: D.optNum }).describe('Plus the pool: given, used and places funded') }, v1: sponsor.getPool,
});
v1Route({
method: 'post', path: '/sponsorships/checkout', tag: 'Sponsorship', summary: 'Give towards places',
description: 'Starts a checkout; answers where to send the person.', access: 'optional', body: z.looseObject({ eventId: id }), v1: sponsor.createSponsorCheckout,
description: 'Starts a checkout; answers where to send the person.', access: 'optional', body: z.looseObject({ eventId: id }), documented: { body: D.obj({ eventId: D.str, spots: D.numeric.optional().describe('Places to fund (or amount)'), amount: D.numeric.optional(), anonymous: D.optBool, sponsorName: D.optStr, message: D.optStr, name: D.optStr.describe('Not signed in'), email: D.optStr, phone: D.optStr, provider: D.optStr }), response: D.checkout.extend({ amount: D.rand }) }, v1: sponsor.createSponsorCheckout,
});
// ─── Consent ─────────────────────────────────────────────────────────────────
v1Route({
method: 'get', path: '/events/:id/consent', tag: 'Consent', summary: 'Parental consent for an event',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: consent.listEventConsent,
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: D.obj({ event: D.anyObj, counts: z.record(z.string(), z.number()).describe('Status → how many'), rows: z.array(consentRow) }) }, v1: consent.listEventConsent,
});
v1Route({
method: 'get', path: '/events/:id/consent/medical', tag: 'Consent', summary: 'Medical notes from consent forms',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: consent.medicalList,
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: D.obj({ event: D.eventRef.partial(), rows: z.array(D.obj({ name: D.str, dateOfBirth: D.optDate, parentName: D.optStr, parentPhone: D.optStr, parentEmail: D.optStr, medical: D.anyObj.nullish(), emergency: D.anyObj.nullish(), photoConsent: D.optBool, purged: D.bool.describe('Medical details removed after the event') })) }) }, v1: consent.medicalList,
});
v1Route({
method: 'post', path: '/events/:id/consent/resend-pending', tag: 'Consent', summary: 'Remind everyone who hasn\'t answered',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: z.looseObject({}), map: asEvent, v1: consent.resendPending,
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: z.looseObject({}), map: asEvent, documented: { response: D.obj({ message: D.str, sent: D.num, pending: D.num }) }, v1: consent.resendPending,
});
v1Route({
method: 'post', path: '/events/:id/consent/rerequest', tag: 'Consent', summary: 'Ask for consent again',
description: 'After the consent wording changed.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams,
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id }, body: ctx.req.body }), v1: consent.rerequestConsent,
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id }, body: ctx.req.body }), documented: { response: D.obj({ message: D.str, created: D.num }) }, v1: consent.rerequestConsent,
});
v1Route({
method: 'post', path: '/consent-requests/:id/resend', tag: 'Consent', summary: 'Send a consent request again',
access: 'user', campusScope: 'scopeEvent(consentRequest, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'consentRequest', ownerOk: true })], params: idParams,
body: z.looseObject({}), v1: consent.resendRequest,
body: z.looseObject({}), documented: { response: D.message }, v1: consent.resendRequest,
});
v1Route({
method: 'get', path: '/consent/:token', tag: 'Consent', summary: 'A consent form',
description: 'What the link in a consent request shows (the token is the parent\'s credential).',
access: 'public', middleware: [linkLimiter()], params: tokenParams, v1: consent.getConsent,
access: 'public', middleware: [linkLimiter()], params: tokenParams, documented: { response: D.obj({ status: D.str, superseded: D.bool, expired: D.bool, respondedAt: D.optDate, attendeeName: D.str, parentName: D.optStr, consentText: D.str, textVersion: D.optNum, event: D.obj({ title: D.str, startDate: D.date, endDate: D.date, location: D.optStr }), collect: D.obj({ medical: D.bool, emergency: D.bool, photo: D.bool }).describe('What the form asks for') }) }, v1: consent.getConsent,
});
v1Route({
method: 'post', path: '/consent/:token', tag: 'Consent', summary: 'Answer a consent form',
access: 'public', middleware: [linkLimiter()], params: tokenParams, body: z.looseObject({}), v1: consent.answerConsent,
access: 'public', middleware: [linkLimiter()], params: tokenParams, body: z.looseObject({}), documented: { body: D.obj({ decision: z.enum(['give', 'decline']), signedName: D.str, isGuardian: D.bool.describe('Must be true'), medical: D.obj({ allergies: D.optStr, medication: D.optStr, conditions: D.optStr, medicalAid: D.optStr }).optional(), emergency: D.obj({ name: D.str, phone: D.str, relationship: D.optStr }).optional(), photoConsent: D.optBool }), response: D.obj({ message: D.str, status: z.enum(['given', 'declined']) }) }, v1: consent.answerConsent,
});
// ─── Attendee form answers ───────────────────────────────────────────────────
@@ -276,35 +332,35 @@ v1Route({
v1Route({
method: 'get', path: '/form-responses', tag: 'Registrations', summary: 'Attendee form answers',
description: 'For an event (`eventId`) or a registration (`registrationId`), for events you may see.',
access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'registrationId', 'userId', 'cursor'), v1: forms.listFormResponses,
access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'registrationId', 'userId', 'cursor'), documented: { response: D.obj({ items: z.array(D.obj({ id: D.str, registrationId: D.str, attendeeId: D.optStr, createdAt: D.date, answers: z.array(D.obj({ fieldId: D.str, value: D.str })).optional() })), nextCursor: D.optStr.describe('Pass as `cursor` for the next page') }) }, v1: forms.listFormResponses,
});
// ─── Feedback ────────────────────────────────────────────────────────────────
v1Route({
method: 'get', path: '/feedback/summary', tag: 'Feedback', summary: 'Feedback across events', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventIds'), v1: feedback.getSummary,
method: 'get', path: '/feedback/summary', tag: 'Feedback', summary: 'Feedback across events', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventIds'), documented: { response: z.array(D.obj({ eventId: D.str, title: D.str, startDate: D.date, anonymous: D.bool, sentAt: D.optDate, invited: D.num, responses: D.num, responseRate: D.optNum.describe('%'), questions: z.array(D.anyObj).describe('Each question with its answers summarised') })) }, v1: feedback.getSummary,
});
v1Route({
method: 'get', path: '/feedback-forms/:token', tag: 'Feedback', summary: 'A feedback form',
description: 'What the link sent after an event shows (the token is the credential).', access: 'public', params: tokenParams, v1: feedback.getPublicForm,
description: 'What the link sent after an event shows (the token is the credential).', access: 'public', params: tokenParams, documented: { response: D.obj({ event: D.anyObj, name: D.str, intro: D.optStr, anonymous: D.bool, responded: D.bool, fields: z.array(feedbackField) }) }, v1: feedback.getPublicForm,
});
v1Route({
method: 'post', path: '/feedback-forms/:token', tag: 'Feedback', summary: 'Answer a feedback form',
access: 'public', params: tokenParams, body: z.looseObject({}), v1: feedback.submitPublicForm,
access: 'public', params: tokenParams, body: z.looseObject({}), documented: { body: D.obj({ answers: z.record(z.string(), z.unknown()).describe('Field id → answer') }), response: D.message }, v1: feedback.submitPublicForm,
});
v1Route({
method: 'get', path: '/events/:id/feedback', tag: 'Feedback', summary: 'An event\'s feedback set-up and answers',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: feedback.getEventFeedback,
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, documented: { response: feedbackForm }, v1: feedback.getEventFeedback,
});
v1Route({
method: 'put', path: '/events/:id/feedback', tag: 'Feedback', summary: 'Set up an event\'s feedback form',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, v1: feedback.saveEventFeedback,
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, documented: { body: D.obj({ sendAt: D.optDate, audience: D.optStr.describe('checked_in or everyone'), anonymous: D.optBool, intro: D.optStr, fields: z.array(feedbackField) }), response: feedbackForm }, v1: feedback.saveEventFeedback,
});
v1Route({
method: 'delete', path: '/events/:id/feedback', tag: 'Feedback', summary: 'Remove an event\'s feedback form',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: feedback.deleteEventFeedback,
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, documented: { response: D.message }, v1: feedback.deleteEventFeedback,
});
v1Route({
method: 'post', path: '/events/:id/feedback/send-now', tag: 'Feedback', summary: 'Send the feedback form now',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, v1: feedback.sendNow,
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, documented: { response: D.obj({ invited: D.num, message: D.str }) }, v1: feedback.sendNow,
});
+105 -56
View File
@@ -9,6 +9,7 @@ import { z } from 'zod';
import type { Request } from 'express';
import { id, idParams } from '../schemas';
import { v1Route } from '../viaV1';
import * as D from '../docs';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const rateLimit = require('express-rate-limit') as (opts: object) => any;
@@ -38,38 +39,76 @@ const dashboardRouter = require('../../routes/dashboardRoutes');
const loose = z.looseObject({});
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
// ─── Documented shapes (docs.ts) ─────────────────────────────────────────────
const challenge = D.obj({ challengeId: D.str, method: z.enum(['email', 'whatsapp']), destination: D.str.describe('Where the code went, partly hidden') });
const signedIn = D.obj({ message: D.optStr, id: D.str, name: D.str, email: D.str, role: D.str, token: D.str.describe('A new sign-in token') });
const kind = z.enum(['update', 'news']).describe('update: about something they booked (always sent). news: needs their news consent');
const attendeeFilter = D.obj({
status: D.optStr.describe('any, paid, unpaid, checked_in, not_checked_in…'), attendeeIds: D.ids.optional(), registrationIds: D.ids.optional(),
allocationGroupId: D.optStr.describe('People in one room or group'), name: D.optStr, email: D.optStr, phone: D.optStr,
audience: z.enum(['sponsors']).optional().describe('Sponsors of the event instead'),
});
const emailAttendees = D.obj({
subject: D.optStr, html: D.optStr, text: D.optStr.describe('subject and html or text, unless template is tickets'),
template: z.enum(['custom', 'tickets', 'payment_reminder', 'event_reminder']).optional().describe('tickets: send everyone their tickets; the reminders fill in a standard message'), filter: attendeeFilter.optional(),
dryRun: D.optBool.describe('Only say who it would reach'), kind: kind.optional(), promoEventId: D.optStr.describe('Promote another event (news)'),
});
const whatsappAttendees = D.obj({
message: D.optStr.describe('Required unless template is tickets'), template: z.enum(['custom', 'tickets', 'payment_reminder', 'event_reminder']).optional(), filter: attendeeFilter.optional(),
dryRun: D.optBool, kind: kind.optional(), promoEventId: D.optStr,
});
const reach = D.obj({ eventId: D.optStr, kind: D.optStr, matched: D.num, skipped: D.optNum.describe('Left out: no news consent'), recipients: z.array(D.anyObj).optional().describe('The first 20'), queued: D.optNum, template: D.optStr, sent: D.optNum });
const scheduledJob = D.obj({
id: D.str, kind: D.str.describe('broadcast or attendees'), eventId: D.optStr, broadcast: D.bool, channel: z.enum(['email', 'whatsapp']).or(D.str),
recipient: D.optStr, scheduledAt: D.date, createdAt: D.date, status: D.str.describe('queued, sent, failed, cancelled'), attempts: D.num,
sentAt: D.optDate, lastError: D.optStr, subject: D.str, html: D.str, text: D.str, message: D.str, hasHtml: D.bool, hasText: D.bool,
});
const scheduled = D.obj({ message: D.str, job: D.anyObj });
const bannerShape = D.obj({ message: D.str, type: D.str.describe('info, warning, success, error'), liveFrom: D.optDate, liveTill: D.optDate });
const savedFilter = D.obj({ id: D.str, page: D.str.describe('The list page it belongs to'), name: D.str, params: D.anyObj.describe('The filters'), isDefault: D.bool, updatedAt: D.date });
const scanStats = D.obj({ totalToday: D.num, myToday: D.num, lastHour: D.num, byStaff: z.array(D.anyObj) }).partial();
const monthly = D.obj({ thisMonth: D.num, lastMonth: D.num, pctChange: D.optNum });
const reportBody = D.obj({
title: D.str, subtitle: D.optStr, kind: z.enum(['table', 'layered']),
table: D.obj({ columns: z.array(D.str), rows: z.array(z.array(z.unknown())) }).optional().describe('kind table'),
layered: D.obj({ header: D.optStr, sections: z.array(D.anyObj) }).optional().describe('kind layered: titled sections, each with its rows'),
orientation: z.enum(['portrait', 'landscape']).optional(), stats: z.array(D.anyObj).optional().describe('Figures shown above the table'),
chart: D.anyObj.optional(), note: D.optStr, extraTables: z.array(D.anyObj).optional(),
});
/** The query string of the v2 request, for a v1 router's URL. */
const qs = (req: Request) => (req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '');
// ─── Accounts ────────────────────────────────────────────────────────────────
v1Route({ method: 'post', path: '/auth/login/code/resend', tag: 'Accounts', summary: 'Send the sign-in code again', access: 'public', middleware: [loginLimiter], body: loose, v1: users.resendLoginTwoFactor });
v1Route({ method: 'post', path: '/auth/password/forgot', tag: 'Accounts', summary: 'Email me a link to reset my password', description: 'Always the same answer, so it doesn\'t say whether an account has that email. Rate-limited like sign-in.', access: 'public', middleware: [loginLimiter], body: z.looseObject({ email: z.string().max(200) }), v1: users.requestPasswordReset });
v1Route({ method: 'post', path: '/auth/password/reset', tag: 'Accounts', summary: 'Set a new password with a reset link', access: 'public', body: loose, v1: users.resetPassword });
v1Route({ method: 'post', path: '/auth/activate', tag: 'Accounts', summary: 'Activate an account with its link', access: 'public', body: loose, v1: users.activateAccount });
v1Route({ method: 'post', path: '/me/sessions/revoke', tag: 'Me', summary: 'Sign out everywhere else', access: 'user', body: loose, v1: users.revokeMySession });
v1Route({ method: 'post', path: '/me/close', tag: 'Me', summary: 'Close my account', description: 'Needs the password. Personal details are removed; payment records stay, anonymised.', access: 'user', body: loose, v1: users.closeAccount });
v1Route({ method: 'get', path: '/me/activity', tag: 'Me', summary: 'Recent sign-ins and changes to my account', access: 'user', v1: users.getMyActivity });
v1Route({ method: 'post', path: '/me/verify-password', tag: 'Me', summary: 'Check my password (before a sensitive change)', access: 'user', middleware: [loginLimiter], body: loose, v1: users.verifyPassword });
v1Route({ method: 'post', path: '/me/two-factor/enable', tag: 'Me', summary: 'Start turning on two-step sign-in', description: 'Sends a code to confirm with.', access: 'user', middleware: [loginLimiter], body: loose, v1: users.startEnableTwoFactor });
v1Route({ method: 'post', path: '/me/two-factor/confirm', tag: 'Me', summary: 'Confirm two-step sign-in with the code', access: 'user', middleware: [loginLimiter], body: loose, v1: users.confirmEnableTwoFactor });
v1Route({ method: 'post', path: '/me/two-factor/disable', tag: 'Me', summary: 'Turn off two-step sign-in', access: 'user', middleware: [loginLimiter], body: loose, v1: users.disableTwoFactor });
v1Route({ method: 'post', path: '/auth/login/code/resend', tag: 'Accounts', summary: 'Send the sign-in code again', access: 'public', middleware: [loginLimiter], body: loose, documented: { body: D.obj({ challengeId: D.str }), response: challenge }, v1: users.resendLoginTwoFactor });
v1Route({ method: 'post', path: '/auth/password/forgot', tag: 'Accounts', summary: 'Email me a link to reset my password', description: 'Always the same answer, so it doesn\'t say whether an account has that email. Rate-limited like sign-in.', access: 'public', middleware: [loginLimiter], body: z.looseObject({ email: z.string().max(200) }), documented: { response: D.message }, v1: users.requestPasswordReset });
v1Route({ method: 'post', path: '/auth/password/reset', tag: 'Accounts', summary: 'Set a new password with a reset link', access: 'public', body: loose, documented: { body: D.obj({ token: D.str.describe('From the link'), password: D.str }), response: D.message }, v1: users.resetPassword });
v1Route({ method: 'post', path: '/auth/activate', tag: 'Accounts', summary: 'Activate an account with its link', access: 'public', body: loose, documented: { body: D.obj({ token: D.str.describe('From the link'), password: D.str.describe('At least 8 characters'), acceptTerms: D.optBool }), response: signedIn }, v1: users.activateAccount });
v1Route({ method: 'post', path: '/me/sessions/revoke', tag: 'Me', summary: 'Sign out everywhere else', access: 'user', body: loose, documented: { response: D.obj({ message: D.str, token: D.str.describe('A new token for this device (the old one stops working)') }) }, v1: users.revokeMySession });
v1Route({ method: 'post', path: '/me/close', tag: 'Me', summary: 'Close my account', description: 'Needs the password. Personal details are removed; payment records stay, anonymised.', access: 'user', body: loose, documented: { body: D.obj({ password: D.str, deleteData: D.optBool.describe('Also ask for my personal information to be deleted') }), response: D.message }, v1: users.closeAccount });
v1Route({ method: 'get', path: '/me/activity', tag: 'Me', summary: 'Recent sign-ins and changes to my account', access: 'user', documented: { response: z.array(D.obj({ id: D.str, type: D.str.describe('login, password_changed, two_factor_enabled…'), device: D.optStr, createdAt: D.date })) }, v1: users.getMyActivity });
v1Route({ method: 'post', path: '/me/verify-password', tag: 'Me', summary: 'Check my password (before a sensitive change)', access: 'user', middleware: [loginLimiter], body: loose, documented: { body: D.obj({ password: D.str }), response: D.ok }, v1: users.verifyPassword });
v1Route({ method: 'post', path: '/me/two-factor/enable', tag: 'Me', summary: 'Start turning on two-step sign-in', description: 'Sends a code to confirm with.', access: 'user', middleware: [loginLimiter], body: loose, documented: { body: D.obj({ method: z.enum(['email', 'whatsapp']), password: D.str }), response: challenge }, v1: users.startEnableTwoFactor });
v1Route({ method: 'post', path: '/me/two-factor/confirm', tag: 'Me', summary: 'Confirm two-step sign-in with the code', access: 'user', middleware: [loginLimiter], body: loose, documented: { body: D.obj({ challengeId: D.str, code: D.str }), response: D.obj({ twoFactorEnabled: z.literal(true), twoFactorMethod: D.str }) }, v1: users.confirmEnableTwoFactor });
v1Route({ method: 'post', path: '/me/two-factor/disable', tag: 'Me', summary: 'Turn off two-step sign-in', access: 'user', middleware: [loginLimiter], body: loose, documented: { body: D.obj({ password: D.str }), response: D.obj({ twoFactorEnabled: z.literal(false), twoFactorMethod: z.null() }) }, v1: users.disableTwoFactor });
v1Route({
method: 'get', path: '/users/exists', tag: 'Accounts', summary: 'Is there an account with this email or phone?',
access: 'supervisor', campusScope: 'campusIndependent (accounts are shared across campuses)', middleware: [cs.campusIndependent('accounts are shared across campuses')],
query: filters('email', 'phone', 'phoneNumber'), v1: users.checkUserExists,
query: filters('email', 'phone', 'phoneNumber'), documented: { response: D.obj({ exists: D.bool, hasEmail: D.optBool, hasPhone: D.optBool, user: D.obj({ id: D.str, name: D.str, email: D.optStr, phoneNumber: D.optStr, notificationPreference: D.optStr }).nullish() }) }, v1: users.checkUserExists,
});
v1Route({
method: 'get', path: '/users', tag: 'Accounts', summary: 'Every account',
description: 'Paged (`page`, `limit`) and searchable (`q`, `role`, `campus`, …) — see v1 for every filter.',
access: 'supervisor', campusScope: 'campusIndependent (accounts are shared across campuses)', middleware: [cs.campusIndependent('accounts are shared across campuses')],
query: filters('page', 'limit', 'search', 'role', 'isActive', 'campus'), v1: users.getUsers,
query: filters('page', 'limit', 'search', 'role', 'isActive', 'campus'), documented: { response: D.v1Page(D.user) }, v1: users.getUsers,
});
v1Route({ method: 'get', path: '/users/:id', tag: 'Accounts', summary: 'One account', access: 'admin', params: idParams, v1: users.getUserById });
v1Route({ method: 'patch', path: '/users/:id', tag: 'Accounts', summary: 'Change an account', description: 'Name, contact details, role, campus limits, active.', access: 'admin', params: idParams, body: loose, v1: users.updateUser });
v1Route({ method: 'delete', path: '/users/:id', tag: 'Accounts', summary: 'Delete an account', access: 'admin', params: idParams, v1: users.deleteUser });
v1Route({ method: 'post', path: '/users/:id/sessions/revoke', tag: 'Accounts', summary: 'Sign someone out everywhere', access: 'admin', params: idParams, body: loose, v1: users.adminRevokeUserSessions });
v1Route({ method: 'post', path: '/users/:id/anonymize', tag: 'Accounts', summary: 'Anonymise an account', description: 'Admins. Personal details are removed for good; payment records stay.', access: 'admin', params: idParams, body: loose, v1: users.anonymizeUser });
v1Route({ method: 'get', path: '/users/:id', tag: 'Accounts', summary: 'One account', access: 'admin', params: idParams, documented: { response: D.user.extend({ scopedCampusIds: D.ids.optional().describe('Campuses they\'re limited to') }) }, v1: users.getUserById });
v1Route({ method: 'patch', path: '/users/:id', tag: 'Accounts', summary: 'Change an account', description: 'Name, contact details, role, campus limits, active.', access: 'admin', params: idParams, body: loose, documented: { body: D.obj({ name: D.optStr, email: D.optStr, phoneNumber: D.optStr, role: D.optStr.describe('admin, supervisor, staff or user'), isActive: D.optBool, password: D.optStr.describe('Set a new password'), notificationPreference: D.optStr.describe('email, whatsapp or both'), twoFactorEnabled: D.optBool.describe('Only to turn it off'), scopedCampusIds: D.ids.optional().describe('Limit staff to these campuses (empty: all)') }), response: D.user }, v1: users.updateUser });
v1Route({ method: 'delete', path: '/users/:id', tag: 'Accounts', summary: 'Delete an account', access: 'admin', params: idParams, documented: { response: D.message }, v1: users.deleteUser });
v1Route({ method: 'post', path: '/users/:id/sessions/revoke', tag: 'Accounts', summary: 'Sign someone out everywhere', access: 'admin', params: idParams, body: loose, documented: { response: D.message }, v1: users.adminRevokeUserSessions });
v1Route({ method: 'post', path: '/users/:id/anonymize', tag: 'Accounts', summary: 'Anonymise an account', description: 'Admins. Personal details are removed for good; payment records stay.', access: 'admin', params: idParams, body: loose, documented: { response: D.message }, v1: users.anonymizeUser });
// ─── Messaging an event's people ─────────────────────────────────────────────
@@ -77,62 +116,72 @@ const toEvent = { campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()]
v1Route({
method: 'get', path: '/event-listing', tag: 'Events', summary: 'Upcoming public events in full',
description: 'What the site\'s event lists show (ticket options, prices, early-bird tiers), not paged. `campus` (id or slug) and `series` (slug) narrow it. For a paged, slimmer list use GET /events.',
access: 'public', query: filters('campus', 'series'), v1: events.getEvents,
access: 'public', query: filters('campus', 'series'), documented: { response: z.array(D.event.extend({ eventOptions: z.array(D.eventOption).optional(), isSoldOut: D.optBool })) }, v1: events.getEvents,
});
v1Route({ method: 'get', path: '/admin/events', tag: 'Events (staff)', summary: 'Every event, including deactivated ones', access: 'admin', v1: events.getAllEvents });
v1Route({ method: 'post', path: '/events/:id/email-attendees', tag: 'Messaging', summary: 'Email an event\'s people', description: 'Filtered by status or chosen people; news consent is checked.', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.emailEventAttendees });
v1Route({ method: 'post', path: '/events/:id/email-attendees/schedule', tag: 'Messaging', summary: 'Schedule an email to an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.scheduleEmailEventAttendees });
v1Route({ method: 'post', path: '/events/:id/whatsapp-attendees', tag: 'Messaging', summary: 'WhatsApp an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.whatsappEventAttendees });
v1Route({ method: 'post', path: '/events/:id/whatsapp-attendees/schedule', tag: 'Messaging', summary: 'Schedule a WhatsApp to an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, v1: events.scheduleWhatsappEventAttendees });
v1Route({ method: 'get', path: '/admin/events', tag: 'Events (staff)', summary: 'Every event, including deactivated ones', access: 'admin', documented: { response: z.array(D.event) }, v1: events.getAllEvents });
v1Route({ method: 'post', path: '/events/:id/email-attendees', tag: 'Messaging', summary: 'Email an event\'s people', description: 'Filtered by status or chosen people; news consent is checked.', access: 'supervisor', ...toEvent, params: idParams, body: loose, documented: { body: emailAttendees, response: reach }, v1: events.emailEventAttendees });
v1Route({ method: 'post', path: '/events/:id/email-attendees/schedule', tag: 'Messaging', summary: 'Schedule an email to an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, documented: { body: emailAttendees.extend({ scheduledAt: D.date }), response: scheduled }, v1: events.scheduleEmailEventAttendees });
v1Route({ method: 'post', path: '/events/:id/whatsapp-attendees', tag: 'Messaging', summary: 'WhatsApp an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, documented: { body: whatsappAttendees, response: reach }, v1: events.whatsappEventAttendees });
v1Route({ method: 'post', path: '/events/:id/whatsapp-attendees/schedule', tag: 'Messaging', summary: 'Schedule a WhatsApp to an event\'s people', access: 'supervisor', ...toEvent, params: idParams, body: loose, documented: { body: whatsappAttendees.extend({ scheduledAt: D.date }), response: scheduled }, v1: events.scheduleWhatsappEventAttendees });
for (const [channel, field, ctrl] of [['email', 'emails', bc], ['whatsapp', 'phones', wbc]] as const) {
const scope = { campusScope: `scopeBroadcast(freeform ${field})`, middleware: [cs.scopeBroadcast({ freeformField: field })] };
const [preview, send, schedule] = channel === 'email' ? [ctrl.previewBroadcast, ctrl.sendBroadcast, ctrl.scheduleBroadcast] : [ctrl.previewWhatsAppBroadcast, ctrl.sendWhatsAppBroadcast, ctrl.scheduleWhatsAppBroadcast];
const name = channel === 'email' ? 'an email' : 'a WhatsApp';
v1Route({ method: 'post', path: `/broadcasts/${channel}/preview`, tag: 'Messaging', summary: `Who ${name} broadcast would reach`, access: 'supervisor', ...scope, body: loose, v1: preview });
v1Route({ method: 'post', path: `/broadcasts/${channel}/send`, tag: 'Messaging', summary: `Send ${name} broadcast`, description: 'To campuses, events, roles or listed people; news consent is checked.', access: 'supervisor', ...scope, body: loose, v1: send });
v1Route({ method: 'post', path: `/broadcasts/${channel}/schedule`, tag: 'Messaging', summary: `Schedule ${name} broadcast`, access: 'supervisor', ...scope, body: loose, v1: schedule });
const who = D.obj({
userIds: D.ids.optional().describe('Chosen accounts'),
...(channel === 'email' ? { emails: D.optStr.describe('Typed-in addresses, separated by commas or new lines') } : { phones: D.optStr.describe('Typed-in numbers, separated by commas or new lines') }),
eventId: D.optStr.describe('Everyone registered for this event'), kind: kind.optional().describe('Default news'),
});
const content = channel === 'email' ? { subject: D.str, html: D.optStr, text: D.optStr } : { message: D.str };
v1Route({ method: 'post', path: `/broadcasts/${channel}/preview`, tag: 'Messaging', summary: `Who ${name} broadcast would reach`, access: 'supervisor', ...scope, body: loose, documented: { body: who, response: reach.extend({ event: D.anyObj.nullish(), placeholders: z.array(D.str).optional() }) }, v1: preview });
v1Route({ method: 'post', path: `/broadcasts/${channel}/send`, tag: 'Messaging', summary: `Send ${name} broadcast`, description: 'To campuses, events, roles or listed people; news consent is checked.', access: 'supervisor', ...scope, body: loose, documented: { body: who.extend(content), response: reach }, v1: send });
v1Route({ method: 'post', path: `/broadcasts/${channel}/schedule`, tag: 'Messaging', summary: `Schedule ${name} broadcast`, access: 'supervisor', ...scope, body: loose, documented: { body: who.extend(content).extend({ scheduledAt: D.date }), response: scheduled }, v1: schedule });
}
v1Route({ method: 'get', path: '/scheduled-messages', tag: 'Messaging', summary: 'Scheduled emails and WhatsApps', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('status', 'eventId', 'channel'), v1: sched.listScheduledEmails });
v1Route({ method: 'patch', path: '/scheduled-messages/:id', tag: 'Messaging', summary: 'Change a scheduled message', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], params: idParams, body: loose, v1: sched.updateScheduledEmail });
v1Route({ method: 'delete', path: '/scheduled-messages/:id', tag: 'Messaging', summary: 'Cancel a scheduled message', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], params: idParams, v1: sched.deleteScheduledEmail });
v1Route({ method: 'get', path: '/scheduled-messages', tag: 'Messaging', summary: 'Scheduled emails and WhatsApps', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('status', 'eventId', 'channel'), documented: { response: D.obj({ jobs: z.array(scheduledJob) }) }, v1: sched.listScheduledEmails });
v1Route({ method: 'patch', path: '/scheduled-messages/:id', tag: 'Messaging', summary: 'Change a scheduled message', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], params: idParams, body: loose, documented: { body: D.obj({ scheduledAt: D.optDate, subject: D.optStr, html: D.optStr, text: D.optStr, message: D.optStr.describe('WhatsApp') }), response: D.obj({ message: D.str, job: scheduledJob }) }, v1: sched.updateScheduledEmail });
v1Route({ method: 'delete', path: '/scheduled-messages/:id', tag: 'Messaging', summary: 'Cancel a scheduled message', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], params: idParams, documented: { response: D.message }, v1: sched.deleteScheduledEmail });
v1Route({
method: 'post', path: '/automations/schedule', tag: 'Messaging', summary: 'Schedule an event\'s reminder messages',
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], body: z.looseObject({ eventId: id }), v1: auto.scheduleAutomations,
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [cs.scopeEvent({ from: (req: any) => req.body?.eventId })], body: z.looseObject({ eventId: id }), documented: { body: D.obj({ eventId: D.str, jobs: z.array(D.obj({ scheduledAt: D.date, subject: D.str, html: D.optStr, text: D.optStr, audience: z.enum(['sponsors']).optional(), promoEventId: D.optStr })).min(1) }), response: D.obj({ message: D.str, jobs: z.array(D.anyObj) }) }, v1: auto.scheduleAutomations,
});
// ─── WhatsApp connection (admins) ────────────────────────────────────────────
v1Route({ method: 'get', path: '/whatsapp/config', tag: 'WhatsApp', summary: 'The WhatsApp connection settings', access: 'admin', v1: wa.getConfigHandler });
v1Route({ method: 'put', path: '/whatsapp/config', tag: 'WhatsApp', summary: 'Change the WhatsApp connection settings', access: 'admin', body: loose, v1: wa.saveConfigHandler });
v1Route({ method: 'post', path: '/whatsapp/instance', tag: 'WhatsApp', summary: 'Create the WhatsApp instance', access: 'admin', body: loose, v1: wa.createInstanceHandler });
v1Route({ method: 'delete', path: '/whatsapp/instance', tag: 'WhatsApp', summary: 'Delete the WhatsApp instance', access: 'admin', v1: wa.deleteInstanceHandler });
v1Route({ method: 'get', path: '/whatsapp/status', tag: 'WhatsApp', summary: 'Is WhatsApp connected?', access: 'admin', v1: wa.getStatusHandler });
v1Route({ method: 'get', path: '/whatsapp/qr', tag: 'WhatsApp', summary: 'The QR code to link a phone', access: 'admin', v1: wa.getQrHandler });
v1Route({ method: 'get', path: '/whatsapp/config', tag: 'WhatsApp', summary: 'The WhatsApp connection settings', access: 'admin', documented: { response: D.obj({ tokenMasked: D.str, instanceId: D.str, hasToken: D.bool, hasInstance: D.bool, configured: D.bool, webhookUrl: D.optStr }) }, v1: wa.getConfigHandler });
v1Route({ method: 'put', path: '/whatsapp/config', tag: 'WhatsApp', summary: 'Change the WhatsApp connection settings', access: 'admin', body: loose, documented: { body: D.obj({ token: D.optStr, instanceId: D.optStr }), response: D.message }, v1: wa.saveConfigHandler });
v1Route({ method: 'post', path: '/whatsapp/instance', tag: 'WhatsApp', summary: 'Create the WhatsApp instance', access: 'admin', body: loose, documented: { body: D.obj({ name: D.optStr }), response: D.obj({ message: D.str }).describe('Plus what the WhatsApp service answered') }, v1: wa.createInstanceHandler });
v1Route({ method: 'delete', path: '/whatsapp/instance', tag: 'WhatsApp', summary: 'Delete the WhatsApp instance', access: 'admin', documented: { response: D.obj({ message: D.str }).describe('Plus what the WhatsApp service answered') }, v1: wa.deleteInstanceHandler });
v1Route({ method: 'get', path: '/whatsapp/status', tag: 'WhatsApp', summary: 'Is WhatsApp connected?', access: 'admin', documented: { response: D.anyObj.describe('The WhatsApp service\'s status answer') }, v1: wa.getStatusHandler });
v1Route({ method: 'get', path: '/whatsapp/qr', tag: 'WhatsApp', summary: 'The QR code to link a phone', access: 'admin', documented: { response: D.anyObj.describe('The WhatsApp service\'s answer, with the QR code image') }, v1: wa.getQrHandler });
for (const [action, summary, handler] of [['request-code', 'Link a phone with a code instead', wa.requestCodeHandler], ['logout', 'Unlink the phone', wa.logoutHandler], ['start', 'Start the WhatsApp session', wa.startHandler], ['restart', 'Restart the WhatsApp session', wa.restartHandler]] as const) {
v1Route({ method: 'post', path: `/whatsapp/${action}`, tag: 'WhatsApp', summary, access: 'admin', body: loose, v1: handler });
v1Route({
method: 'post', path: `/whatsapp/${action}`, tag: 'WhatsApp', summary, access: 'admin', body: loose,
documented: { body: action === 'request-code' ? D.obj({ phoneNumber: D.str }) : D.anyObj, response: D.anyObj.describe('The WhatsApp service\'s answer') },
v1: handler,
});
}
// ─── Banner, saved filters, dashboards, stats, analytics, reports ────────────
v1Route({ method: 'get', path: '/banner', tag: 'Site', summary: 'The site-wide banner', access: 'public', v1: banner.getBanner });
v1Route({ method: 'put', path: '/banner', tag: 'Site', summary: 'Set the site-wide banner', access: 'supervisor', campusScope: 'unscopedOnly (admins and unscoped supervisors)', middleware: [cs.unscopedOnly], body: loose, v1: banner.setBanner });
v1Route({ method: 'get', path: '/banner', tag: 'Site', summary: 'The site-wide banner', access: 'public', documented: { response: bannerShape }, v1: banner.getBanner });
v1Route({ method: 'put', path: '/banner', tag: 'Site', summary: 'Set the site-wide banner', access: 'supervisor', campusScope: 'unscopedOnly (admins and unscoped supervisors)', middleware: [cs.unscopedOnly], body: loose, documented: { body: bannerShape.partial().describe('An empty message takes the banner down'), response: bannerShape }, v1: banner.setBanner });
const OWN = 'campusIndependent (a person\'s own saved filters)';
v1Route({ method: 'get', path: '/saved-filters', tag: 'Me', summary: 'My saved filters', description: 'For one list page (`page`).', access: 'staff', campusScope: OWN, query: filters('page'), v1: savedFilterRouter, map: ctx => ({ url: `/${qs(ctx.req)}` }) });
v1Route({ method: 'post', path: '/saved-filters', tag: 'Me', summary: 'Save a filter', description: 'By name (replaces one with the same name); up to 30 per page.', access: 'staff', campusScope: OWN, body: loose, v1: savedFilterRouter, map: () => ({ url: '/' }) });
v1Route({ method: 'put', path: '/saved-filters/:id', tag: 'Me', summary: 'Change a saved filter', access: 'staff', campusScope: OWN, params: idParams, body: loose, v1: savedFilterRouter, map: ctx => ({ url: `/${encodeURIComponent(ctx.params.id)}` }) });
v1Route({ method: 'delete', path: '/saved-filters/:id', tag: 'Me', summary: 'Delete a saved filter', access: 'staff', campusScope: OWN, params: idParams, v1: savedFilterRouter, map: ctx => ({ url: `/${encodeURIComponent(ctx.params.id)}` }) });
v1Route({ method: 'get', path: '/dashboard/today', tag: 'Dashboard', summary: 'Today at a glance', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: dashboardRouter, map: ctx => ({ url: `/today${qs(ctx.req)}` }) });
v1Route({ method: 'get', path: '/dashboard/search', tag: 'Dashboard', summary: 'Search people, registrations and events', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('q'), v1: dashboardRouter, map: ctx => ({ url: `/search${qs(ctx.req)}` }) });
v1Route({ method: 'get', path: '/stats/staff', tag: 'Dashboard', summary: 'Staff dashboard numbers', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: stats.getStaffDashboardStats });
v1Route({ method: 'get', path: '/stats/supervisor', tag: 'Dashboard', summary: 'Supervisor dashboard numbers', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], v1: stats.getSupervisorDashboardStats });
v1Route({ method: 'get', path: '/stats/admin', tag: 'Dashboard', summary: 'Admin dashboard numbers', access: 'admin', v1: stats.getAdminDashboardStats });
v1Route({ method: 'get', path: '/stats/overview', tag: 'Dashboard', summary: 'Overview numbers', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('from', 'to', 'campus'), v1: stats.getOverviewStats });
v1Route({ method: 'get', path: '/saved-filters', tag: 'Me', summary: 'My saved filters', description: 'For one list page (`page`).', access: 'staff', campusScope: OWN, query: filters('page'), documented: { response: z.array(savedFilter) }, v1: savedFilterRouter, map: ctx => ({ url: `/${qs(ctx.req)}` }) });
v1Route({ method: 'post', path: '/saved-filters', tag: 'Me', summary: 'Save a filter', description: 'By name (replaces one with the same name); up to 30 per page.', access: 'staff', campusScope: OWN, body: loose, documented: { body: D.obj({ page: D.str, name: D.str, params: D.anyObj, isDefault: D.optBool }), response: savedFilter }, v1: savedFilterRouter, map: () => ({ url: '/' }) });
v1Route({ method: 'put', path: '/saved-filters/:id', tag: 'Me', summary: 'Change a saved filter', access: 'staff', campusScope: OWN, params: idParams, body: loose, documented: { body: D.obj({ name: D.optStr, params: D.anyObj.optional(), isDefault: D.optBool }), response: savedFilter }, v1: savedFilterRouter, map: ctx => ({ url: `/${encodeURIComponent(ctx.params.id)}` }) });
v1Route({ method: 'delete', path: '/saved-filters/:id', tag: 'Me', summary: 'Delete a saved filter', access: 'staff', campusScope: OWN, params: idParams, documented: { response: D.ok }, v1: savedFilterRouter, map: ctx => ({ url: `/${encodeURIComponent(ctx.params.id)}` }) });
v1Route({ method: 'get', path: '/dashboard/today', tag: 'Dashboard', summary: 'Today at a glance', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], documented: { response: D.obj({ events: z.array(D.obj({ id: D.str, title: D.str, startsAt: D.date, endsAt: D.date, sessionLabel: D.optStr, places: D.num, checkedIn: D.num })).describe('Today\'s events with places and check-ins'), money: D.obj({ today: D.rand, todayCount: D.num, week: D.rand }).nullish(), pending: z.array(D.obj({ key: D.str, label: D.str, count: D.num, href: D.str, tone: D.optStr })).describe('What\'s waiting for you') }) }, v1: dashboardRouter, map: ctx => ({ url: `/today${qs(ctx.req)}` }) });
v1Route({ method: 'get', path: '/dashboard/search', tag: 'Dashboard', summary: 'Search people, registrations and events', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('q'), documented: { response: D.obj({ results: z.array(D.obj({ type: D.str.describe('event, payment, person, registration'), id: D.str, title: D.str, subtitle: D.optStr, href: D.optStr })) }) }, v1: dashboardRouter, map: ctx => ({ url: `/search${qs(ctx.req)}` }) });
v1Route({ method: 'get', path: '/stats/staff', tag: 'Dashboard', summary: 'Staff dashboard numbers', access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], documented: { response: D.obj({ scanStats, recentScans: z.array(D.anyObj) }) }, v1: stats.getStaffDashboardStats });
v1Route({ method: 'get', path: '/stats/supervisor', tag: 'Dashboard', summary: 'Supervisor dashboard numbers', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], documented: { response: D.obj({ scanStats, recentScans: z.array(D.anyObj), paymentStats: D.anyObj, activeEventsCount: D.num }) }, v1: stats.getSupervisorDashboardStats });
v1Route({ method: 'get', path: '/stats/admin', tag: 'Dashboard', summary: 'Admin dashboard numbers', access: 'admin', documented: { response: D.obj({ scanStats, recentScans: z.array(D.anyObj), paymentStats: D.anyObj, activeEventsCount: D.num }) }, v1: stats.getAdminDashboardStats });
v1Route({ method: 'get', path: '/stats/overview', tag: 'Dashboard', summary: 'Overview numbers', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('from', 'to', 'campus'), documented: { response: D.obj({ funnel: D.anyObj.nullish(), activeEvents: D.num, registrations: monthly, ticketsSold: monthly, revenue: monthly, donations: monthly, trend: z.array(D.obj({ date: D.str, revenue: D.rand })), topEvents: z.array(D.obj({ eventId: D.str, title: D.str, revenue: D.rand, registrations: D.num, ticketsSold: D.num })) }) }, v1: stats.getOverviewStats });
const trackLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false });
v1Route({ method: 'post', path: '/analytics/track', tag: 'Dashboard', summary: 'Record a step on the way to registering', access: 'public', middleware: [trackLimiter], body: loose, v1: analytics.trackStage });
v1Route({ method: 'get', path: '/analytics/funnel', tag: 'Dashboard', summary: 'How many get from viewing to paying', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'from', 'to'), v1: analytics.getFunnel });
v1Route({ method: 'post', path: '/reports/pdf', tag: 'Reports', summary: 'A report as a PDF', description: 'The report\'s rows and columns in the body.', access: 'user', body: loose, file: { contentType: 'application/pdf' }, v1: reports.generatePdf });
v1Route({ method: 'post', path: '/reports/excel', tag: 'Reports', summary: 'A report as an Excel file', access: 'user', body: loose, file: { contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, v1: reports.generateExcel });
v1Route({ method: 'post', path: '/reports/email', tag: 'Reports', summary: 'Email a report as a PDF', access: 'user', body: loose, v1: reports.emailPdf });
v1Route({ method: 'post', path: '/reports/whatsapp', tag: 'Reports', summary: 'WhatsApp a report as a PDF', access: 'user', body: loose, v1: reports.whatsappPdf });
v1Route({ method: 'post', path: '/analytics/track', tag: 'Dashboard', summary: 'Record a step on the way to registering', access: 'public', middleware: [trackLimiter], body: loose, documented: { body: D.obj({ sessionId: D.str.describe('The browser\'s analytics id'), eventId: D.str, stage: z.enum(['view', 'start', 'submit', 'paid']), source: D.optStr.describe('Where they came from') }), response: z.null().describe('Always 204, no content') }, v1: analytics.trackStage });
v1Route({ method: 'get', path: '/analytics/funnel', tag: 'Dashboard', summary: 'How many get from viewing to paying', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventIds', 'eventId', 'from', 'to'), documented: { response: D.obj({ perEvent: z.array(D.anyObj).describe('The same for each event') }).describe('Counts per stage (view, start, submit, paid), sources and devices, and per ticket') }, v1: analytics.getFunnel });
v1Route({ method: 'post', path: '/reports/pdf', tag: 'Reports', summary: 'A report as a PDF', description: 'The report\'s rows and columns in the body.', access: 'user', body: loose, file: { contentType: 'application/pdf' }, documented: { body: reportBody }, v1: reports.generatePdf });
v1Route({ method: 'post', path: '/reports/excel', tag: 'Reports', summary: 'A report as an Excel file', access: 'user', body: loose, file: { contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, documented: { body: reportBody }, v1: reports.generateExcel });
v1Route({ method: 'post', path: '/reports/email', tag: 'Reports', summary: 'Email a report as a PDF', access: 'user', body: loose, documented: { body: reportBody.extend({ subject: D.optStr, body: D.optStr.describe('Text of the email') }), response: D.message }, v1: reports.emailPdf });
v1Route({ method: 'post', path: '/reports/whatsapp', tag: 'Reports', summary: 'WhatsApp a report as a PDF', access: 'user', body: loose, documented: { body: reportBody.extend({ caption: D.optStr }), response: D.message }, v1: reports.whatsappPdf });
@@ -1,4 +1,5 @@
import { z } from 'zod';
import * as D from '../docs';
import type { Request } from 'express';
import { defineRoute, type ApiUser } from '../route';
import { pageOf, single } from '../paging';
@@ -207,6 +208,16 @@ export const bulk = defineRoute({
access: 'supervisor', campusScope: 'scopeBody(registrationIds, deny orphans)', middleware: [campusScope.scopeBody([['registrationIds', 'registration']], { orphan: 'deny' })],
body: z.looseObject({ action: z.enum(['markPaid', 'cancel', 'moveOption', 'allocate', 'message']), registrationIds: z.array(id).min(1).max(500) }),
response: single(z.looseObject({})),
documented: {
body: D.obj({
action: z.enum(['markPaid', 'cancel', 'moveOption', 'allocate', 'message']), registrationIds: z.array(id).min(1).max(500),
method: D.optStr.describe('markPaid: cash, eft, card or other'), paidAt: D.optDate.describe('markPaid'),
fromOptionId: D.optStr.describe('moveOption'), toOptionId: D.optStr.describe('moveOption'),
setId: D.optStr.describe('allocate'), groupId: D.optStr.describe('allocate'),
channel: z.enum(['email', 'whatsapp']).optional().describe('message'), subject: D.optStr.describe('message (email)'), text: D.optStr.describe('message'),
}),
response: D.obj({ action: D.str, done: D.num, skipped: D.num, failed: D.num, results: z.array(D.obj({ id: D.str, ok: D.bool, skipped: D.optBool, message: D.optStr })) }),
},
async handler({ body, req }) {
const { runBulk } = require('../../utils/bulkRegistrations') as { runBulk(req: unknown, body: object): Promise<object> };
return { data: plain(await runBulk(req, body)) };
+4
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import * as D from '../docs';
import type { Request } from 'express';
import { defineRoute, type ApiUser } from '../route';
import { single } from '../paging';
@@ -74,6 +75,7 @@ export const compareSeries = defineRoute({
method: 'get', path: '/series/:id/compare', tag: 'Series', summary: 'Year on year',
description: 'Registrations, tickets, income and attendance for each event in the series that you may see, oldest first.',
access: 'supervisor', campusScope: 'scopeList (eventWhere in compareSeries)', params: idParams, response: single(z.looseObject({})),
documented: { response: D.obj({ series: D.anyObj, events: z.array(D.obj({ eventId: D.str, title: D.str, startDate: D.date, endDate: D.date, campus: D.anyObj.nullish(), isActive: D.bool, sessions: D.optNum, registrations: D.num, tickets: D.num, income: D.rand, checkedIn: D.num, attendanceRate: D.optNum.describe('%'), change: D.obj({ registrations: D.optNum.describe('% change from the year before'), tickets: D.optNum.describe('% change from the year before'), income: D.optNum.describe('% change from the year before'), checkedIn: D.optNum.describe('% change from the year before') }).nullable() })) }) },
async handler({ params, req }) { return { data: plain(await series.compareSeries(params.id, await ctxOf(req))) }; },
});
@@ -137,6 +139,7 @@ export const buyPass = defineRoute({
description: 'Registers you at every event on it, then answers with a checkout to pay (or issues the tickets when it\'s free).',
access: 'user', status: 201, params: idParams,
body: z.object({ quantity: z.number().int().min(1).max(50).default(1), provider: z.string().max(40).optional() }),
documented: { response: D.obj({ purchaseId: D.str, total: D.rand, paid: D.bool.describe('Free: the tickets are issued straight away'), redirectUrl: D.optStr.describe('Where to send them to pay'), provider: D.optStr, message: D.optStr }) },
response: single(z.looseObject({})),
async handler({ params, body, user }) { return { data: plain(await series.buyPass(params.id, user!.id, body)) }; },
});
@@ -144,6 +147,7 @@ export const buyPass = defineRoute({
export const payPass = defineRoute({
method: 'post', path: '/season-pass-purchases/:id/checkout', tag: 'Series', summary: 'Pay what\'s owed on a pass',
access: 'user', params: idParams, body: z.object({ provider: z.string().max(40).optional() }), response: single(z.looseObject({})),
documented: { response: D.obj({ redirectUrl: D.optStr, provider: D.optStr, message: D.optStr }) },
async handler({ params, body, user }) { return { data: plain(await series.payPassPurchase(params.id, user!.id, body)) }; },
});
+18
View File
@@ -57,6 +57,13 @@ export type RouteDef<P extends Schema | undefined, Q extends Schema | undefined,
* and `response` is `fileResponse`. `contentType` here is what the docs show.
*/
file?: { contentType: string };
/**
* Documentation only (v1Route, 1.52.1): the body and the answer's `data` as the v1 handler takes
* and gives them, for the OpenAPI document. Not enforced — the handler gets what was sent and its
* answer goes out as it is — but outside production anything that doesn't fit is logged
* (`[api/v2] … doesn't match its documented …`), so the docs can't drift unnoticed.
*/
documented?: { body?: Schema; response?: Schema };
handler: (ctx: Ctx<Infer<P>, Infer<Q>, Infer<B>>) => Promise<z.input<R>> | z.input<R>;
};
@@ -89,6 +96,15 @@ export function defineRoute<P extends Schema | undefined, Q extends Schema | und
const parse = <S extends Schema | undefined>(schema: S, value: unknown): Infer<S> =>
(schema ? schema.parse(value) : undefined) as Infer<S>;
/** Outside production: says when a documented-only body or answer doesn't fit its documentation. */
function warnUndocumented(def: AnyRoute, what: string, schema: Schema, value: unknown) {
if (process.env.NODE_ENV === 'production') return;
const checked = schema.safeParse(value);
if (!checked.success) {
console.warn(`[api/v2] ${def.method.toUpperCase()} ${def.path}: ${what} doesn't match its documented schema`, JSON.stringify(checked.error.issues.slice(0, 5)));
}
}
/** Adds every defined route to the router. */
export function mount(router: Router, list: AnyRoute[] = routes): void {
for (const def of list) {
@@ -102,7 +118,9 @@ export function mount(router: Router, list: AnyRoute[] = routes): void {
req,
res,
};
if (def.documented?.body) warnUndocumented(def, 'request body', def.documented.body, req.body ?? {});
const result = await def.handler(ctx);
if (def.documented?.response) warnUndocumented(def, 'answer', def.documented.response, (result as { data?: unknown })?.data);
const checked = def.response.safeParse(result);
if (!checked.success) {
console.error(`[api/v2] ${def.method.toUpperCase()} ${def.path}: response doesn't match its schema`, checked.error.issues);
+2
View File
@@ -152,6 +152,8 @@ type V1RouteDef = {
query?: z.ZodType;
body?: z.ZodType;
response?: z.ZodType;
/** The body and the answer's `data`, for the docs only (see RouteDef.documented). */
documented?: { body?: z.ZodType; response?: z.ZodType };
status?: number;
file?: { contentType: string };
upload?: { field: string; description: string };
+109
View File
@@ -0,0 +1,109 @@
import express from 'express';
import request from 'supertest';
import { z } from 'zod';
// 1.52.1: the API document describes every route. Routes answered by a v1 handler carry
// `documented` shapes — in the document, never enforced, and logged outside production when the
// real body or answer doesn't fit them.
jest.mock('../src/middleware/authMiddleware', () => ({
protect: (_req: unknown, _res: unknown, next: () => void) => next(),
optionalAuth: (_req: unknown, _res: unknown, next: () => void) => next(),
admin: (_req: unknown, _res: unknown, next: () => void) => next(),
staff: (_req: unknown, _res: unknown, next: () => void) => next(),
supervisor: (_req: unknown, _res: unknown, next: () => void) => next(),
loginLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
}));
import { defineRoute, mount } from '../src/v2/route';
import { buildOpenApi } from '../src/v2/openapi';
import { errorHandler } from '../src/v2/errors';
const documented = defineRoute({
method: 'post', path: '/docs-test/things', tag: 'Test', summary: 'Make a thing', access: 'public',
body: z.looseObject({}),
response: z.object({ data: z.unknown() }),
documented: { body: z.object({ name: z.string() }), response: z.object({ id: z.string(), name: z.string() }) },
handler: ({ req }) => ({ data: (req.body as { name?: unknown }).name === 'odd' ? { id: 1 } : { id: 't1', name: String((req.body as { name?: unknown }).name) } }),
});
const action = defineRoute({
method: 'post', path: '/docs-test/things/:id/poke', tag: 'Test', summary: 'Poke a thing', access: 'public',
params: z.object({ id: z.string() }), query: z.looseObject({ from: z.string().optional() }), body: z.looseObject({}), response: z.object({ data: z.unknown() }),
handler: () => ({ data: null }),
});
const app = express();
app.use(express.json());
const router = express.Router();
mount(router, [documented, action]);
router.use(errorHandler);
app.use('/api/v2', router);
describe('documented shapes', () => {
const doc = buildOpenApi({ version: 'test', list: [documented, action] }) as any;
const op = doc.paths['/docs-test/things'].post;
test('are what the document shows, in place of the generic body and answer', () => {
expect(op.requestBody.required).toBe(true);
expect(op.requestBody.content['application/json'].schema.properties.name).toEqual({ type: 'string' });
expect(op.responses['200'].content['application/json'].schema.properties.data.properties).toEqual({ id: { type: 'string' }, name: { type: 'string' } });
});
test('an action with no input says it needs no body; parameters get their common description', () => {
const poke = doc.paths['/docs-test/things/{id}/poke'].post;
expect(poke.requestBody).toMatchObject({ required: true, description: expect.stringContaining('No body needed') });
expect(poke.parameters).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'id', in: 'path', description: 'The id' }),
expect.objectContaining({ name: 'from', in: 'query', description: expect.stringContaining('On or after') }),
]));
});
test('are never enforced: a body or answer that doesn\'t fit still goes through, and is logged', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
const fits = await request(app).post('/api/v2/docs-test/things').send({ name: 'Bob' });
expect(fits.status).toBe(200);
expect(warn).not.toHaveBeenCalled();
const odd = await request(app).post('/api/v2/docs-test/things').send({ name: 'odd' });
expect(odd.status).toBe(200);
expect(odd.body).toEqual({ data: { id: 1 } });
const noName = await request(app).post('/api/v2/docs-test/things').send({});
expect(noName.status).toBe(200);
expect(warn.mock.calls.map(c => c[0])).toEqual([
expect.stringContaining('answer doesn\'t match its documented schema'),
expect.stringContaining('request body doesn\'t match its documented schema'),
]);
warn.mockRestore();
});
});
describe('the whole API document', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../src/v2');
const doc = buildOpenApi({ version: 'test' }) as any;
// (Leaving out this file's own test routes.)
const ops = Object.entries<any>(doc.paths).filter(([path]) => !path.startsWith('/docs-test')).flatMap(([path, methods]) => Object.entries<any>(methods).map(([method, op]) => ({ path, method, op })));
const isBlank = (s: any) => !s || Object.keys(s).length === 0 || (s.type === 'object' && !s.properties && !s.additionalProperties && !s.description);
test('every JSON answer says what\'s in it', () => {
const blank = ops.filter(({ op }) => {
const json = Object.entries<any>(op.responses).find(([code]) => code.startsWith('2'))?.[1]?.content?.['application/json'];
if (!json) return false;
const data = json.schema?.properties?.data;
return isBlank(data) || (data?.type === 'array' && isBlank(data.items));
}).map(({ method, path }) => `${method.toUpperCase()} ${path}`);
// A JSON file download (my data) is the one answer that's a file, not a shape.
expect(blank).toEqual(['GET /me/privacy/export']);
});
test('every body that takes something says what, and every parameter is described', () => {
const undescribed = ops.flatMap(({ method, path, op }) => op.parameters.filter((p: any) => !p.description).map((p: any) => `${method} ${path} ${p.in}:${p.name}`));
expect(undescribed).toEqual([]);
const bodies = ops.filter(({ op }) => op.requestBody?.content?.['application/json']);
for (const { op } of bodies) {
const schema = op.requestBody.content['application/json'].schema;
const takesSomething = (!!schema.properties && Object.keys(schema.properties).length > 0) || !!schema.additionalProperties?.type || !!schema.description;
if (!takesSomething) expect(op.requestBody.description).toMatch(/No body needed/);
}
});
});
+14 -2
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-11-02, after **1.52.0** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-11-02, after **1.52.2** (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).
@@ -14,6 +14,8 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.52.2 | `scripts/deploy.sh` stops first (listing them) when files in the install were changed or added by hand; `--check` runs only that; runs from a copy outside the install too; `ALLOW_LOCAL_CHANGES=1` overrides |
| 1.52.1 | **API documentation complete:** every v2 route documents its body, answer and parameters — the ~300 answered by v1 handlers through `documented` shapes (`src/v2/docs.ts`), shown but not enforced, logged outside production when they don't fit; empty bodies say "No body needed". Test `tests/v2Docs.test.ts` |
| 1.52.0 | **Urgent fix + Corrections:** required attendee forms no longer leave paid door/manual registrations without tickets — popup with "Fill in later" (`Registration.formWaivedAt`, `POST /api/v2/registrations/{id}/form-later`); one form per person on picked sessions; forms with no questions don't hold tickets; kiosk asks for the forms the server says are missing. Admin **Corrections** page (`src/services/corrections.ts`, `/api/v2/corrections/…`): issue missing tickets, pay later (`ticketsReleasedAt`), make it free (`complimentaryDiscount`), check in after the event. Migration `20261102120000_corrections`. e2e `e2e/corrections.spec.ts` |
| 1.51.17 | **Security:** forgot-password and sign-in don't reveal whether an account exists (same answer, status and timing); forgot-password rate-limited |
| 1.51.16 | **Incoming webhooks and message links on v2** — `src/v2/links.ts` (`/api/v2/webhooks/…`, one-click unsubscribe, 308s for old links); provider URLs and email links point at v2 |
@@ -127,7 +129,7 @@ Languages (Afrikaans, §10) and Wallet passes (§7) are removed from scope (2026
The backend declares `engines.node >= 22`. pg-boss 11 (1.51.7) runs in its own schema,
`pgboss_v11` — it can't migrate pg-boss 10's `pgboss` schema; there's no `archive` table any more
(finished runs stay in `job` until they expire).
- **Deploying:** `bash scripts/deploy.sh [tag]` on the server (no tag = latest main). Each install
- **Deploying:** `bash scripts/deploy.sh [tag]` on the server (no tag = latest main). It stops first if files in the install were changed or added by hand (`--check` to only check; `ALLOW_LOCAL_CHANGES=1` to go ahead anyway). Each install
keeps its own `ecosystem.config.js` (PM2 app names, port), which is not in git. The script
runs from a temp copy of itself and rolls back on failure before the PM2 reload. It was
tested by running it against a throwaway clone with npm/pm2 stubbed: across the tracked →
@@ -499,6 +501,16 @@ Languages (Afrikaans, §10) and Wallet passes (§7) are removed from scope (2026
- Body and query schemas are loose (`z.looseObject`) and v1 gets the request as sent, so v2 refuses
nothing v1 accepted. Give a `query` schema for filters the frontend passes — the typed `apiV2`
only allows declared ones.
- **Document every v1Route (1.52.1):** `documented: { body?, response? }` with the shapes from
`src/v2/docs.ts` (`D.obj`, `D.payment`, `D.message`…) — the body it reads and the `data` it answers.
They go in the OpenAPI document but are **not enforced** (the handler still gets what was sent and
its answer goes out as it is), so a mistake can't break a screen. Outside production a body or
answer that doesn't fit is logged as `[api/v2] … doesn't match its documented schema` — grep the
e2e output for it after changing a handler. `tests/v2Docs.test.ts` fails if a JSON answer is
undocumented or a parameter has no description (common query names are described once in
`QUERY_HELP`, path ones in `PATH_HELP`). An action that takes no input keeps `body: loose`; the
document says "No body needed". Because the frontend's types come from the document, a documented
shape that's wrong usually shows up as a frontend type error too.
- Moving these handlers into TS services (as 1.51.1 – 1.51.10 did) is a later clean-up; the v2 API
doesn't change when it happens.
- Frontend conversions are scripted from `SUCCESSORS` and the OpenAPI document (scratch tool, not
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.52.0",
"version": "1.52.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.52.0",
"version": "1.52.2",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.52.0",
"version": "1.52.2",
"private": true,
"scripts": {
"dev": "next dev",
@@ -312,7 +312,7 @@ export default function KidsDeskPage() {
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 },
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);
@@ -101,7 +101,8 @@ export function BreachesPanel({ token }: { token: string }) {
setBusy(true);
setError(null);
try {
if (id === "new") await apiV2Data("post", "/privacy/breaches", { authToken: token, body });
// A new breach always comes from the full form (save below), so the required fields are there.
if (id === "new") await apiV2Data("post", "/privacy/breaches", { authToken: token, body: body as { title: string; description: string; dataAffected: string; discoveredAt: string } });
else await apiV2Data("put", "/privacy/breaches/{id}", { params: { id: id }, authToken: token, body });
setEditing(null);
await load();
@@ -48,7 +48,7 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
setBusy(true);
setError(null);
try {
const body: Record<string, string> = { name: f.name, email: f.email, phone: f.phone, ...(f.gender ? { gender: f.gender } : {}) };
const body: { name: string; [field: string]: string } = { name: f.name, email: f.email, phone: f.phone, ...(f.gender ? { gender: f.gender } : {}) };
if (youth) Object.assign(body, { dateOfBirth: f.dateOfBirth, parentName: f.parentName, parentEmail: f.parentEmail, parentPhone: f.parentPhone });
const res = await (apiV2Data("post", "/tickets/{id}/transfer", { params: { id: ticket.id }, authToken: token, body }) as Promise<{ message: string }>);
onDone(res.message);
File diff suppressed because it is too large Load Diff
+7260 -288
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.52.0",
"version": "1.52.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.52.0",
"version": "1.52.2",
"license": "ISC",
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.52.0",
"version": "1.52.2",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",
+39 -1
View File
@@ -6,20 +6,34 @@
# Usage (on the server, from the repo folder):
# bash scripts/deploy.sh # the latest main
# bash scripts/deploy.sh v1.37.3 # a tagged release
# bash scripts/deploy.sh --check # only check for files changed by hand (changes nothing)
#
# PM2 uses this install's own ecosystem.config.js (app names, ports). It isn't in git, so
# deploys never change it; the first deploy creates it from ecosystem.config.example.js.
#
# A deploy stops before touching anything when files in the repo folder were changed or added
# by hand: they'd be built into the release (or block the checkout). ALLOW_LOCAL_CHANGES=1
# deploys anyway. Ignored files (.env, uploads, backups, the PM2 config) never count.
set -Eeuo pipefail
# The checkout below replaces this file, and bash reads a script as it runs — so run from a
# copy that can't change underneath us.
if [ -z "${HOPE_DEPLOY_COPY:-}" ]; then
DIR="${HOPE_DEPLOY_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
# Run from a copy outside the repo (e.g. downloaded to /tmp): the folder you're in.
if [ ! -d "$DIR/.git" ] || [ ! -d "$DIR/backend" ]; then DIR="$PWD"; fi
COPY="$(mktemp)"
cp "$0" "$COPY"
HOPE_DEPLOY_COPY=1 HOPE_DEPLOY_DIR="$(cd "$(dirname "$0")/.." && pwd)" exec bash "$COPY" "$@"
HOPE_DEPLOY_COPY=1 HOPE_DEPLOY_DIR="$DIR" exec bash "$COPY" "$@"
fi
cd "$HOPE_DEPLOY_DIR"
if [ ! -d .git ] || [ ! -d backend ]; then
echo "!!> $PWD isn't a Cross Events install. Run this from the install's folder (e.g. /var/www/hope-events)."
exit 1
fi
CHECK_ONLY=0
if [ "${1:-}" = "--check" ]; then CHECK_ONLY=1; shift; fi
TARGET="${1:-main}"
PREVIOUS="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)"
@@ -51,6 +65,30 @@ checkout() {
restore_pm2_config
}
# Files changed or added by hand (anything git reports, apart from this install's PM2 config,
# which releases before 1.37.4 tracked). Ignored files — .env, uploads, backups — aren't listed.
local_changes() {
git status --porcelain --untracked-files=all | awk -v keep="$PM2_CONFIG" 'substr($0, 4) != keep'
}
CHANGES="$(local_changes)"
if [ -n "$CHANGES" ]; then
echo "!!> Files in $PWD were changed or added by hand:"
echo "$CHANGES" | sed 's/^/ /'
echo " A release would build them in (or refuse to replace them), so the deploy can fail."
echo " Move added files (??) somewhere outside this folder, and put changed ones ( M) back with"
echo " \"git checkout -- <file>\" — after checking nothing in them is needed."
if [ "$CHECK_ONLY" = 1 ]; then exit 1; fi
if [ "${ALLOW_LOCAL_CHANGES:-}" != 1 ]; then
echo " Nothing was changed. To deploy anyway: ALLOW_LOCAL_CHANGES=1 bash scripts/deploy.sh $TARGET"
exit 1
fi
echo " ALLOW_LOCAL_CHANGES=1 — deploying anyway."
elif [ "$CHECK_ONLY" = 1 ]; then
echo "==> No files changed by hand in $PWD — ready to deploy."
fi
if [ "$CHECK_ONLY" = 1 ]; then exit 0; fi
echo "==> Deploying $TARGET (currently $PREVIOUS)"
# Node 20 reached end of life in April 2026 (no more security fixes). Releases still run on it,