Compare commits

...
10 Commits
Author SHA1 Message Date
joshua 16357e6006 Merge branch 'feat/api-v2-events-rest' 2026-10-29 12:18:29 +02:00
joshuaandClaude Opus 5.5 9547793f25 The rest of event set-up on API v2 (1.51.8)
- v2: event in full, calendar file, alias, ticket options and variants, attachments
  (multipart), sections, planned costs, image uploads.
- src/services/eventDetails.ts and src/services/setup.ts; v1 uses them.
- v2 routes can send files (fileResponse) and take uploads (documented as multipart).
- Frontend: fetchEvent() everywhere, lib/eventSetup.ts for set-up calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:18:29 +02:00
joshuaandClaude Opus 5.5 99efe7a7d0 WIP 1.51.8: frontend on v2 for event detail, options, sections, attachments, costs, uploads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:14:01 +02:00
joshuaandClaude Opus 5.5 3c983070a1 WIP 1.51.8: sections, costs and uploads on v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:08:03 +02:00
joshuaandClaude Opus 5.5 c63c3a3604 WIP 1.51.8: event detail, options, variants, attachments, calendar and alias on v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:04:06 +02:00
joshua 67d0a83ab8 Merge branch 'feat/api-v2-events' 2026-10-29 11:57:08 +02:00
joshuaandClaude Opus 5.5 2bcc9ac651 Setting events up on API v2, pg-boss 11 (1.51.7)
- v2: /events/manage, create, PATCH, deactivate, copy-from, notify-recipients.
- src/services/events.ts (from eventController.js, with the schedule helpers); v1 uses it.
- fetchManagedEvents() for every picker; the event wizard writes through v2.
- pg-boss 11 in its own schema (pgboss_v11); old pgboss schema left in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 11:57:08 +02:00
joshua b2906011fd Merge branch 'feat/palette-excelts' 2026-10-29 11:16:38 +02:00
joshuaandClaude Opus 5.5 70850df4f6 Complete command palette, ExcelTS 10, roadmap: v2 for webhooks and sent links (1.51.6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 11:14:19 +02:00
joshua cbebcdbd48 Merge branch 'chore/next16' 2026-10-29 11:00:31 +02:00
72 changed files with 11134 additions and 2707 deletions
+79
View File
@@ -7,6 +7,85 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.51.8] - 2026-10-29
### Added
- **API v2 — the rest of setting events up:** `GET /events/{id}/full` (the event in full — what's
left per ticket, variant and session, form, attachments, bundles, next in series; staff also see
inactive events, creator and email recipients), `GET /events/{id}/calendar.ics`,
`GET /events/by-alias/{alias}`; ticket options (`POST /events/{id}/ticket-options`,
`PATCH`/`DELETE /ticket-options/{id}`) and variants (`POST /ticket-options/{id}/variants`,
`PATCH`/`DELETE /variants/{id}`); attachments (`GET`/`POST /events/{id}/attachments` — multipart —
and `DELETE …/{attachmentId}`, admin `/attachments/status` and `/attachments/sync`); scanning
sections (`/sections`); planned costs (`/events/{id}/costs`, `/costs/{id}`); image uploads
(`/uploads/event-image`, `/uploads/logo`, `/uploads/favicon` — returning `path` and `url`).
- **Services:** `src/services/eventDetails.ts` (from eventController.js, now 1,080 lines, from 2,217
at the start of 1.51.7) and `src/services/setup.ts` (sections and costs). v1 uses them too.
- **v2 route features:** `file` routes answer with a download (`fileResponse`), and `upload` routes
are described as multipart in the OpenAPI document.
- Frontend: `fetchEvent()` for every event page (the public event and registration pages too),
and `lib/eventSetup.ts` for ticket options, sections, attachments, costs and image uploads.
### Changed
- The pre-migration fallbacks for attachments (JSON manifests on disk) are gone from the normal
path; old manifests can still be imported with `/attachments/sync`.
- v1's `GET /api/events/:id` now points at `/api/v2/events/{id}/full` as its successor.
- Roadmap: series, season passes, bundles, coupons and campuses are 1.51.9; later areas move up one
(to 1.51.15).
## [1.51.7] - 2026-10-29
### Added
- **API v2 — setting events up:** `GET /events/manage` (every event staff may see, hidden ones too;
`past`, `inactive`, `closed`; campus-scoped), `POST /events`, `PATCH /events/{id}` (only what's sent
changes; `sessionChanges` when sessions were sent), `DELETE /events/{id}` (admins; deactivates),
`POST /events/{id}/copy-from`, and `GET`/`PUT /events/{id}/notify-recipients`. Event records are
typed for their core fields and pass the rest through as stored (`z.looseObject`).
- **`src/services/events.ts`:** create, update, deactivate, the staff list, notify recipients and
copy-from moved out of `eventController.js` (2,217 → 1,717 lines), with the schedule helpers
(`withSchedule`, `scheduleData`, `saveSchedule`). v1 uses it too. The pre-migration fallbacks
(retrying without `registrationDeadline`, `goLiveAt`…) are gone — every install has those columns.
Creating an event now also returns its ticket options.
- Frontend: `fetchManagedEvents()` (`lib/api.ts`) — every event picker, the kiosk, reports and the
set-up screens load events through v2; the event wizard creates, saves, copies and sets
notification recipients through v2. v1 deprecation links for these endpoints.
### Changed
- **pg-boss 11** (servers are on Node 24). It can't upgrade pg-boss 10's tables, so the job queue
uses a new schema, `pgboss_v11`; schedules are recreated at start, and the admin page's
"recent runs" start again from this release. The old `pgboss` schema is left as it was (it can
be dropped: `DROP SCHEMA pgboss CASCADE;`). pg-boss 11 brings cron-parser 5, so the last
cron-parser 4 deprecation is gone.
- Roadmap: the rest of the events area (event details for staff, ticket options and variants,
attachments, sections, costs, series, season passes, bundles, coupons, campuses, calendar file,
alias) is 1.51.8; the later areas move up one (to 1.51.14).
## [1.51.6] - 2026-10-29
### Fixed
- **Command palette (Ctrl+K) was missing things.** It listed 9 of the 19 reports — now every
report (built from the report catalogue, so new ones appear by themselves), plus Dashboard,
Reports, Ticket options and My groups, and your own pages (My events & tickets, My payments,
My privacy, My kids, My serving, My attendee forms, Make a donation). Attendee forms now opens
for staff and supervisors too (the page allows them; the palette had it as admin-only). An e2e
test fails when a dashboard page is neither in the palette nor deliberately left out.
### Changed
- **Excel exports use ExcelTS** (`@cj-tech-master/excelts` 10) instead of exceljs, which is
unmaintained: same API, no dependencies — the backend has 87 fewer packages and none of
exceljs's deprecated ones (fstream, glob 7, inflight, lodash.isequal, rimraf 2). New
`e2e/excel.spec.ts` opens a real report and attendance export. ExcelTS itself now says it's no
longer developed (its successor, documonster, is pre-1.0); existing versions keep working.
- The backend needs **Node 22 or newer** (`engines`); the servers run Node 24 LTS.
- Roadmap: incoming webhooks and links in sent messages get v2 URLs too (1.51.13), with the v1 paths
still answering; the area releases are now 1.51.7 – 1.51.13.
## [1.51.5] - 2026-10-29
### Changed
+1
View File
@@ -898,6 +898,7 @@ can't start, the same jobs run on in-process timers.
link (1.51.3).
- **TypeScript backend** (1.51.0): `.ts` files are checked strictly, `.js` compiles as it is;
deploys build `dist/`, which `src/index.js` runs.
- **Command palette** (Ctrl+K): every page, settings tab, report and quick action for your role.
- **Tests** — Jest unit tests in `backend/tests` (`npm test`), and Playwright
end-to-end tests in `e2e/` (`npm run test:e2e`) that start their own backend and
frontend against a separate, freshly seeded database: public pages, free and paid
+20
View File
@@ -12,6 +12,26 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 1.51.8
- **More groundwork for 2.0.** Event pages, ticket types, scanning sections, attachments, planned
costs and picture uploads now run on the new version of the server's connection to the app. They
work just as before.
## 1.51.7
- **More groundwork for 2.0.** Creating and editing events, and every list of events staff choose
from, now run on the new version of the server's connection to the app. They work as before.
- The background job runner was updated. Its list of recent runs (Site settings → Background jobs)
starts afresh with this version.
## 1.51.6
- **Find anything with Ctrl+K.** The search box (Ctrl+K) now lists every report, and pages that
were missing: the dashboard, all reports, ticket options, your groups, and your own events,
payments, privacy, kids, serving, forms and donations.
- Excel downloads use a newer, maintained library. They look the same.
## 1.51.5
- **Updated framework.** The site now runs on the latest version of its web framework. Nothing
+25 -910
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.51.5",
"version": "1.51.8",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -20,6 +20,7 @@
"openapi": "tsx scripts/openapi.ts"
},
"dependencies": {
"@cj-tech-master/excelts": "^10.2.0",
"@prisma/adapter-pg": "^7.10.1",
"@prisma/client": "^7.10.1",
"@sentry/node": "^10.71.0",
@@ -28,7 +29,6 @@
"cors": "^2.8.5",
"cron-parser": "^5.10.2",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^5.3.0",
"express-rate-limit": "^8.6.2",
"ics": "^3.12.0",
@@ -37,7 +37,7 @@
"node-fetch": "^2.7.0",
"nodemailer": "^10.3.0",
"pdfkit": "^0.17.1",
"pg-boss": "^10.4.2",
"pg-boss": "^11.1.2",
"qrcode": "^1.5.4",
"raw-body": "^3.0.0",
"swagger-ui-dist": "^5.34.0",
@@ -72,5 +72,8 @@
"@parcel/watcher": false,
"@scarf/scarf": false,
"esbuild": false
},
"engines": {
"node": ">=22"
}
}
@@ -1,4 +1,5 @@
const ExcelJS = require('exceljs');
// ExcelTS: a maintained TypeScript port of exceljs (same API, no dependencies).
const ExcelJS = require('@cj-tech-master/excelts');
const PDFDocument = require('pdfkit');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
+15 -148
View File
@@ -1,177 +1,44 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
// Planned event costs (cashup and the profit report). Shared with /api/v2 (services/setup.ts, 1.51.8).
const setup = require('../services/setup');
const { safeErrorMessage } = require('../utils/errorUtils');
const { assertEventOpen, ALL_METHODS } = require('../utils/cashupUtils');
function normalizePaidFromMethod(value) {
if (value === undefined) return undefined;
if (value === null || value === '') return null;
if (!ALL_METHODS.includes(value)) {
throw new Error("paidFromMethod must be one of 'cash', 'card', 'eft', 'other', or null");
}
return value;
}
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
// @desc List costs for an event
// @route GET /api/events/:eventId/costs
// @access Private/Supervisor
const getEventCosts = async (req, res) => {
try {
const costs = await prisma.eventCost.findMany({
where: { eventId: req.params.eventId },
include: { eventOption: { select: { id: true, name: true } } },
orderBy: { createdAt: 'asc' }
});
res.json(costs);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
res.json(await setup.listCosts(req.params.eventId));
} catch (error) { fail(res, error); }
};
// @desc Create a cost for an event
// @route POST /api/events/:eventId/costs
// @access Private/Supervisor
// @access Private/Admin
const createEventCost = async (req, res) => {
try {
const { eventId } = req.params;
const { label, costType, amount, eventOptionId, notes, paidFromMethod } = req.body;
await assertEventOpen(eventId, res);
if (!label || !String(label).trim()) {
res.status(400);
throw new Error('Label is required');
}
if (costType !== 'once_off' && costType !== 'per_item') {
res.status(400);
throw new Error("costType must be 'once_off' or 'per_item'");
}
const amt = parseFloat(amount);
if (!(amt >= 0)) {
res.status(400);
throw new Error('Amount must be a non-negative number');
}
if (costType === 'per_item' && !eventOptionId) {
res.status(400);
throw new Error('eventOptionId is required for per-item costs');
}
if (costType === 'per_item') {
const option = await prisma.eventOption.findUnique({ where: { id: eventOptionId } });
if (!option || option.eventId !== eventId) {
res.status(404);
throw new Error('Ticket type not found for this event');
}
}
const cost = await prisma.eventCost.create({
data: {
id: uuidv4(),
eventId,
label: String(label).trim(),
costType,
amount: amt,
eventOptionId: costType === 'per_item' ? eventOptionId : null,
paidFromMethod: normalizePaidFromMethod(paidFromMethod) ?? null,
notes: notes || null
},
include: { eventOption: { select: { id: true, name: true } } }
});
res.status(201).json(cost);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
res.status(201).json(await setup.createCost(req.params.eventId, req.body || {}));
} catch (error) { fail(res, error); }
};
// @desc Update a cost
// @route PUT /api/costs/:id
// @access Private/Supervisor
// @access Private/Admin
const updateEventCost = async (req, res) => {
try {
const existing = await prisma.eventCost.findUnique({ where: { id: req.params.id } });
if (!existing) {
res.status(404);
throw new Error('Cost not found');
}
await assertEventOpen(existing.eventId, res);
const { label, costType, amount, eventOptionId, notes, paidFromMethod } = req.body;
const nextCostType = costType !== undefined ? costType : existing.costType;
if (nextCostType !== 'once_off' && nextCostType !== 'per_item') {
res.status(400);
throw new Error("costType must be 'once_off' or 'per_item'");
}
const nextEventOptionId = nextCostType === 'per_item'
? (eventOptionId !== undefined ? eventOptionId : existing.eventOptionId)
: null;
if (nextCostType === 'per_item') {
if (!nextEventOptionId) {
res.status(400);
throw new Error('eventOptionId is required for per-item costs');
}
const option = await prisma.eventOption.findUnique({ where: { id: nextEventOptionId } });
if (!option || option.eventId !== existing.eventId) {
res.status(404);
throw new Error('Ticket type not found for this event');
}
}
let nextAmount = existing.amount;
if (amount !== undefined) {
const amt = parseFloat(amount);
if (!(amt >= 0)) {
res.status(400);
throw new Error('Amount must be a non-negative number');
}
nextAmount = amt;
}
const nextPaidFromMethod = normalizePaidFromMethod(paidFromMethod);
const cost = await prisma.eventCost.update({
where: { id: req.params.id },
data: {
label: label !== undefined ? String(label).trim() : existing.label,
costType: nextCostType,
amount: nextAmount,
eventOptionId: nextEventOptionId,
paidFromMethod: nextPaidFromMethod !== undefined ? nextPaidFromMethod : existing.paidFromMethod,
notes: notes !== undefined ? notes : existing.notes
},
include: { eventOption: { select: { id: true, name: true } } }
});
res.json(cost);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
res.json(await setup.updateCost(req.params.id, req.body || {}));
} catch (error) { fail(res, error); }
};
// @desc Delete a cost
// @route DELETE /api/costs/:id
// @access Private/Supervisor
// @access Private/Admin
const deleteEventCost = async (req, res) => {
try {
const existing = await prisma.eventCost.findUnique({ where: { id: req.params.id } });
if (!existing) {
res.status(404);
throw new Error('Cost not found');
}
await assertEventOpen(existing.eventId, res);
await prisma.eventCost.delete({ where: { id: req.params.id } });
await setup.deleteCost(req.params.id);
res.json({ message: 'Cost deleted' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
} catch (error) { fail(res, error); }
};
module.exports = {
getEventCosts,
createEventCost,
updateEventCost,
deleteEventCost
};
module.exports = { getEventCosts, createEventCost, updateEventCost, deleteEventCost };
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,5 +1,6 @@
const PDFDocument = require('pdfkit');
const ExcelJS = require('exceljs');
// ExcelTS: a maintained TypeScript port of exceljs (same API, no dependencies).
const ExcelJS = require('@cj-tech-master/excelts');
const fs = require('fs');
const path = require('path');
const { sendMail, emailWrapper } = require('../utils/email');
+20 -164
View File
@@ -1,189 +1,45 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
// Scanning sections: which ticket types may come in at which door. Shared with
// /api/v2/sections (services/setup.ts, 1.51.8).
const setup = require('../services/setup');
const { listWhere } = require('../utils/campusScope');
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message });
//
// @desc Get sections (optionally by event)
// @route GET /api/sections?eventId=xxx
// @access Private/Staff
//
const getSections = async (req, res) => {
try {
const { eventId } = req.query;
const { withScope, listWhere } = require('../utils/campusScope');
const where = withScope(eventId && eventId !== 'all' ? { eventId } : {}, await listWhere(req, 'event'));
const sections = await prisma.section.findMany({
where,
include: {
allowedOptions: {
include: {
eventOption: true
}
},
event: {
select: { id: true, title: true }
}
},
orderBy: { name: 'asc' }
});
res.json(sections);
} catch (error) {
res.status(400).json({ message: error.message });
}
try {
res.json(await setup.listSections(req.query.eventId, await listWhere(req, 'event')));
} catch (error) { fail(res, error); }
};
//
// @desc Create section
// @route POST /api/sections
// @access Private/Admin/Supervisor
//
const createSection = async (req, res) => {
try {
const { eventId, name, allowedOptionIds } = req.body;
if (!eventId || !name) {
res.status(400);
throw new Error('Event and section name are required');
}
const event = await prisma.event.findUnique({
where: { id: eventId }
});
if (!event) {
res.status(404);
throw new Error('Event not found');
}
const section = await prisma.section.create({
data: {
id: uuidv4(),
eventId,
name,
updatedAt: new Date()
}
});
// Attach allowed options if provided
if (Array.isArray(allowedOptionIds) && allowedOptionIds.length > 0) {
await prisma.sectionOption.createMany({
data: allowedOptionIds.map(optionId => ({
id: uuidv4(),
sectionId: section.id,
eventOptionId: optionId
})),
skipDuplicates: true
});
}
const fullSection = await prisma.section.findUnique({
where: { id: section.id },
include: {
allowedOptions: {
include: { eventOption: true }
}
}
});
res.status(201).json(fullSection);
} catch (error) {
res.status(400).json({ message: error.message });
}
try {
res.status(201).json(await setup.createSection(req.body || {}));
} catch (error) { fail(res, error); }
};
//
// @desc Update section
// @route PUT /api/sections/:id
// @access Private/Admin/Supervisor
//
const updateSection = async (req, res) => {
try {
const { name, allowedOptionIds } = req.body;
const section = await prisma.section.findUnique({
where: { id: req.params.id }
});
if (!section) {
res.status(404);
throw new Error('Section not found');
}
const updatedSection = await prisma.section.update({
where: { id: req.params.id },
data: {
name: name || section.name,
updatedAt: new Date()
}
});
// If allowed options supplied → reset them
if (Array.isArray(allowedOptionIds)) {
await prisma.sectionOption.deleteMany({
where: { sectionId: section.id }
});
if (allowedOptionIds.length > 0) {
await prisma.sectionOption.createMany({
data: allowedOptionIds.map(optionId => ({
id: uuidv4(),
sectionId: section.id,
eventOptionId: optionId
}))
});
}
}
const fullSection = await prisma.section.findUnique({
where: { id: section.id },
include: {
allowedOptions: {
include: { eventOption: true }
}
}
});
res.json(fullSection);
} catch (error) {
res.status(400).json({ message: error.message });
}
try {
res.json(await setup.updateSection(req.params.id, req.body || {}));
} catch (error) { fail(res, error); }
};
//
// @desc Delete section
// @route DELETE /api/sections/:id
// @access Private/Admin
//
const deleteSection = async (req, res) => {
try {
const section = await prisma.section.findUnique({
where: { id: req.params.id }
});
if (!section) {
res.status(404);
throw new Error('Section not found');
}
await prisma.section.delete({
where: { id: section.id }
});
res.json({ message: 'Section deleted' });
} catch (error) {
res.status(400).json({ message: error.message });
}
try {
await setup.deleteSection(req.params.id);
res.json({ message: 'Section deleted' });
} catch (error) { fail(res, error); }
};
module.exports = {
getSections,
createSection,
updateSection,
deleteSection
};
module.exports = { getSections, createSection, updateSection, deleteSection };
+6 -6
View File
@@ -18,7 +18,10 @@
const { CronExpressionParser } = require('cron-parser');
const TIMEZONE = process.env.APP_TIMEZONE || 'Africa/Johannesburg';
const SCHEMA = 'pgboss';
// pg-boss 11 can't upgrade pg-boss 10's schema (it migrates only from 11.0), so it has its own.
// The old `pgboss` schema (10.x) is left as it was; it can be dropped (DROP SCHEMA pgboss CASCADE)
// once 1.51.7 has run — it only held queued runs and their history.
const SCHEMA = 'pgboss_v11';
const JOBS = [
{
@@ -270,11 +273,8 @@ async function jobStatus(prisma) {
FROM (
SELECT name, state, created_on, started_on, completed_on, output,
row_number() OVER (PARTITION BY name ORDER BY created_on DESC) AS rn
FROM (
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.job
UNION ALL
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.archive
) all_jobs
-- pg-boss 11 keeps finished runs in job until they expire (there's no archive table).
FROM (SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.job) all_jobs
WHERE name = ANY($1::text[])
) ranked
WHERE rn <= 10
+373
View File
@@ -0,0 +1,373 @@
/**
* One event in full, its ticket options and variants, attachments, calendar file and short link
* (1.51.8). Moved from eventController.js so /api/events/* (v1) and /api/v2/events/* share it.
* The pre-migration fallbacks (attachments kept in JSON manifests on disk, clients without the
* tier/variant models) are gone: every install has those tables.
*/
import fs from 'fs';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { prisma } from '../v2/legacy';
import { fail } from './errors';
import { SESSIONS_INCLUDE, SERIES_SELECT, withSchedule } from './events';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const sessionsUtil = require('../utils/sessions') as {
isPickOption(event: any, option: any): boolean;
activeSessions(sessions: any[]): Array<{ id: string }>;
sessionBookings(eventId: string): Promise<Map<string, number>>;
forgetSchedule(eventId: string): void;
};
const { publicOptions } = require('../utils/volunteers') as { publicOptions<O extends { id: string }>(event: any): O[] };
const { EVENT_CAMPUS_SELECT } = require('../utils/campuses') as { EVENT_CAMPUS_SELECT: Record<string, true> };
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
const { takenCount } = require('../utils/stock') as { takenCount(where: object): Promise<number> };
/** Where uploaded attachments live (served at /uploads/event-files/…). */
export const ATTACHMENTS_DIR = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
export const ALLOWED_DOCS = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv', '.zip'];
/** A stored path as an absolute URL (the caller knows the host the request came to). */
export type UrlOf = (stored: string | null | undefined) => string | null;
/**
* Who's looking: staff of a campus they may see get everything (inactive and not-yet-live
* events, who created it, who gets its emails, the kids/volunteer tickets); everyone else gets
* the public view and NOT_FOUND for events that aren't public.
*/
export type Viewer = { staff: boolean; scope: object | null };
const { canView } = require('../utils/campusScope') as { canView(scope: object | null, campusId: string | null): boolean };
/** Staff see an event in full when it's at a campus they work with (or campuses are off for them). */
const staffViewOf = (viewer: Viewer, campusId: string | null) => viewer.staff && canView(viewer.scope, campusId);
const notPublic = (ev: { isActive: boolean | null; goLiveAt: Date | null }) =>
ev.isActive === false || (ev.goLiveAt != null && new Date() < new Date(ev.goLiveAt));
/**
* One event in full: ticket options with what's left (per variant, and per session for
* "pick sessions" tickets), sessions with bookings, the attendee form, attachments, bundle deals
* on sale and the next event in its series.
*/
export async function getEventDetail(eventId: string, viewer: Viewer, urlOf: UrlOf) {
const event: any = await prisma.event.findUnique({
where: { id: eventId },
include: {
// (createdBy and notifyRecipients are dropped again below for the public view)
eventOptions: { include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } } },
sessions: SESSIONS_INCLUDE,
campus: { select: EVENT_CAMPUS_SELECT },
series: SERIES_SELECT,
attachments: true,
...(viewer.staff ? {
createdBy: { select: { id: true, name: true, email: true } },
notifyRecipients: { select: { id: true, name: true, email: true, role: true } },
} : {}),
},
});
if (!event) return fail(404, 'Event not found');
if (!staffViewOf(viewer, event.campusId)) {
// Campus-scoped staff get the public view of other campuses' events.
delete event.createdBy;
delete event.notifyRecipients;
if (notPublic(event)) fail(404, 'Event not found');
// The kids check-in and volunteer tickets are handed out by staff, never sold.
event.eventOptions = publicOptions(event);
}
const f = await prisma.eventForm.findUnique({ where: { eventId }, include: { fields: true } });
const form = f ? {
id: f.id,
isRequired: !!f.isRequired,
fields: [...f.fields].sort((a, b) => (a.order || 0) - (b.order || 0)).map(fl => ({
id: fl.id, type: fl.type, label: fl.label, isRequired: !!fl.isRequired, order: fl.order || 0, helpText: fl.helpText || null, options: fl.options || null,
})),
} : null;
// What's left per option and variant (taken = non-cancelled registrations + places held for
// live waitlist offers). "Pick sessions" tickets have places per session instead.
const eventOptions = await Promise.all((event.eventOptions || []).map(async (opt: any) => {
if (sessionsUtil.isPickOption(event, opt)) {
const sessionAvailability: Record<string, number> = {};
if (opt.stockLimit > 0) {
for (const s of sessionsUtil.activeSessions(event.sessions)) {
sessionAvailability[s.id] = Math.max(0, opt.stockLimit - await takenCount({ eventOptionId: opt.id, sessionId: s.id }));
}
}
return { ...opt, soldCount: 0, availableCount: null, sessionAvailability };
}
const soldCount = await takenCount({ eventOptionId: opt.id });
const variants = await Promise.all((opt.variants || []).map(async (v: any) => {
if (!v.stockLimit) return { ...v, soldCount: 0, availableCount: null };
const vSold = await takenCount({ eventOptionId: opt.id, variantId: v.id });
return { ...v, soldCount: vSold, availableCount: Math.max(0, v.stockLimit - vSold) };
}));
return { ...opt, soldCount, availableCount: opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null, variants };
}));
const bookings = event.scheduleType === 'sessions' ? await sessionsUtil.sessionBookings(eventId).catch(() => null) : null;
const { publicBundlesForEvent } = require('../controllers/bundleController') as { publicBundlesForEvent(id: string): Promise<unknown[]> };
const { nextInSeries } = require('../utils/series') as { nextInSeries(ev: unknown): Promise<unknown> };
return withSchedule({
...event,
eventOptions,
picture: urlOf(event.picture),
attachments: (event.attachments || []).map((att: any) => ({ ...att, url: urlOf(att.url) })),
form,
bundles: await publicBundlesForEvent(eventId).catch(() => []),
nextInSeries: await nextInSeries(event).catch(() => null),
}, bookings);
}
/** The event (or each session of a multi-day event) as an .ics calendar file. */
export async function eventCalendar(eventId: string, viewer: Viewer): Promise<{ filename: string; ics: string }> {
const event = await prisma.event.findUnique({
where: { id: eventId },
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true, scheduleType: true, campusId: true, sessions: { orderBy: { startsAt: 'asc' } } },
});
if (!event || (!staffViewOf(viewer, event.campusId) && notPublic(event))) return fail(404, 'Event not found');
const { buildEventIcs } = require('../utils/icsUtils') as { buildEventIcs(ev: unknown, url: string): string };
let frontendUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
// The ics library's URL check needs a scheme.
if (!/^[a-z0-9+.-]+:\/\//i.test(frontendUrl)) frontendUrl = `https://${frontendUrl}`;
return {
filename: `${event.title.replace(/[^a-z0-9 -]/gi, '').slice(0, 60) || 'event'}.ics`,
ics: buildEventIcs(event, `${frontendUrl}/events/${event.id}`),
};
}
const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
/** An active event by its short link (`/camp2026`), any letter case. */
export async function eventByAlias(alias: string) {
if (!VALID_ALIAS.test(alias)) return fail(404, 'Event not found');
const event = await prisma.event.findFirst({
where: { redirectUrl: { equals: alias, mode: 'insensitive' }, isActive: true },
select: { id: true, title: true, description: true, startDate: true, endDate: true, registrationDeadline: true, goLiveAt: true, price: true, picture: true, isActive: true, redirectUrl: true },
});
if (!event) return fail(404, 'Event not found or inactive');
const now = new Date();
const isLive = (!event.startDate || new Date(event.startDate) >= now) && (!event.endDate || new Date(event.endDate) >= now);
return { ...event, isLive };
}
// ─── Ticket options and variants ─────────────────────────────────────────────
/** A staff price: undefined keeps `current`, '' or null clears it. */
export function parseStaffPrice(value: unknown, current: number | null = null): number | null {
if (value === undefined) return current;
if (value === null || value === '') return null;
const n = parseFloat(String(value));
if (!(n >= 0)) fail(400, 'Staff price must be zero or more');
return n;
}
const openEvent = async (eventId: string) => {
try {
await assertEventOpen(eventId);
} catch (e: any) {
fail(e.message === 'Event not found' ? 404 : 400, e.message);
}
};
export type OptionInput = {
name?: string; price?: number | string; isMainTicket?: boolean; stockLimit?: number | string; staffPrice?: number | string | null;
sessionMode?: string; earlyBirdTiers?: any[]; variants?: any[];
};
export async function createOption(eventId: string, input: OptionInput) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
if (!event) return fail(404, 'Event not found');
await openEvent(eventId);
if (!input.name) fail(400, 'Give the ticket a name');
const option = await prisma.eventOption.create({
data: {
id: uuidv4(), eventId, name: String(input.name), price: parseFloat(String(input.price ?? 0)) || 0, isMainTicket: input.isMainTicket || false,
stockLimit: input.stockLimit ? parseInt(String(input.stockLimit), 10) : 0, staffPrice: parseStaffPrice(input.staffPrice),
sessionMode: input.sessionMode === 'pick' ? 'pick' : 'all',
},
});
sessionsUtil.forgetSchedule(eventId);
return option;
}
/**
* Changes a ticket option, and — when sent — its early-bird tiers and variants. Tiers and
* variants are upserted by id, so purchases keep pointing at the tier they were bought under;
* ones left out are removed only when nothing was bought under them.
*/
export async function updateOption(optionId: string, input: OptionInput) {
const existing = await prisma.eventOption.findUnique({ where: { id: optionId } });
if (!existing) return fail(404, 'Event option not found');
await openEvent(existing.eventId);
const updated = await prisma.eventOption.update({
where: { id: optionId },
data: {
name: input.name !== undefined ? String(input.name) : existing.name,
price: input.price !== undefined ? parseFloat(String(input.price)) : existing.price,
isMainTicket: input.isMainTicket !== undefined ? input.isMainTicket : existing.isMainTicket,
stockLimit: input.stockLimit !== undefined ? parseInt(String(input.stockLimit), 10) : existing.stockLimit,
staffPrice: parseStaffPrice(input.staffPrice, existing.staffPrice),
sessionMode: input.sessionMode !== undefined ? (input.sessionMode === 'pick' ? 'pick' : 'all') : existing.sessionMode,
},
});
// Early-bird tiers: deleting and recreating them would give every tier a new id and cut past
// purchases off from the tier they were bought under (and reset its stock count).
if (Array.isArray(input.earlyBirdTiers)) {
const incoming = input.earlyBirdTiers.filter(t => t && t.id).map(t => t.id);
for (const et of await prisma.earlyBirdTier.findMany({ where: { eventOptionId: optionId } })) {
if (incoming.includes(et.id)) continue;
// Never delete a tier still attributed on past purchases.
if (await prisma.registrationOptionTranche.count({ where: { appliedTierId: et.id } }) === 0) {
await prisma.earlyBirdTier.delete({ where: { id: et.id } }).catch(() => {});
}
}
for (let i = 0; i < input.earlyBirdTiers.length; i++) {
const t = input.earlyBirdTiers[i];
if (!t || !t.deadline || t.price === undefined || t.price === null) continue;
const deadline = new Date(t.deadline);
const p = parseFloat(t.price);
if (isNaN(deadline.getTime()) || !(p >= 0)) continue;
const data = { eventOptionId: optionId, variantId: t.variantId || null, deadline, price: p, order: typeof t.order === 'number' ? t.order : i, stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0 };
if (t.id) await prisma.earlyBirdTier.upsert({ where: { id: t.id }, update: data, create: { id: t.id, ...data } });
else await prisma.earlyBirdTier.create({ data: { id: uuidv4(), ...data } });
}
}
// Variants: upserted by id; ones left out are deleted only when nobody has bought them.
if (Array.isArray(input.variants)) {
const incoming = input.variants.filter(v => v.id).map(v => v.id);
for (const ov of await prisma.optionVariant.findMany({ where: { eventOptionId: optionId } })) {
if (!incoming.includes(ov.id) && await prisma.registrationOption.count({ where: { variantId: ov.id } }) === 0) {
await prisma.optionVariant.delete({ where: { id: ov.id } });
}
}
for (let i = 0; i < input.variants.length; i++) {
const v = input.variants[i];
if (!v.name) continue;
const data = {
name: v.name,
price: v.price !== undefined && v.price !== null && v.price !== '' ? parseFloat(v.price) : null,
stockLimit: v.stockLimit !== undefined ? parseInt(v.stockLimit, 10) : 0,
staffPrice: parseStaffPrice(v.staffPrice),
order: i,
};
if (v.id) await prisma.optionVariant.update({ where: { id: v.id }, data });
else await prisma.optionVariant.create({ data: { id: uuidv4(), eventOptionId: optionId, ...data } });
}
}
if (input.stockLimit !== undefined || input.variants !== undefined) {
(require('../utils/waitlist') as { queueWaitlist(eventId: string): void }).queueWaitlist(existing.eventId);
}
return prisma.eventOption.findUniqueOrThrow({ where: { id: updated.id }, include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } } });
}
/** Removes a ticket option nobody has bought (and never the event's only main ticket). */
export async function deleteOption(optionId: string) {
const option = await prisma.eventOption.findUnique({ where: { id: optionId } });
if (!option) return fail(404, 'Event option not found');
await openEvent(option.eventId);
if (option.isMainTicket) {
const others = await prisma.eventOption.count({ where: { eventId: option.eventId, isMainTicket: true, NOT: { id: option.id } } });
if (others === 0) fail(400, 'Cannot delete the only main ticket option');
}
if (await prisma.registrationOption.count({ where: { eventOptionId: optionId } }) > 0) fail(400, 'Cannot delete event option that has registrations');
await prisma.eventOption.delete({ where: { id: optionId } });
}
export type VariantInput = { name?: string; price?: number | string | null; stockLimit?: number | string; order?: number | string; staffPrice?: number | string | null };
export async function createVariant(optionId: string, input: VariantInput) {
const option = await prisma.eventOption.findUnique({ where: { id: optionId } });
if (!option) return fail(404, 'Event option not found');
await openEvent(option.eventId);
if (!input.name) fail(400, 'Variant name is required');
return prisma.optionVariant.create({
data: {
id: uuidv4(), eventOptionId: optionId, name: String(input.name),
price: input.price !== undefined && input.price !== null && input.price !== '' ? parseFloat(String(input.price)) : null,
stockLimit: input.stockLimit ? parseInt(String(input.stockLimit), 10) : 0,
staffPrice: parseStaffPrice(input.staffPrice),
order: input.order !== undefined ? parseInt(String(input.order), 10) : 0,
},
});
}
export async function updateVariant(variantId: string, input: VariantInput) {
const variant = await prisma.optionVariant.findUnique({ where: { id: variantId }, include: { eventOption: { select: { eventId: true } } } });
if (!variant) return fail(404, 'Variant not found');
await openEvent(variant.eventOption.eventId);
return prisma.optionVariant.update({
where: { id: variantId },
data: {
name: input.name !== undefined ? String(input.name) : variant.name,
staffPrice: parseStaffPrice(input.staffPrice, variant.staffPrice),
price: input.price !== undefined ? (input.price === null || input.price === '' ? null : parseFloat(String(input.price))) : variant.price,
stockLimit: input.stockLimit !== undefined ? parseInt(String(input.stockLimit), 10) : variant.stockLimit,
order: input.order !== undefined ? parseInt(String(input.order), 10) : variant.order,
},
});
}
/** Removes a variant nobody has bought. */
export async function deleteVariant(variantId: string) {
const variant = await prisma.optionVariant.findUnique({ where: { id: variantId }, include: { eventOption: { select: { eventId: true } } } });
if (!variant) return fail(404, 'Variant not found');
await openEvent(variant.eventOption.eventId);
if (await prisma.registrationOption.count({ where: { variantId } }) > 0) fail(400, 'Cannot delete variant with existing registrations');
await prisma.optionVariant.delete({ where: { id: variantId } });
}
// ─── Attachments ─────────────────────────────────────────────────────────────
export async function listAttachments(eventId: string, urlOf: UrlOf) {
const items = await prisma.eventAttachment.findMany({ where: { eventId }, orderBy: { createdAt: 'desc' } });
return items.map(att => ({ ...att, url: urlOf(att.url) }));
}
/** Records a file multer has already saved in ATTACHMENTS_DIR. */
export async function recordAttachment(eventId: string, file: { originalname: string; filename: string; mimetype: string; size: number }, urlOf: UrlOf) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
if (!event) {
fs.promises.unlink(path.join(ATTACHMENTS_DIR, file.filename)).catch(() => {});
return fail(404, 'Event not found');
}
const created = await prisma.eventAttachment.create({
data: { id: uuidv4(), eventId, originalName: file.originalname, filename: file.filename, mimeType: file.mimetype, size: file.size, url: `/uploads/event-files/${file.filename}` },
});
return { ...created, url: urlOf(created.url) };
}
export async function deleteAttachment(eventId: string, attachmentId: string) {
const att = await prisma.eventAttachment.findUnique({ where: { id: attachmentId } });
if (!att || att.eventId !== eventId) return fail(404, 'Attachment not found');
// The file goes too (best effort; the record is what the site shows).
await fs.promises.unlink(path.join(ATTACHMENTS_DIR, att.filename)).catch(() => {});
await prisma.eventAttachment.delete({ where: { id: attachmentId } });
}
/** Admin diagnostics: whether the attachments table answers, and which database it is (no secrets). */
export async function attachmentsStatus() {
let tableQueryable = false;
let probeError: string | null = null;
try {
await prisma.eventAttachment.findFirst({});
tableQueryable = true;
} catch (e: any) {
probeError = String(e?.message || e);
}
let database: Record<string, string | undefined> | null = null;
try {
const u = new URL(process.env.DATABASE_URL || '');
database = { protocol: u.protocol.replace(':', ''), host: u.hostname, port: u.port || undefined, database: u.pathname.replace(/^\//, '') || undefined };
} catch { /* not a URL */ }
return { prismaClientHasEventAttachmentModel: true, databaseTableQueryable: tableQueryable, probeError, database };
}
/** Imports attachments still listed in old JSON manifests on disk into the database. */
export async function syncAttachmentManifests(opts: { dryRun: boolean; remove: boolean }) {
const { syncManifestsToDb } = require('../utils/attachmentsSync') as { syncManifestsToDb(db: unknown, o: { dryRun: boolean; removeManifestAfterImport: boolean }): Promise<Record<string, unknown>> };
return { ...opts, ...(await syncManifestsToDb(prisma, { dryRun: opts.dryRun, removeManifestAfterImport: opts.remove })) };
}
+411
View File
@@ -0,0 +1,411 @@
/**
* Setting events up (1.51.7): create, edit, deactivate, the staff list, notification recipients
* and copying set-up from another event. Moved from eventController.js so /api/events (v1) and
* /api/v2/events share it.
*/
import { v4 as uuidv4 } from 'uuid';
import type { Prisma } from '@prisma/client';
import { prisma } from '../v2/legacy';
import { fail, ServiceError } from './errors';
import type { Role } from './registrations';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
type CampusScope = object | null;
const sessionsUtil = require('../utils/sessions') as {
sessionError(message: string): Error;
normalizeReminders(v: unknown): unknown;
normalizeCertificateConfig(v: unknown): unknown;
normalizeSessionsInput(v: unknown): Array<{ cancelled?: boolean } & Record<string, unknown>>;
scheduleBounds(sessions: unknown[]): { startDate: Date; endDate: Date };
forgetSchedule(eventId: string): void;
syncEventSessions(tx: any, eventId: string, sessions: unknown[]): Promise<{ sessions: Array<{ id: string; order: number; cancelledAt: Date | null }>; changes: Record<string, number> }>;
activeSessions(sessions: any[]): any[];
registrationClosesAt(ev: any, sessions: any[]): Date;
wholeCourseClosesAt(ev: any, sessions: any[]): Date;
lateInfo(ev: any, sessions: any[], now: Date): unknown;
};
const campuses = require('../utils/campuses') as {
campusesEnabled(): Promise<boolean>;
campusForEvent(campusId: unknown, opts: { enabled: boolean; currentCampusId?: string | null }): Promise<{ campusId?: string | null; error?: string }>;
campusWhere(filter: string, opts: { enabled: boolean; withAllCampuses?: boolean }): object;
resolveCampusFilter(value: unknown): Promise<string>;
EVENT_CAMPUS_SELECT: Record<string, true>;
};
const campusScope = require('../utils/campusScope') as {
canManage(scope: object, campusId: string | null): boolean;
eventWhere(scope: CampusScope): object;
withScope(where: object, scopeWhere: object): object;
};
const seriesUtil = require('../utils/series') as { seriesForEvent(seriesId: unknown): Promise<string | null | undefined> };
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
export type Actor = { id: string; role: Role };
/** The caller's campus scope (utils/campusScope.scopeFor) and IP, for checks and the audit log. */
export type Ctx = { scope: CampusScope; ip?: string };
/** An event body as the event wizard sends it (v1 and v2 take the same fields). */
export type EventInput = Record<string, any>;
export const SERIES_SELECT = { select: { id: true, name: true, slug: true } } as const;
export const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' as const }, include: { _count: { select: { usages: true } } } };
/** A campus-scoped supervisor may only create or move events to one of their campuses. */
export function assertCampusAllowed(scope: CampusScope, campusId: string | null): void {
if (scope && !campusScope.canManage(scope, campusId)) {
fail(403, campusId == null
? 'Choose one of your campuses — events for all campuses are set up by admins and supervisors for every campus.'
: 'You can only put events at your own campuses.');
}
}
/**
* Adds the multi-day schedule to an event for API responses: sessions (cancelled ones flagged),
* when registration closes, the next session, and late-joiner pricing. `bookings` (Map
* sessionId → people booked) adds `booked` and `placesLeft` per session.
*/
export function withSchedule<E extends { sessions?: any[]; scheduleType?: string }>(ev: E, bookings: Map<string, number> | null = null) {
const sessions = (ev.sessions || []).map(({ _count, ...s }: any) => ({
...s,
cancelled: !!s.cancelledAt,
hasCheckIns: (_count?.usages || 0) > 0,
...(bookings ? { booked: bookings.get(s.id) || 0, placesLeft: s.capacity > 0 ? Math.max(0, s.capacity - (bookings.get(s.id) || 0)) : null } : {}),
}));
const active = sessionsUtil.activeSessions(sessions);
const now = new Date();
return {
...ev,
sessions,
registrationClosesAt: sessionsUtil.registrationClosesAt(ev, sessions),
wholeCourseClosesAt: ev.scheduleType === 'sessions' ? sessionsUtil.wholeCourseClosesAt(ev, sessions) : null,
nextSession: ev.scheduleType === 'sessions' ? active.find((s: any) => new Date(s.endsAt) > now) || null : null,
lateJoin: ev.scheduleType === 'sessions' ? sessionsUtil.lateInfo(ev, sessions, now) : null,
};
}
/** The schedule/attendance fields from a create/update body. `current` is the saved event on update. */
export function scheduleData(body: EventInput, current: Record<string, any> = {}): Record<string, any> {
const has = (k: string) => body[k] !== undefined;
const data: Record<string, any> = {};
if (has('scheduleType')) data.scheduleType = body.scheduleType === 'sessions' ? 'sessions' : 'single';
if (has('attendanceMode')) data.attendanceMode = body.attendanceMode === 'pick' ? 'pick' : 'whole';
if ((data.attendanceMode ?? current.attendanceMode) === 'pick' && (body.requiresParentalConsent ?? current.requiresParentalConsent) === true) {
throw sessionsUtil.sessionError("Choosing sessions isn't available on youth events (parental consent).");
}
if (has('checkInWindow')) data.checkInWindow = body.checkInWindow === 'session_only' ? 'session_only' : 'whole_day';
if (has('lateJoinEnabled')) data.lateJoinEnabled = body.lateJoinEnabled === true;
if (has('latePricing')) data.latePricing = ['prorata', 'fixed'].includes(body.latePricing) ? body.latePricing : 'full';
if (has('lateFixedPrice')) {
const n = parseFloat(body.lateFixedPrice);
data.lateFixedPrice = body.lateFixedPrice === null || body.lateFixedPrice === '' || isNaN(n) ? null : Math.max(0, n);
}
if (has('sessionReminders')) data.sessionReminders = sessionsUtil.normalizeReminders(body.sessionReminders) ?? null;
if (has('certificatesEnabled')) data.certificatesEnabled = body.certificatesEnabled === true;
if (has('certificateConfig')) data.certificateConfig = sessionsUtil.normalizeCertificateConfig(body.certificateConfig);
const latePricing = data.latePricing ?? current.latePricing;
const lateFixedPrice = data.lateFixedPrice !== undefined ? data.lateFixedPrice : current.lateFixedPrice;
if (latePricing === 'fixed' && (lateFixedPrice === null || lateFixedPrice === undefined) && (data.lateJoinEnabled ?? current.lateJoinEnabled)) {
throw sessionsUtil.sessionError('Enter the fixed late price, or choose another late price option');
}
return data;
}
/**
* Saves the session list and the "late joining until" session, inside the caller's transaction
* so a refused change saves nothing. Returns the change counts, or null when nothing was sent.
*/
export async function saveSchedule(tx: Prisma.TransactionClient, eventId: string, body: EventInput): Promise<Record<string, number> | null> {
sessionsUtil.forgetSchedule(eventId);
if (body.scheduleType === 'single') {
const used = await tx.ticketUsage.count({ where: { session: { eventId } } });
if (used > 0) throw sessionsUtil.sessionError("This event already has session check-ins, so it can't go back to a single date.");
await tx.eventSession.deleteMany({ where: { eventId } });
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: null } });
return null;
}
if (!Array.isArray(body.sessions)) return null;
const { sessions, changes } = await sessionsUtil.syncEventSessions(tx, eventId, body.sessions);
if (body.lateJoinUntilSessionIndex !== undefined) {
const idx = parseInt(body.lateJoinUntilSessionIndex, 10);
const ordered = [...sessions].sort((a, b) => a.order - b.order);
const until = Number.isInteger(idx) && ordered[idx] && !ordered[idx].cancelledAt ? ordered[idx].id : null;
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: until } });
}
return changes;
}
const yes = (v: unknown) => v === true || v === 'true';
const notNo = (v: unknown) => !(v === false || v === 'false');
/** Replaces the event's attendee form with the one sent (`{ isRequired, fields[] }`). */
async function saveForm(eventId: string, formInput: any): Promise<void> {
if (!formInput) return;
try {
let form = await prisma.eventForm.findUnique({ where: { eventId } });
if (!form) form = await prisma.eventForm.create({ data: { id: uuidv4(), eventId, isRequired: !!formInput.isRequired } });
else await prisma.eventForm.update({ where: { id: form.id }, data: { isRequired: !!formInput.isRequired } });
await prisma.eventFormField.deleteMany({ where: { formId: form.id } });
const fields = Array.isArray(formInput.fields) ? formInput.fields : [];
for (let i = 0; i < fields.length; i++) {
const f = fields[i];
if (!f || !f.type || !f.label) continue;
await prisma.eventFormField.create({
data: {
id: uuidv4(), formId: form.id, type: f.type, label: String(f.label), isRequired: !!f.isRequired,
order: typeof f.order === 'number' ? f.order : i, helpText: f.helpText || null, options: f.options || undefined,
},
});
}
} catch (e: any) {
console.error('[events] Failed to save form/fields:', e?.message || e);
fail(400, 'Failed to save event form/fields');
}
}
/** Turns a helper's plain Error (session rules, series, campus) into a 400, like v1 did. */
async function asClientErrors<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e: any) {
if (e instanceof ServiceError || /^P\d{4}$/.test(String(e?.code || ''))) throw e;
throw new ServiceError(e?.statusCode || 400, e?.message || 'The event could not be saved');
}
}
/**
* A new event, with a "Main Ticket" at the event price (unless it's contact-only), its sessions
* and its attendee form. A campus-scoped supervisor must put it at one of their campuses.
*/
export function createEvent(body: EventInput, actor: Actor, ctx: Ctx) {
return asClientErrors(async () => {
const campusPick = await campuses.campusForEvent(body.campusId, { enabled: await campuses.campusesEnabled() });
if (campusPick.error) fail(400, campusPick.error);
// Not choosing a campus means all campuses, which a scoped supervisor can't create.
assertCampusAllowed(ctx.scope, campusPick.campusId === undefined ? null : campusPick.campusId);
const seriesId = await seriesUtil.seriesForEvent(body.seriesId);
const data: Record<string, any> = {
id: uuidv4(),
title: body.title,
description: body.description,
startDate: new Date(body.startDate),
endDate: new Date(body.endDate),
registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null,
goLiveAt: body.goLiveAt ? new Date(body.goLiveAt) : undefined,
price: parseFloat(body.price),
picture: body.picture,
updatedAt: new Date(),
createdById: actor.id,
redirectUrl: body.redirectUrl,
isHidden: yes(body.isHidden),
requiresAuth: notNo(body.requiresAuth),
requiresRegistration: notNo(body.requiresRegistration),
// A youth event needs names (with dates of birth), so it always takes them.
allowNamedAttendees: yes(body.allowNamedAttendees) || body.requiresParentalConsent === true,
requiresParentalConsent: body.requiresParentalConsent === true,
consentText: body.consentText ? String(body.consentText) : null,
consentCollectMedical: body.consentCollectMedical === true,
consentCollectEmergency: body.consentCollectEmergency === true,
consentCollectPhoto: body.consentCollectPhoto === true,
waitlistEnabled: body.waitlistEnabled === true,
allowTicketTransfer: body.allowTicketTransfer !== false,
collectGender: body.collectGender === true,
allowSponsorships: body.allowSponsorships === true,
sponsorshipGoalSpots: body.sponsorshipGoalSpots ? Math.max(1, parseInt(body.sponsorshipGoalSpots, 10) || 0) || null : null,
showSponsorshipProgress: body.showSponsorshipProgress !== false,
waitlistOfferHours: Math.min(168, Math.max(1, parseInt(body.waitlistOfferHours, 10) || 24)),
waitlistPartialOffers: body.waitlistPartialOffers !== false,
contactName: body.contactName || null,
contactPhone: body.contactPhone || null,
contactEmail: body.contactEmail || null,
location: body.location || null,
...(campusPick.campusId !== undefined ? { campusId: campusPick.campusId } : {}),
...(seriesId ? { seriesId } : {}),
...scheduleData(body),
};
if (data.scheduleType === 'sessions') {
// Check the sessions before anything is saved; the dates come from them.
const bounds = sessionsUtil.scheduleBounds(sessionsUtil.normalizeSessionsInput(body.sessions).filter(s => !s.cancelled));
data.startDate = bounds.startDate;
data.endDate = bounds.endDate;
}
const event = await prisma.event.create({ data: data as Prisma.EventUncheckedCreateInput });
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'event_created', targetType: 'Event', targetId: event.id,
metadata: { title: event.title, ...(body.clonedFromId ? { clonedFromId: String(body.clonedFromId) } : {}) }, ip: ctx.ip,
});
if (data.scheduleType === 'sessions') await prisma.$transaction(tx => saveSchedule(tx, event.id, body));
// A main ticket at the event price — contact-only events have nothing to book.
if (data.requiresRegistration !== false) {
await prisma.eventOption.create({ data: { id: uuidv4(), eventId: event.id, name: 'Main Ticket', price: event.price || 0, isMainTicket: true } })
.catch((e: any) => console.warn('[events] Failed to auto-create main ticket option:', e?.message || e));
}
await saveForm(event.id, body.form);
return withSchedule(await prisma.event.findUniqueOrThrow({ where: { id: event.id }, include: { sessions: SESSIONS_INCLUDE, eventOptions: true } }));
});
}
/**
* Edits an event: only the fields sent change. Its sessions save in the same transaction (a
* refused schedule change saves nothing); a changed consent text starts a new version; moving it
* to another series lets that series' followers hear about it. Closed (cashed-up) events can't
* change.
*/
export function updateEvent(eventId: string, body: EventInput, actor: Actor, ctx: Ctx) {
return asClientErrors(async () => {
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) return fail(404, 'Event not found');
// A closed (cashed-up) event's pricing/structure must not change under the reconciled totals.
await assertEventOpen(eventId);
const campusPick = await campuses.campusForEvent(body.campusId, { enabled: await campuses.campusesEnabled(), currentCampusId: event.campusId });
if (campusPick.error) fail(400, campusPick.error);
const campusChanged = campusPick.campusId !== undefined && campusPick.campusId !== event.campusId;
const seriesPick = await seriesUtil.seriesForEvent(body.seriesId);
// Moved into another series: that series' followers hear about it (once it's live).
const seriesChanged = seriesPick !== undefined && seriesPick !== event.seriesId;
if (campusChanged) assertCampusAllowed(ctx.scope, campusPick.campusId ?? null);
const keep = <K extends keyof typeof event>(k: K, v: unknown, map: (x: any) => unknown = x => x) => (v !== undefined ? map(v) : event[k]);
const requiresParentalConsent = body.requiresParentalConsent ?? event.requiresParentalConsent;
const data: Record<string, any> = {
title: body.title || event.title,
description: keep('description', body.description),
startDate: body.startDate ? new Date(body.startDate) : event.startDate,
endDate: body.endDate ? new Date(body.endDate) : event.endDate,
registrationDeadline: keep('registrationDeadline', body.registrationDeadline, v => (v ? new Date(v) : null)),
goLiveAt: body.goLiveAt !== undefined ? (body.goLiveAt ? new Date(body.goLiveAt) : new Date()) : (event.goLiveAt || undefined),
price: body.price ? parseFloat(body.price) : event.price,
picture: keep('picture', body.picture),
isActive: keep('isActive', body.isActive),
isHidden: body.isHidden !== undefined ? yes(body.isHidden) : (event.isHidden ?? false),
requiresAuth: body.requiresAuth !== undefined ? notNo(body.requiresAuth) : (event.requiresAuth ?? true),
requiresRegistration: body.requiresRegistration !== undefined ? notNo(body.requiresRegistration) : (event.requiresRegistration ?? true),
allowNamedAttendees: requiresParentalConsent === true ? true : (body.allowNamedAttendees !== undefined ? yes(body.allowNamedAttendees) : event.allowNamedAttendees),
requiresParentalConsent: keep('requiresParentalConsent', body.requiresParentalConsent, v => v === true),
consentCollectMedical: keep('consentCollectMedical', body.consentCollectMedical, v => v === true),
consentCollectEmergency: keep('consentCollectEmergency', body.consentCollectEmergency, v => v === true),
consentCollectPhoto: keep('consentCollectPhoto', body.consentCollectPhoto, v => v === true),
waitlistEnabled: keep('waitlistEnabled', body.waitlistEnabled, v => v === true),
allowTicketTransfer: keep('allowTicketTransfer', body.allowTicketTransfer, v => v !== false),
collectGender: keep('collectGender', body.collectGender, v => v === true),
allowSponsorships: keep('allowSponsorships', body.allowSponsorships, v => v === true),
sponsorshipGoalSpots: keep('sponsorshipGoalSpots', body.sponsorshipGoalSpots, v => (v ? Math.max(1, parseInt(v, 10) || 0) || null : null)),
showSponsorshipProgress: keep('showSponsorshipProgress', body.showSponsorshipProgress, v => v !== false),
waitlistOfferHours: keep('waitlistOfferHours', body.waitlistOfferHours, v => Math.min(168, Math.max(1, parseInt(v, 10) || 24))),
waitlistPartialOffers: keep('waitlistPartialOffers', body.waitlistPartialOffers, v => v !== false),
// Editing the consent text starts a new version; parents who already agreed keep their
// record of the old one (supervisors can re-request consent).
...(body.consentText !== undefined && String(body.consentText || '') !== String(event.consentText || '')
? { consentText: body.consentText ? String(body.consentText) : null, consentTextVersion: (event.consentTextVersion || 1) + 1 }
: {}),
contactName: keep('contactName', body.contactName, v => v || null),
contactPhone: keep('contactPhone', body.contactPhone, v => v || null),
contactEmail: keep('contactEmail', body.contactEmail, v => v || null),
location: keep('location', body.location, v => v || null),
...(campusChanged ? { campusId: campusPick.campusId } : {}),
...(seriesChanged ? { seriesId: seriesPick, seriesAnnouncedAt: null } : {}),
updatedAt: new Date(),
redirectUrl: keep('redirectUrl', body.redirectUrl),
...scheduleData(body, event),
};
// A multi-session event's dates come from its sessions (set by saveSchedule).
if ((data.scheduleType ?? event.scheduleType) === 'sessions') {
data.startDate = event.startDate;
data.endDate = event.endDate;
if (Array.isArray(body.sessions)) sessionsUtil.normalizeSessionsInput(body.sessions);
else if (event.scheduleType !== 'sessions') throw sessionsUtil.sessionError('Add the sessions for a multi-session event');
}
// Event fields and sessions save together: a refused schedule change saves nothing.
const scheduleBody = data.scheduleType === 'single' && event.scheduleType === 'sessions' ? { scheduleType: 'single' } : body;
let sessionChanges: Record<string, number> | null = null;
const saved = await prisma.$transaction(async (tx) => {
const updated = await tx.event.update({ where: { id: eventId }, data });
sessionChanges = updated.scheduleType === 'sessions' || scheduleBody.scheduleType === 'single' ? await saveSchedule(tx, updated.id, scheduleBody) : null;
return updated;
});
sessionsUtil.forgetSchedule(saved.id);
if (campusChanged) {
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'event_campus_changed', targetType: 'Event', targetId: saved.id, metadata: { from: event.campusId, to: campusPick.campusId }, ip: ctx.ip });
}
const changes = sessionChanges as Record<string, number> | null;
if (changes && Object.values(changes).some(n => n > 0)) {
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'event_sessions_changed', targetType: 'Event', targetId: saved.id, metadata: changes, ip: ctx.ip });
}
await saveForm(saved.id, body.form);
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'event_updated', targetType: 'Event', targetId: eventId, metadata: { changedKeys: Object.keys(body || {}) }, ip: ctx.ip });
const fresh = withSchedule(await prisma.event.findUniqueOrThrow({ where: { id: saved.id }, include: { sessions: SESSIONS_INCLUDE } }));
return { ...fresh, sessionChanges: changes };
});
}
/** "Deleting" an event deactivates it (registrations, payments and tickets stay). Admins only. */
export async function deactivateEvent(eventId: string, actor: Actor, ctx: Ctx) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
if (!event) return fail(404, 'Event not found');
const updated = await prisma.event.update({ where: { id: eventId }, data: { isActive: false, updatedAt: new Date() } });
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'event_deleted', targetType: 'Event', targetId: eventId, ip: ctx.ip });
return updated;
}
export type ManagedListOptions = { includePast?: boolean; includeInactive?: boolean; excludeClosed?: boolean; campus?: string };
/**
* Every event staff may see (hidden ones too) — upcoming by default; past, inactive and
* closed (cashed-up) on request; limited to their campuses when they're campus-scoped. With
* ticket options (tiers, variants), the attendee form, sessions, campus and series.
*/
export async function listManagedEvents(opts: ManagedListOptions, ctx: Ctx) {
const where: Record<string, any> = {};
if (!opts.includeInactive) where.isActive = true;
if (!opts.includePast) where.endDate = { gte: new Date() };
if (opts.excludeClosed) where.cashupStatus = { not: 'closed' };
// ?campus=<id, slug or 'all'>: one campus's events, or only church-wide ones.
const enabled = await campuses.campusesEnabled();
Object.assign(where, campuses.campusWhere(enabled ? await campuses.resolveCampusFilter(opts.campus) : '', { enabled }));
const scoped = campusScope.withScope(where, campusScope.eventWhere(ctx.scope));
const events = await prisma.event.findMany({
where: scoped,
include: {
eventOptions: { include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } } },
form: { include: { fields: true } },
sessions: SESSIONS_INCLUDE,
campus: { select: campuses.EVENT_CAMPUS_SELECT },
series: SERIES_SELECT,
},
orderBy: { startDate: 'asc' },
});
return events.map(ev => withSchedule(ev));
}
const RECIPIENT_SELECT = { id: true, name: true, email: true, role: true } as const;
/** Who gets this event's registration, payment and daily-summary emails. */
export async function getNotifyRecipients(eventId: string) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { notifyRecipients: { select: RECIPIENT_SELECT } } });
if (!event) return fail(404, 'Event not found');
return event.notifyRecipients;
}
export async function setNotifyRecipients(eventId: string, userIds: string[]) {
if (!Array.isArray(userIds)) fail(400, 'userIds must be an array');
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
if (!event) return fail(404, 'Event not found');
const updated = await prisma.event.update({
where: { id: eventId },
data: { notifyRecipients: { set: userIds.map(id => ({ id })) } },
include: { notifyRecipients: { select: RECIPIENT_SELECT } },
});
return updated.notifyRecipients;
}
/** Copies allocation groups (and, for admins, planned costs) from another event into this one. */
export async function copySetupFrom(eventId: string, fromEventId: string, actor: Actor) {
if (!fromEventId) fail(400, 'fromEventId is required');
const { copyEventSetup } = require('../utils/eventClone') as { copyEventSetup(from: string, to: string, opts: { withCosts: boolean }): Promise<unknown> };
return copyEventSetup(String(fromEventId), eventId, { withCosts: actor.role === 'admin' });
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Scanning sections and planned event costs (1.51.8). Moved from sectionController.js and
* costController.js so v1 and /api/v2 share them.
*/
import { v4 as uuidv4 } from 'uuid';
import { prisma } from '../v2/legacy';
import { fail } from './errors';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const cashup = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown>; ALL_METHODS: string[] };
const openEvent = async (eventId: string) => {
try {
await cashup.assertEventOpen(eventId);
} catch (e: any) {
fail(e.message === 'Event not found' ? 404 : 400, e.message);
}
};
// ─── Sections (which ticket types may come in at which door) ─────────────────
const SECTION_INCLUDE = { allowedOptions: { include: { eventOption: true } } } as const;
/** Sections, for one event or every event the caller may see (`scopeWhere` from campus scope). */
export function listSections(eventId: string | undefined, scopeWhere: object) {
const where = { AND: [eventId && eventId !== 'all' ? { eventId } : {}, scopeWhere] };
return prisma.section.findMany({ where, include: { ...SECTION_INCLUDE, event: { select: { id: true, title: true } } }, orderBy: { name: 'asc' } });
}
export async function createSection(input: { eventId?: string; name?: string; allowedOptionIds?: string[] }) {
if (!input.eventId || !input.name) return fail(400, 'Event and section name are required');
const event = await prisma.event.findUnique({ where: { id: input.eventId }, select: { id: true } });
if (!event) return fail(404, 'Event not found');
const section = await prisma.section.create({ data: { id: uuidv4(), eventId: input.eventId, name: String(input.name), updatedAt: new Date() } });
if (Array.isArray(input.allowedOptionIds) && input.allowedOptionIds.length > 0) {
await prisma.sectionOption.createMany({
data: input.allowedOptionIds.map(optionId => ({ id: uuidv4(), sectionId: section.id, eventOptionId: optionId })),
skipDuplicates: true,
});
}
return prisma.section.findUniqueOrThrow({ where: { id: section.id }, include: SECTION_INCLUDE });
}
/** Renames a section and/or replaces its ticket types (when `allowedOptionIds` is sent). */
export async function updateSection(sectionId: string, input: { name?: string; allowedOptionIds?: string[] }) {
const section = await prisma.section.findUnique({ where: { id: sectionId } });
if (!section) return fail(404, 'Section not found');
await prisma.section.update({ where: { id: sectionId }, data: { name: input.name || section.name, updatedAt: new Date() } });
if (Array.isArray(input.allowedOptionIds)) {
await prisma.sectionOption.deleteMany({ where: { sectionId } });
if (input.allowedOptionIds.length > 0) {
await prisma.sectionOption.createMany({ data: input.allowedOptionIds.map(optionId => ({ id: uuidv4(), sectionId, eventOptionId: optionId })) });
}
}
return prisma.section.findUniqueOrThrow({ where: { id: sectionId }, include: SECTION_INCLUDE });
}
export async function deleteSection(sectionId: string) {
const section = await prisma.section.findUnique({ where: { id: sectionId }, select: { id: true } });
if (!section) return fail(404, 'Section not found');
await prisma.section.delete({ where: { id: sectionId } });
}
// ─── Planned costs (for cashup and the profit report) ────────────────────────
const COST_INCLUDE = { eventOption: { select: { id: true, name: true } } } as const;
function paidFromMethod(value: unknown): string | null | undefined {
if (value === undefined) return undefined;
if (value === null || value === '') return null;
if (!cashup.ALL_METHODS.includes(String(value))) fail(400, "paidFromMethod must be one of 'cash', 'card', 'eft', 'other', or null");
return String(value);
}
export function listCosts(eventId: string) {
return prisma.eventCost.findMany({ where: { eventId }, include: COST_INCLUDE, orderBy: { createdAt: 'asc' } });
}
export type CostInput = { label?: string; costType?: string; amount?: number | string; eventOptionId?: string | null; notes?: string | null; paidFromMethod?: string | null };
/** A cost per event ("hall hire") or per ticket sold ("T-shirt per camper"). */
export async function createCost(eventId: string, input: CostInput) {
await openEvent(eventId);
if (!input.label || !String(input.label).trim()) fail(400, 'Label is required');
if (input.costType !== 'once_off' && input.costType !== 'per_item') fail(400, "costType must be 'once_off' or 'per_item'");
const amount = parseFloat(String(input.amount));
if (!(amount >= 0)) fail(400, 'Amount must be a non-negative number');
if (input.costType === 'per_item') {
if (!input.eventOptionId) fail(400, 'eventOptionId is required for per-item costs');
const option = await prisma.eventOption.findUnique({ where: { id: input.eventOptionId! }, select: { eventId: true } });
if (!option || option.eventId !== eventId) fail(404, 'Ticket type not found for this event');
}
return prisma.eventCost.create({
data: {
id: uuidv4(), eventId, label: String(input.label).trim(), costType: input.costType as 'once_off' | 'per_item', amount,
eventOptionId: input.costType === 'per_item' ? input.eventOptionId! : null,
paidFromMethod: (paidFromMethod(input.paidFromMethod) ?? null) as any, notes: input.notes || null,
},
include: COST_INCLUDE,
});
}
export async function updateCost(costId: string, input: CostInput) {
const existing = await prisma.eventCost.findUnique({ where: { id: costId } });
if (!existing) return fail(404, 'Cost not found');
await openEvent(existing.eventId);
const costType = input.costType !== undefined ? input.costType : existing.costType;
if (costType !== 'once_off' && costType !== 'per_item') fail(400, "costType must be 'once_off' or 'per_item'");
const eventOptionId = costType === 'per_item' ? (input.eventOptionId !== undefined ? input.eventOptionId : existing.eventOptionId) : null;
if (costType === 'per_item') {
if (!eventOptionId) fail(400, 'eventOptionId is required for per-item costs');
const option = await prisma.eventOption.findUnique({ where: { id: eventOptionId! }, select: { eventId: true } });
if (!option || option.eventId !== existing.eventId) fail(404, 'Ticket type not found for this event');
}
let amount = existing.amount;
if (input.amount !== undefined) {
amount = parseFloat(String(input.amount));
if (!(amount >= 0)) fail(400, 'Amount must be a non-negative number');
}
const paid = paidFromMethod(input.paidFromMethod);
return prisma.eventCost.update({
where: { id: costId },
data: {
label: input.label !== undefined ? String(input.label).trim() : existing.label,
costType: costType as 'once_off' | 'per_item',
amount,
eventOptionId,
paidFromMethod: (paid !== undefined ? paid : existing.paidFromMethod) as any,
notes: input.notes !== undefined ? input.notes : existing.notes,
},
include: COST_INCLUDE,
});
}
export async function deleteCost(costId: string) {
const existing = await prisma.eventCost.findUnique({ where: { id: costId }, select: { eventId: true } });
if (!existing) return fail(404, 'Cost not found');
await openEvent(existing.eventId);
await prisma.eventCost.delete({ where: { id: costId } });
}
+33 -1
View File
@@ -13,7 +13,39 @@ type Successor = { method: string; v1: RegExp; v2: (m: RegExpMatchArray) => stri
const SUCCESSORS: Successor[] = [
{ method: 'GET', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
// v1's event detail is the full record (v2's /events/{id} is the public summary).
{ method: 'GET', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/full` },
// 1.51.8: the rest of the events area
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/ics$/, v2: m => `/api/v2/events/${m[1]}/calendar.ics` },
{ method: 'GET', v1: /^\/api\/events\/by-alias\/([^/]+)$/, v2: m => `/api/v2/events/by-alias/${m[1]}` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/options$/, v2: m => `/api/v2/events/${m[1]}/ticket-options` },
{ method: 'PUT', v1: /^\/api\/events\/options\/([^/]+)$/, v2: m => `/api/v2/ticket-options/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/events\/options\/([^/]+)$/, v2: m => `/api/v2/ticket-options/${m[1]}` },
{ method: 'POST', v1: /^\/api\/events\/options\/([^/]+)\/variants$/, v2: m => `/api/v2/ticket-options/${m[1]}/variants` },
{ method: 'PUT', v1: /^\/api\/events\/variants\/([^/]+)$/, v2: m => `/api/v2/variants/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/events\/variants\/([^/]+)$/, v2: m => `/api/v2/variants/${m[1]}` },
{ method: 'GET', v1: /^\/api\/events\/attachments\/status$/, v2: () => '/api/v2/attachments/status' },
{ method: 'POST', v1: /^\/api\/events\/attachments\/sync$/, v2: () => '/api/v2/attachments/sync' },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/attachments$/, v2: m => `/api/v2/events/${m[1]}/attachments` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/attachments$/, v2: m => `/api/v2/events/${m[1]}/attachments` },
{ method: 'DELETE', v1: /^\/api\/events\/([^/]+)\/attachments\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/attachments/${m[2]}` },
{ method: 'GET', v1: /^\/api\/sections\/?$/, v2: () => '/api/v2/sections' },
{ method: 'POST', v1: /^\/api\/sections\/?$/, v2: () => '/api/v2/sections' },
{ method: 'PUT', v1: /^\/api\/sections\/([^/]+)$/, v2: m => `/api/v2/sections/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/sections\/([^/]+)$/, v2: m => `/api/v2/sections/${m[1]}` },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/costs$/, v2: m => `/api/v2/events/${m[1]}/costs` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/costs$/, v2: m => `/api/v2/events/${m[1]}/costs` },
{ method: 'PUT', v1: /^\/api\/costs\/([^/]+)$/, v2: m => `/api/v2/costs/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/costs\/([^/]+)$/, v2: m => `/api/v2/costs/${m[1]}` },
{ method: 'POST', v1: /^\/api\/uploads\/(event-image|logo|favicon)$/, v2: m => `/api/v2/uploads/${m[1]}` },
// 1.51.7: setting events up
{ method: 'GET', v1: /^\/api\/events\/all$/, v2: () => '/api/v2/events/manage' },
{ method: 'POST', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/copy-from$/, v2: m => `/api/v2/events/${m[1]}/copy-from` },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
{ method: 'GET', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
{ method: 'PUT', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
{ method: 'POST', v1: /^\/api\/users\/?$/, v2: () => '/api/v2/auth/signup' },
+4
View File
@@ -5,6 +5,10 @@ import { buildOpenApi } from './openapi';
import { errorHandler, notFoundHandler } from './errors';
import auth from '../middleware/authMiddleware';
// Resources: importing one registers its routes.
// eventsManage first: /events/manage must match before /events/:id.
import './resources/eventsManage';
import './resources/eventDetails';
import './resources/setup';
import './resources/events';
import './resources/me';
import './resources/registrations';
+6 -2
View File
@@ -71,9 +71,13 @@ export function buildOpenApi(opts: { version: string; serverUrl?: string; list?:
operationId: `${def.method}${path.replace(/[{}]/g, '').split('/').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')}`,
...(secured ? { security: [{ bearerAuth: [] }] } : def.access === 'optional' ? { security: [{}, { bearerAuth: [] }] } : {}),
parameters: parameters(def),
...(def.body ? { requestBody: { required: true, content: { 'application/json': { schema: toSchema(def.body, 'input') } } } } : {}),
...(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') } } } } : {}),
responses: {
[String(def.status ?? 200)]: { description: 'OK', content: { 'application/json': { schema: toSchema(def.response, 'output') } } },
[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') } } },
...(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)') } : {}),
+282
View File
@@ -0,0 +1,282 @@
import { z } from 'zod';
import type { Request } from 'express';
import { defineRoute, fileResponse, type ApiUser } from '../route';
import { ApiError } from '../errors';
import { single } from '../paging';
import { absoluteUrl, id, idParams, isoDate } from '../schemas';
import * as details from '../../services/eventDetails';
import { managedEvent } from './eventsManage';
/* eslint-disable @typescript-eslint/no-explicit-any -- stored records are passed through */
const campusScope = require('../../utils/campusScope') as {
scopeFor(req: unknown): Promise<object | null>;
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
};
// The same multer step as v1 (field "file"; PDFs, Office files, text, CSV, ZIP; 20 MB).
const { receiveAttachment } = require('../../controllers/eventController') as { receiveAttachment: any };
const urlOf = (req: Request) => (u: string | null | undefined) => absoluteUrl(req, u);
const plain = <T>(v: T): any => JSON.parse(JSON.stringify(v));
const viewerOf = async (req: Request, user: ApiUser | null) => ({ staff: !!user && ['admin', 'supervisor', 'staff'].includes(user.role), scope: await campusScope.scopeFor(req) });
// ─── The event in full, calendar file, short link ──────────────────────────────
export const full = defineRoute({
method: 'get',
path: '/events/:id/full',
tag: 'Events',
summary: 'One event in full',
description: [
'What the registration page and the set-up screens need: ticket options with what\'s left (per variant, and per session for "pick sessions" tickets), early-bird tiers, sessions with bookings, the attendee form, attachments, bundle deals on sale and the next event in its series.',
'Staff (of a campus they work with) also get inactive and not-yet-live events, who created it, who gets its emails, and the kids/volunteer tickets. Everyone else gets NOT_FOUND for events that aren\'t public.',
].join('\n\n'),
access: 'optional',
params: idParams,
response: single(managedEvent.extend({
form: z.looseObject({ id, isRequired: z.boolean(), fields: z.array(z.looseObject({ id, type: z.string(), label: z.string() })) }).nullable(),
attachments: z.array(z.looseObject({ id, originalName: z.string(), url: z.string().nullable() })),
})),
async handler({ params, user, req }) {
return { data: plain(await details.getEventDetail(params.id, await viewerOf(req, user), urlOf(req))) };
},
});
export const calendar = defineRoute({
method: 'get',
path: '/events/:id/calendar.ics',
tag: 'Events',
summary: 'The event as a calendar file',
description: 'An .ics file (each session for a multi-day event). Same visibility as the event.',
access: 'optional',
file: { contentType: 'text/calendar' },
params: idParams,
response: fileResponse,
async handler({ params, user, req }) {
const { filename, ics } = await details.eventCalendar(params.id, await viewerOf(req, user));
return { data: { contentType: 'text/calendar; charset=utf-8', filename, body: ics } };
},
});
export const byAlias = defineRoute({
method: 'get',
path: '/events/by-alias/:alias',
tag: 'Events',
summary: 'An event by its short link',
description: 'The part after the site address (`/camp2026`), any letter case. Active events only.',
access: 'public',
params: z.object({ alias: z.string().min(1).max(100).describe('The short link, e.g. camp2026') }),
response: single(z.object({
id, title: z.string(), description: z.string().nullable(), startDate: isoDate, endDate: isoDate, registrationDeadline: isoDate.nullable(),
goLiveAt: isoDate.nullable(), price: z.number(), picture: z.string().nullable(), redirectUrl: z.string().nullable(),
isLive: z.boolean().describe('Not over yet'),
})),
async handler({ params, req }) {
const ev = plain(await details.eventByAlias(params.alias));
return { data: { ...ev, picture: absoluteUrl(req, ev.picture) } };
},
});
// ─── Ticket options and variants ─────────────────────────────────────────────
const variant = z.looseObject({ id, name: z.string(), price: z.number().nullable(), stockLimit: z.number().int(), staffPrice: z.number().nullable(), order: z.number().int() });
const tier = z.looseObject({ id, deadline: isoDate, price: z.number(), stockLimit: z.number().int(), variantId: id.nullable() });
const ticketOption = z.looseObject({
id, eventId: id, name: z.string(), price: z.number(), isMainTicket: z.boolean(), stockLimit: z.number().int(), staffPrice: z.number().nullable(),
sessionMode: z.enum(['all', 'pick']), earlyBirdTiers: z.array(tier).optional(), variants: z.array(variant).optional(),
});
const money = z.union([z.number(), z.string().max(20)]);
const optionBody = z.object({
name: z.string().trim().min(1).max(120),
price: money,
isMainTicket: z.boolean().optional(),
stockLimit: z.union([z.number().int().min(0), z.string().max(10)]).optional().describe('0 = no limit'),
staffPrice: money.nullable().optional().describe('What staff pay (empty = the normal price)'),
sessionMode: z.enum(['all', 'pick']).optional().describe('"pick" = buyers choose sessions (multi-day events)'),
});
const variantBody = z.object({
name: z.string().trim().min(1).max(120),
price: money.nullable().optional().describe('Empty = the option\'s price'),
stockLimit: z.union([z.number().int().min(0), z.string().max(10)]).optional(),
staffPrice: money.nullable().optional(),
order: z.union([z.number().int(), z.string().max(10)]).optional(),
});
export const createOption = defineRoute({
method: 'post',
path: '/events/:id/ticket-options',
tag: 'Events (staff)',
summary: 'Add a ticket option',
access: 'supervisor',
campusScope: 'scopeEvent(manage)',
middleware: [campusScope.scopeEvent({ need: 'manage' })],
status: 201,
params: idParams,
body: optionBody,
response: single(ticketOption),
async handler({ params, body }) {
return { data: plain(await details.createOption(params.id, body)) };
},
});
export const updateOption = defineRoute({
method: 'patch',
path: '/ticket-options/:id',
tag: 'Events (staff)',
summary: 'Change a ticket option',
description: 'Only what\'s sent changes. `earlyBirdTiers` and `variants`, when sent, are the full lists: kept by id, added without one, and ones left out removed when nothing was bought under them.',
access: 'supervisor',
campusScope: 'scopeEvent(eventOption, manage)',
middleware: [campusScope.scopeEvent({ resolve: 'eventOption', need: 'manage' })],
params: idParams,
body: optionBody.partial().extend({
earlyBirdTiers: z.array(z.looseObject({ id: id.optional(), deadline: z.string().max(40), price: money, stockLimit: z.union([z.number(), z.string()]).optional(), variantId: id.nullable().optional() })).max(50).optional(),
variants: z.array(variantBody.extend({ id: id.optional() })).max(100).optional(),
}),
response: single(ticketOption),
async handler({ params, body }) {
return { data: plain(await details.updateOption(params.id, body)) };
},
});
export const deleteOption = defineRoute({
method: 'delete',
path: '/ticket-options/:id',
tag: 'Events (staff)',
summary: 'Remove a ticket option',
description: 'Admins only. Not one anybody has bought, and never the event\'s only main ticket.',
access: 'admin',
params: idParams,
response: single(z.object({ id, deleted: z.literal(true) })),
async handler({ params }) {
await details.deleteOption(params.id);
return { data: { id: params.id, deleted: true as const } };
},
});
export const createVariant = defineRoute({
method: 'post',
path: '/ticket-options/:id/variants',
tag: 'Events (staff)',
summary: 'Add a variant (e.g. a T-shirt size)',
access: 'supervisor',
campusScope: 'scopeEvent(eventOption, manage)',
middleware: [campusScope.scopeEvent({ resolve: 'eventOption', need: 'manage' })],
status: 201,
params: idParams,
body: variantBody,
response: single(variant),
async handler({ params, body }) {
return { data: plain(await details.createVariant(params.id, body)) };
},
});
export const updateVariant = defineRoute({
method: 'patch',
path: '/variants/:id',
tag: 'Events (staff)',
summary: 'Change a variant',
access: 'supervisor',
campusScope: 'scopeEvent(optionVariant, manage)',
middleware: [campusScope.scopeEvent({ resolve: 'optionVariant', need: 'manage' })],
params: idParams,
body: variantBody.partial(),
response: single(variant),
async handler({ params, body }) {
return { data: plain(await details.updateVariant(params.id, body)) };
},
});
export const deleteVariant = defineRoute({
method: 'delete',
path: '/variants/:id',
tag: 'Events (staff)',
summary: 'Remove a variant',
description: 'Admins only, and not one anybody has bought.',
access: 'admin',
params: idParams,
response: single(z.object({ id, deleted: z.literal(true) })),
async handler({ params }) {
await details.deleteVariant(params.id);
return { data: { id: params.id, deleted: true as const } };
},
});
// ─── Attachments ─────────────────────────────────────────────────────────────
const attachment = z.object({ id, eventId: id, originalName: z.string(), filename: z.string(), mimeType: z.string(), size: z.number().int(), url: z.string().nullable(), createdAt: isoDate });
export const listAttachments = defineRoute({
method: 'get',
path: '/events/:id/attachments',
tag: 'Events',
summary: 'An event\'s attachments',
description: 'Files for attendees (programmes, packing lists…), newest first.',
access: 'public',
params: idParams,
response: z.object({ data: z.array(attachment) }),
async handler({ params, req }) {
return { data: plain(await details.listAttachments(params.id, urlOf(req))) };
},
});
export const uploadAttachment = defineRoute({
method: 'post',
path: '/events/:id/attachments',
tag: 'Events (staff)',
summary: 'Attach a file to an event',
access: 'supervisor',
campusScope: 'scopeEvent(manage)',
middleware: [campusScope.scopeEvent({ need: 'manage' }), receiveAttachment],
upload: { field: 'file', description: 'PDF, Word, Excel, PowerPoint, text, CSV or ZIP, up to 20 MB' },
status: 201,
params: idParams,
response: single(attachment),
async handler({ params, req }) {
const r = req as Request & { multerError?: Error; file?: { originalname: string; filename: string; mimetype: string; size: number } };
if (r.multerError) throw new ApiError('VALIDATION_FAILED', r.multerError.message, { file: r.multerError.message });
if (!r.file) throw new ApiError('VALIDATION_FAILED', 'No file uploaded', { file: 'Choose a file' });
return { data: plain(await details.recordAttachment(params.id, r.file, urlOf(req))) };
},
});
export const deleteAttachment = defineRoute({
method: 'delete',
path: '/events/:id/attachments/:attachmentId',
tag: 'Events (staff)',
summary: 'Remove an attachment',
access: 'supervisor',
campusScope: 'scopeEvent(manage)',
middleware: [campusScope.scopeEvent({ need: 'manage' })],
params: z.object({ id, attachmentId: id }),
response: single(z.object({ id, deleted: z.literal(true) })),
async handler({ params }) {
await details.deleteAttachment(params.id, params.attachmentId);
return { data: { id: params.attachmentId, deleted: true as const } };
},
});
export const attachmentsStatus = defineRoute({
method: 'get',
path: '/attachments/status',
tag: 'Admin',
summary: 'Attachments diagnostics',
access: 'admin',
response: single(z.looseObject({ databaseTableQueryable: z.boolean(), probeError: z.string().nullable() })),
async handler() {
return { data: plain(await details.attachmentsStatus()) };
},
});
export const attachmentsSync = defineRoute({
method: 'post',
path: '/attachments/sync',
tag: 'Admin',
summary: 'Import attachments from old manifests',
description: 'Attachments recorded in JSON files on disk by very old versions go into the database. `dryRun` only reports; `remove` deletes each manifest once imported.',
access: 'admin',
body: z.object({ dryRun: z.boolean().default(false), remove: z.boolean().default(false) }),
response: single(z.looseObject({ dryRun: z.boolean(), remove: z.boolean() })),
async handler({ body }) {
return { data: plain(await details.syncAttachmentManifests(body)) };
},
});
+190
View File
@@ -0,0 +1,190 @@
import { z } from 'zod';
import type { Request } from 'express';
import { defineRoute, type ApiUser } from '../route';
import { single } from '../paging';
import { absoluteUrl, id, idParams, isoDate } from '../schemas';
import {
createEvent, updateEvent, deactivateEvent, listManagedEvents, getNotifyRecipients, setNotifyRecipients, copySetupFrom,
} from '../../services/events';
/* eslint-disable @typescript-eslint/no-explicit-any -- event records are passed through as stored */
const campusScope = require('../../utils/campusScope') as {
scopeFor(req: unknown): Promise<object | null>;
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
scopeBody(targets: [string, string][], opts?: object): any;
};
/**
* Staff and supervisor views of events (1.51.7). Event records are large (ticket options with
* tiers and variants, the attendee form, sessions…) and grow with features, so the schema types
* the core fields and lets the rest through as stored (`looseObject`); dates come back as ISO
* strings. Registered before resources/events.ts so /events/manage isn't read as an event id.
*/
const option = z.looseObject({ id, name: z.string(), price: z.number(), isMainTicket: z.boolean(), stockLimit: z.number().int() });
const session = z.looseObject({ id, label: z.string().nullable(), startsAt: isoDate, endsAt: isoDate, cancelled: z.boolean() });
export const managedEvent = z.looseObject({
id,
title: z.string(),
description: z.string().nullable(),
startDate: isoDate,
endDate: isoDate,
isActive: z.boolean(),
isHidden: z.boolean(),
requiresRegistration: z.boolean(),
scheduleType: z.enum(['single', 'sessions']),
attendanceMode: z.enum(['whole', 'pick']),
cashupStatus: z.enum(['open', 'closed']),
campusId: id.nullable(),
seriesId: id.nullable(),
picture: z.string().nullable(),
eventOptions: z.array(option).optional(),
sessions: z.array(session).optional(),
});
/** A stored record as JSON (dates → ISO strings), with the picture as an absolute URL. */
const toJson = (req: Request, ev: any) => {
const plain = JSON.parse(JSON.stringify(ev));
if ('picture' in plain) plain.picture = absoluteUrl(req, plain.picture);
return plain;
};
const ctxOf = async (req: Request) => {
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
return { scope: await campusScope.scopeFor(req), ip: getClientIp(req) };
};
const actorOf = (user: ApiUser | null) => ({ id: user!.id, role: user!.role });
// The event wizard's fields. Only these are named here; the rest of the wizard's fields (consent,
// waitlist, sponsorship, sessions, late joining, certificates, form…) pass through as sent.
const eventBody = z.looseObject({
title: z.string().trim().min(1).max(200).optional(),
description: z.string().max(20000).nullable().optional(),
startDate: z.string().max(40).optional(),
endDate: z.string().max(40).optional(),
price: z.union([z.number(), z.string().max(20)]).optional(),
campusId: id.nullable().optional(),
seriesId: id.nullable().optional(),
scheduleType: z.enum(['single', 'sessions']).optional(),
sessions: z.array(z.looseObject({})).max(200).optional(),
});
export const listManaged = defineRoute({
method: 'get',
path: '/events/manage',
tag: 'Events (staff)',
summary: 'Every event staff can see',
description: 'Hidden ones too. Upcoming by default; `past`, `inactive` and `closed` (cashed-up) widen it. Campus-scoped staff see their campuses\' events and church-wide ones. Not paged — it\'s what the event pickers search.',
access: 'staff',
campusScope: 'scopeList (in listManagedEvents)',
query: z.object({
past: z.enum(['true', 'false']).default('false').transform(v => v === 'true').describe('Include events that have ended'),
inactive: z.enum(['true', 'false']).default('false').transform(v => v === 'true').describe('Include deactivated events'),
closed: z.enum(['true', 'false']).default('true').transform(v => v === 'true').describe('Include cashed-up events (default true)'),
campus: z.string().max(64).optional().describe("A campus id or slug, or 'all' for church-wide events only"),
}),
response: z.object({ data: z.array(managedEvent) }),
async handler({ query, req }) {
const events = await listManagedEvents({ includePast: query.past, includeInactive: query.inactive, excludeClosed: !query.closed, campus: query.campus }, await ctxOf(req));
return { data: events.map(ev => toJson(req, ev)) };
},
});
export const create = defineRoute({
method: 'post',
path: '/events',
tag: 'Events (staff)',
summary: 'Create an event',
description: 'Gets a "Main Ticket" at the event price (unless registration is off). A campus-scoped supervisor must choose one of their campuses.',
access: 'supervisor',
campusScope: 'campus checked in createEvent (assertCampusAllowed)',
status: 201,
body: eventBody.extend({ title: z.string().trim().min(1).max(200), startDate: z.string().max(40), endDate: z.string().max(40) }),
response: single(managedEvent),
async handler({ body, user, req }) {
return { data: toJson(req, await createEvent(body, actorOf(user), await ctxOf(req))) };
},
});
export const update = defineRoute({
method: 'patch',
path: '/events/:id',
tag: 'Events (staff)',
summary: 'Change an event',
description: 'Only the fields sent change. Sessions save with the event (a refused schedule change saves nothing). A cashed-up event can\'t change until an admin reopens it. The answer includes `sessionChanges` (added, moved, cancelled… counts) when sessions were sent.',
access: 'supervisor',
campusScope: 'scopeEvent(manage)',
middleware: [campusScope.scopeEvent({ need: 'manage' })],
params: idParams,
body: eventBody,
response: single(managedEvent.extend({ sessionChanges: z.record(z.string(), z.number()).nullable() })),
async handler({ params, body, user, req }) {
return { data: toJson(req, await updateEvent(params.id, body, actorOf(user), await ctxOf(req))) };
},
});
export const remove = defineRoute({
method: 'delete',
path: '/events/:id',
tag: 'Events (staff)',
summary: 'Deactivate an event',
description: 'Admins only. The event is deactivated, not deleted: registrations, payments and tickets stay.',
access: 'admin',
params: idParams,
response: single(z.object({ id, isActive: z.literal(false) })),
async handler({ params, user, req }) {
const ev = await deactivateEvent(params.id, actorOf(user), await ctxOf(req));
return { data: { id: ev.id, isActive: false as const } };
},
});
export const copyFrom = defineRoute({
method: 'post',
path: '/events/:id/copy-from',
tag: 'Events (staff)',
summary: 'Copy set-up from another event',
description: 'Allocation groups, and planned costs for admins — used right after copying an event.',
access: 'supervisor',
campusScope: 'scopeEvent(manage) + scopeBody(fromEventId)',
middleware: [campusScope.scopeEvent({ need: 'manage' }), campusScope.scopeBody([['fromEventId', 'event']])],
params: idParams,
body: z.object({ fromEventId: id }),
response: single(z.looseObject({})),
async handler({ params, body, user }) {
return { data: JSON.parse(JSON.stringify(await copySetupFrom(params.id, body.fromEventId, actorOf(user)) ?? {})) };
},
});
const recipient = z.object({ id, name: z.string(), email: z.string(), role: z.enum(['admin', 'supervisor', 'staff', 'user']) });
export const listRecipients = defineRoute({
method: 'get',
path: '/events/:id/notify-recipients',
tag: 'Events (staff)',
summary: 'Who gets this event\'s emails',
description: 'Registration, payment and daily-summary emails.',
access: 'supervisor',
campusScope: 'scopeEvent(view)',
middleware: [campusScope.scopeEvent()],
params: idParams,
response: z.object({ data: z.array(recipient) }),
async handler({ params }) {
return { data: await getNotifyRecipients(params.id) };
},
});
export const setRecipients = defineRoute({
method: 'put',
path: '/events/:id/notify-recipients',
tag: 'Events (staff)',
summary: 'Set who gets this event\'s emails',
access: 'supervisor',
campusScope: 'scopeEvent(manage)',
middleware: [campusScope.scopeEvent({ need: 'manage' })],
params: idParams,
body: z.object({ userIds: z.array(id).max(200) }),
response: z.object({ data: z.array(recipient) }),
async handler({ params, body }) {
return { data: await setNotifyRecipients(params.id, body.userIds) };
},
});
+229
View File
@@ -0,0 +1,229 @@
import { z } from 'zod';
import type { Request, RequestHandler } from 'express';
import { defineRoute } from '../route';
import { ApiError } from '../errors';
import { single } from '../paging';
import { absoluteUrl, id, idParams, isoDate } from '../schemas';
import * as setup from '../../services/setup';
import { prisma } from '../legacy';
import auth from '../../middleware/authMiddleware';
/* eslint-disable @typescript-eslint/no-explicit-any -- multer and the scope middleware are JS */
const campusScope = require('../../utils/campusScope') as {
listWhere(req: unknown, path?: string): Promise<object>;
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
};
const uploads = require('../../controllers/uploadController') as { upload: any; uploadLogo: any; uploadFavicon: any };
const plain = <T>(v: T): any => JSON.parse(JSON.stringify(v));
// ─── Sections ────────────────────────────────────────────────────────────────
const section = z.looseObject({
id, eventId: id, name: z.string(),
allowedOptions: z.array(z.looseObject({ eventOptionId: id, eventOption: z.looseObject({ id, name: z.string() }) })),
});
export const listSections = defineRoute({
method: 'get',
path: '/sections',
tag: 'Events (staff)',
summary: 'Scanning sections',
description: 'Which ticket types may come in at which door, for one event or every event you can see.',
access: 'staff',
campusScope: 'scopeList (listWhere)',
query: z.object({ eventId: id.optional() }),
response: z.object({ data: z.array(section.extend({ event: z.object({ id, title: z.string() }) })) }),
async handler({ query, req }) {
return { data: plain(await setup.listSections(query.eventId, await campusScope.listWhere(req, 'event'))) };
},
});
export const createSection = defineRoute({
method: 'post',
path: '/sections',
tag: 'Events (staff)',
summary: 'Add a scanning section',
access: 'supervisor',
campusScope: 'scopeEvent(body.eventId, manage)',
middleware: [campusScope.scopeEvent({ from: req => req.body?.eventId, need: 'manage' })],
status: 201,
body: z.object({ eventId: id, name: z.string().trim().min(1).max(120), allowedOptionIds: z.array(id).max(100).optional() }),
response: single(section),
async handler({ body }) {
return { data: plain(await setup.createSection(body)) };
},
});
export const updateSection = defineRoute({
method: 'patch',
path: '/sections/:id',
tag: 'Events (staff)',
summary: 'Change a scanning section',
description: '`allowedOptionIds`, when sent, replaces its ticket types.',
access: 'supervisor',
campusScope: 'scopeEvent(section, manage)',
middleware: [campusScope.scopeEvent({ resolve: 'section', need: 'manage' })],
params: idParams,
body: z.object({ name: z.string().trim().min(1).max(120).optional(), allowedOptionIds: z.array(id).max(100).optional() }),
response: single(section),
async handler({ params, body }) {
return { data: plain(await setup.updateSection(params.id, body)) };
},
});
export const deleteSection = defineRoute({
method: 'delete',
path: '/sections/:id',
tag: 'Events (staff)',
summary: 'Remove a scanning section',
access: 'admin',
params: idParams,
response: single(z.object({ id, deleted: z.literal(true) })),
async handler({ params }) {
await setup.deleteSection(params.id);
return { data: { id: params.id, deleted: true as const } };
},
});
// ─── Planned costs ───────────────────────────────────────────────────────────
const cost = z.object({
id, eventId: id, label: z.string(), costType: z.enum(['once_off', 'per_item']), amount: z.number(),
eventOptionId: id.nullable(), eventOption: z.object({ id, name: z.string() }).nullable(),
paidFromMethod: z.string().nullable().describe('How it was paid out of the takings (cash, card, eft, other)'),
notes: z.string().nullable(), createdAt: isoDate, updatedAt: isoDate.optional(),
});
const costBody = z.object({
label: z.string().trim().min(1).max(200),
costType: z.enum(['once_off', 'per_item']).describe('per_item = per ticket sold of `eventOptionId`'),
amount: z.union([z.number().min(0), z.string().max(20)]),
eventOptionId: id.nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
paidFromMethod: z.enum(['cash', 'card', 'eft', 'other']).nullable().optional(),
});
export const listCosts = defineRoute({
method: 'get',
path: '/events/:id/costs',
tag: 'Events (staff)',
summary: 'An event\'s planned costs',
access: 'supervisor',
campusScope: 'scopeEvent(manage)',
middleware: [campusScope.scopeEvent({ need: 'manage' })],
params: idParams,
response: z.object({ data: z.array(cost) }),
async handler({ params }) {
return { data: plain(await setup.listCosts(params.id)) };
},
});
export const createCost = defineRoute({
method: 'post',
path: '/events/:id/costs',
tag: 'Events (staff)',
summary: 'Add a planned cost',
description: 'Admins. Not on a cashed-up event.',
access: 'admin',
status: 201,
params: idParams,
body: costBody,
response: single(cost),
async handler({ params, body }) {
return { data: plain(await setup.createCost(params.id, body)) };
},
});
export const updateCost = defineRoute({
method: 'patch',
path: '/costs/:id',
tag: 'Events (staff)',
summary: 'Change a planned cost',
access: 'admin',
params: idParams,
body: costBody.partial(),
response: single(cost),
async handler({ params, body }) {
return { data: plain(await setup.updateCost(params.id, body)) };
},
});
export const deleteCost = defineRoute({
method: 'delete',
path: '/costs/:id',
tag: 'Events (staff)',
summary: 'Remove a planned cost',
access: 'admin',
params: idParams,
response: single(z.object({ id, deleted: z.literal(true) })),
async handler({ params }) {
await setup.deleteCost(params.id);
return { data: { id: params.id, deleted: true as const } };
},
});
// ─── Image uploads ───────────────────────────────────────────────────────────
/** Runs a multer step for field "image", keeping any error on the request. */
const receive = (m: any): RequestHandler => (req, res, next) => m.single('image')(req, res, (e: Error | undefined) => {
if (e) (req as Request & { multerError?: Error }).multerError = e;
next();
});
/** Branding images: admins — or anyone during first-time setup, before any account exists. */
const brandingAccess: RequestHandler = async (req, res, next) => {
try {
if (await prisma.user.count() === 0) return next();
} catch { /* fall through to the admin check */ }
auth.protect(req, res, (e?: unknown) => (e ? next(e) : auth.admin(req, res, next)));
};
const uploaded = (folder: string) => async ({ req }: { req: Request }) => {
const r = req as Request & { multerError?: Error; file?: { filename: string } };
if (r.multerError) throw new ApiError('VALIDATION_FAILED', `Upload failed: ${r.multerError.message}`, { image: r.multerError.message });
if (!r.file) throw new ApiError('VALIDATION_FAILED', 'No file uploaded', { image: 'Choose an image' });
const path = `/uploads/${folder}/${r.file.filename}`;
return { data: { path, url: absoluteUrl(req, path)! } };
};
const uploadResult = single(z.object({ path: z.string().describe('What to save on the event or setting'), url: z.string().describe('The same, as a full address') }));
const IMAGE = 'A JPEG, PNG, GIF or WebP image';
const LOGO = 'A JPEG, PNG, WebP or SVG image';
export const uploadEventImage = defineRoute({
method: 'post',
path: '/uploads/event-image',
tag: 'Uploads',
summary: 'Upload an event picture',
description: 'Returns the path to save as the event\'s `picture`.',
access: 'supervisor',
campusScope: 'campusIndependent (only stores the file; the event is checked when it is saved)',
middleware: [receive(uploads.upload)],
upload: { field: 'image', description: IMAGE },
response: uploadResult,
handler: uploaded('events'),
});
export const uploadLogo = defineRoute({
method: 'post',
path: '/uploads/logo',
tag: 'Uploads',
summary: 'Upload the site logo',
description: 'Admins — or anyone during first-time setup, before any account exists.',
access: 'public',
middleware: [brandingAccess, receive(uploads.uploadLogo)],
upload: { field: 'image', description: LOGO },
response: uploadResult,
handler: uploaded('branding'),
});
export const uploadFavicon = defineRoute({
method: 'post',
path: '/uploads/favicon',
tag: 'Uploads',
summary: 'Upload the site icon (favicon)',
description: 'Admins — or anyone during first-time setup, before any account exists.',
access: 'public',
middleware: [brandingAccess, receive(uploads.uploadFavicon)],
upload: { field: 'image', description: 'An ICO, PNG or SVG image' },
response: uploadResult,
handler: uploaded('branding'),
});
+17
View File
@@ -50,6 +50,13 @@ export type RouteDef<P extends Schema | undefined, Q extends Schema | undefined,
status?: number;
/** Runs after the access check, e.g. a rate limiter. */
middleware?: RequestHandler[];
/** A multipart upload (the file in `field`, saved by a multer step in `middleware`). */
upload?: { field: string; description: string };
/**
* The answer is a file, not JSON: the handler returns `{ data: { contentType, filename, body } }`
* and `response` is `fileResponse`. `contentType` here is what the docs show.
*/
file?: { contentType: string };
handler: (ctx: Ctx<Infer<P>, Infer<Q>, Infer<B>>) => Promise<z.input<R>> | z.input<R>;
};
@@ -97,6 +104,13 @@ export function mount(router: Router, list: AnyRoute[] = routes): void {
console.error(`[api/v2] ${def.method.toUpperCase()} ${def.path}: response doesn't match its schema`, checked.error.issues);
throw new ApiError('INTERNAL', 'Something went wrong on the server.');
}
if (def.file) {
const f = (checked.data as { data: { contentType: string; filename: string; body: string } }).data;
res.setHeader('Content-Type', f.contentType);
res.setHeader('Content-Disposition', `attachment; filename="${f.filename.replace(/"/g, '')}"`);
res.status(def.status ?? 200).send(f.body);
return;
}
res.status(def.status ?? 200).json(checked.data);
} catch (err) {
next(err);
@@ -105,3 +119,6 @@ export function mount(router: Router, list: AnyRoute[] = routes): void {
router[def.method](def.path, ...ACCESS[def.access], ...(def.middleware ?? []), run);
}
}
/** The `response` of a `file` route: what the handler hands back to be sent as a download. */
export const fileResponse = z.object({ data: z.object({ contentType: z.string(), filename: z.string(), body: z.string() }) });
+191 -4
View File
@@ -25,7 +25,7 @@ jest.mock('../src/middleware/authMiddleware', () => {
optionalAuth: (req: { user?: unknown }, _res: unknown, next: () => void) => { if (signedIn) req.user = signedIn; next(); },
admin: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (req.user?.role === 'admin' ? next() : deny(403, 'Not authorized as an admin')(req, res, next as (e: Error) => void)),
staff: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (['admin', 'supervisor', 'staff'].includes(req.user?.role || '') ? next() : deny(403, 'Not authorized as staff')(req, res, next as (e: Error) => void)),
supervisor: jest.fn(),
supervisor: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (['admin', 'supervisor'].includes(req.user?.role || '') ? next() : deny(403, 'Not authorized as a supervisor')(req, res, next as (e: Error) => void)),
loginLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
};
});
@@ -41,12 +41,39 @@ jest.mock('../src/services/auth', () => ({
}));
const mockCheckin = { previewScan: jest.fn(), scanTicket: jest.fn() };
jest.mock('../src/services/checkin', () => ({ previewScan: (...a: unknown[]) => mockCheckin.previewScan(...a), scanTicket: (...a: unknown[]) => mockCheckin.scanTicket(...a) }));
const mockScope = { scope: null as object | null, campusId: 'north' as string | null };
const mockScope = { scope: null as object | null, campusId: 'north' as string | null, blockEvent: false };
jest.mock('../src/utils/campusScope', () => ({
scopeFor: async () => mockScope.scope,
eventOf: async () => ({ campusId: mockScope.campusId, campusName: 'North' }),
canView: (scope: { campusIds: string[] }, campusId: string | null) => !campusId || scope.campusIds.includes(campusId),
// Route-level checks: refuse like v1 does (v1 shape — the v2 wrapper isn't in this test app).
scopeEvent: () => (_req: unknown, res: { status(n: number): { json(b: unknown): void } }, next: () => void) => (mockScope.blockEvent ? res.status(404).json({ message: 'Not found' }) : next()),
scopeBody: () => (_req: unknown, _res: unknown, next: () => void) => next(),
listWhere: async () => ({}),
}));
const mockEvents = { listManagedEvents: jest.fn(), createEvent: jest.fn(), updateEvent: jest.fn(), deactivateEvent: jest.fn(), getNotifyRecipients: jest.fn(), setNotifyRecipients: jest.fn(), copySetupFrom: jest.fn() };
jest.mock('../src/services/events', () => Object.fromEntries(Object.keys(mockEvents).map(k => [k, (...a: unknown[]) => (mockEvents as Record<string, jest.Mock>)[k](...a)])));
const mockDetails = {
getEventDetail: jest.fn(), eventCalendar: jest.fn(), eventByAlias: jest.fn(), createOption: jest.fn(), updateOption: jest.fn(), deleteOption: jest.fn(),
createVariant: jest.fn(), updateVariant: jest.fn(), deleteVariant: jest.fn(), listAttachments: jest.fn(), recordAttachment: jest.fn(), deleteAttachment: jest.fn(),
attachmentsStatus: jest.fn(), syncAttachmentManifests: jest.fn(),
};
jest.mock('../src/services/eventDetails', () => Object.fromEntries(Object.keys(mockDetails).map(k => [k, (...a: unknown[]) => (mockDetails as Record<string, jest.Mock>)[k](...a)])));
// The upload step: pretend multer saved the file (or refused it).
const mockUpload = { file: null as object | null, error: null as Error | null };
jest.mock('../src/controllers/eventController', () => ({
receiveAttachment: (req: { file?: object; multerError?: Error }, _res: unknown, next: () => void) => { if (mockUpload.error) req.multerError = mockUpload.error; else if (mockUpload.file) req.file = mockUpload.file; next(); },
}));
const mockSetup = { listSections: jest.fn(), createSection: jest.fn(), updateSection: jest.fn(), deleteSection: jest.fn(), listCosts: jest.fn(), createCost: jest.fn(), updateCost: jest.fn(), deleteCost: jest.fn() };
jest.mock('../src/services/setup', () => Object.fromEntries(Object.keys(mockSetup).map(k => [k, (...a: unknown[]) => (mockSetup as Record<string, jest.Mock>)[k](...a)])));
// Image uploads: a stand-in for multer that "saves" mockImage.
const mockImage = { file: null as object | null };
jest.mock('../src/controllers/uploadController', () => {
const m = { single: () => (req: { file?: object }, _res: unknown, cb: () => void) => { if (mockImage.file) req.file = mockImage.file; cb(); } };
return { upload: m, uploadLogo: m, uploadFavicon: m };
});
import { v2Router } from '../src/v2';
import { ServiceError } from '../src/services/errors';
@@ -342,8 +369,11 @@ describe('v1 deprecation headers', () => {
const { successorOf } = require('../src/v2/deprecation');
test('v1 endpoints with a v2 replacement point at it; the rest say nothing', () => {
expect(successorOf('GET', '/api/events')).toBe('/api/v2/events');
expect(successorOf('GET', '/api/events/e1')).toBe('/api/v2/events/e1');
expect(successorOf('GET', '/api/events/all')).toBeNull();
expect(successorOf('GET', '/api/events/e1')).toBe('/api/v2/events/e1/full');
expect(successorOf('PUT', '/api/events/options/o1')).toBe('/api/v2/ticket-options/o1');
expect(successorOf('DELETE', '/api/events/e1/attachments/a1')).toBe('/api/v2/events/e1/attachments/a1');
expect(successorOf('GET', '/api/events/all')).toBe('/api/v2/events/manage');
expect(successorOf('PUT', '/api/events/e1')).toBe('/api/v2/events/e1');
expect(successorOf('DELETE', '/api/registrations/r1')).toBe('/api/v2/registrations/r1/cancel');
expect(successorOf('POST', '/api/tickets/scan/QR1')).toBe('/api/v2/checkin/QR1');
expect(successorOf('GET', '/api/reports/summary')).toBeNull();
@@ -367,3 +397,160 @@ describe('shared middleware on v2', () => {
expect((await request(mini).get('/api/v2/fine')).body).toEqual({ data: { message: 'not an error' } });
});
});
describe('events (staff)', () => {
const stored = (over: Record<string, unknown> = {}) => ({
id: 'e1', title: 'Camp', description: null, startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z'),
isActive: true, isHidden: true, requiresRegistration: true, scheduleType: 'single', attendanceMode: 'whole', cashupStatus: 'open',
campusId: null, seriesId: null, picture: '/uploads/events/c.jpg', waitlistEnabled: true,
eventOptions: [{ id: 'o1', name: 'Main Ticket', price: 600, isMainTicket: true, stockLimit: 0, earlyBirdTiers: [] }], sessions: [], ...over,
});
afterEach(() => { mockScope.blockEvent = false; });
test('/events/manage lists hidden events for staff, with the rest of each record passed through', async () => {
signedIn = { id: 's1', role: 'staff' };
mockEvents.listManagedEvents.mockResolvedValue([stored()]);
const res = await request(app).get('/api/v2/events/manage?past=true');
expect(res.status).toBe(200);
expect(res.body.data[0]).toMatchObject({ id: 'e1', isHidden: true, waitlistEnabled: true, startDate: '2026-12-04T16:00:00.000Z' });
expect(res.body.data[0].picture).toMatch(/^http:\/\/.*\/uploads\/events\/c\.jpg$/);
expect(mockEvents.listManagedEvents).toHaveBeenCalledWith({ includePast: true, includeInactive: false, excludeClosed: false, campus: undefined }, expect.objectContaining({ scope: null }));
});
test('members can\'t list or create', async () => {
signedIn = { id: 'u1', role: 'user' };
expect((await request(app).get('/api/v2/events/manage')).body.error.code).toBe('FORBIDDEN');
signedIn = { id: 's1', role: 'staff' };
expect((await request(app).post('/api/v2/events').send({ title: 'X', startDate: '2026-12-01', endDate: '2026-12-02' })).body.error.code).toBe('FORBIDDEN');
});
test('create needs a title and dates; update passes only what was sent', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
const bad = await request(app).post('/api/v2/events').send({ title: '' });
expect(Object.keys(bad.body.error.fields).sort()).toEqual(['endDate', 'startDate', 'title']);
mockEvents.createEvent.mockResolvedValue(stored());
const made = await request(app).post('/api/v2/events').send({ title: 'Camp', startDate: '2026-12-04T16:00', endDate: '2026-12-06T12:00', waitlistEnabled: true });
expect(made.status).toBe(201);
expect(mockEvents.createEvent.mock.calls[0][0]).toMatchObject({ title: 'Camp', waitlistEnabled: true });
mockEvents.updateEvent.mockResolvedValue({ ...stored({ title: 'New' }), sessionChanges: null });
const upd = await request(app).patch('/api/v2/events/e1').send({ title: 'New' });
expect(upd.body.data).toMatchObject({ title: 'New', sessionChanges: null });
expect(mockEvents.updateEvent).toHaveBeenCalledWith('e1', { title: 'New' }, { id: 'v1', role: 'supervisor' }, expect.any(Object));
});
test("a campus-scoped supervisor can't change another campus's event", async () => {
signedIn = { id: 'v1', role: 'supervisor' };
mockScope.blockEvent = true;
expect((await request(app).patch('/api/v2/events/e1').send({ title: 'X' })).status).toBe(404);
expect(mockEvents.updateEvent).not.toHaveBeenCalled();
});
test('deactivating is for admins', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
expect((await request(app).delete('/api/v2/events/e1')).status).toBe(403);
signedIn = { id: 'a1', role: 'admin' };
mockEvents.deactivateEvent.mockResolvedValue({ id: 'e1', isActive: false });
expect((await request(app).delete('/api/v2/events/e1')).body.data).toEqual({ id: 'e1', isActive: false });
});
});
describe('one event in full, options, attachments', () => {
const detail = {
id: 'e1', title: 'Camp', description: null, startDate: '2026-12-04T16:00:00.000Z', endDate: '2026-12-06T12:00:00.000Z', isActive: true, isHidden: false,
requiresRegistration: true, scheduleType: 'single', attendanceMode: 'whole', cashupStatus: 'open', campusId: null, seriesId: null, picture: null,
eventOptions: [], sessions: [], form: null, attachments: [], bundles: [],
};
afterEach(() => { mockUpload.file = null; mockUpload.error = null; });
test('/events/{id}/full asks for the staff view only for staff', async () => {
mockDetails.getEventDetail.mockResolvedValue(detail);
await request(app).get('/api/v2/events/e1/full');
expect(mockDetails.getEventDetail.mock.calls[0][1]).toEqual({ staff: false, scope: null });
signedIn = { id: 's1', role: 'staff' };
const res = await request(app).get('/api/v2/events/e1/full');
expect(res.status).toBe(200);
expect(mockDetails.getEventDetail.mock.calls[1][1]).toEqual({ staff: true, scope: null });
});
test('the calendar file is a download, not JSON', async () => {
mockDetails.eventCalendar.mockResolvedValue({ filename: 'Camp.ics', ics: 'BEGIN:VCALENDAR\nEND:VCALENDAR' });
const res = await request(app).get('/api/v2/events/e1/calendar.ics');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/calendar');
expect(res.headers['content-disposition']).toBe('attachment; filename="Camp.ics"');
expect(res.text).toContain('BEGIN:VCALENDAR');
});
test('ticket options: supervisors add and change them, only admins remove them', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
mockDetails.createOption.mockResolvedValue({ id: 'o2', eventId: 'e1', name: 'Child', price: 300, isMainTicket: false, stockLimit: 0, staffPrice: null, sessionMode: 'all' });
const made = await request(app).post('/api/v2/events/e1/ticket-options').send({ name: 'Child', price: 300 });
expect(made.status).toBe(201);
expect((await request(app).post('/api/v2/events/e1/ticket-options').send({ price: 300 })).body.error.fields).toHaveProperty('name');
expect((await request(app).delete('/api/v2/ticket-options/o2')).status).toBe(403);
signedIn = { id: 'a1', role: 'admin' };
mockDetails.deleteOption.mockRejectedValueOnce(new ServiceError(400, 'Cannot delete event option that has registrations'));
expect((await request(app).delete('/api/v2/ticket-options/o2')).body.error.message).toBe('Cannot delete event option that has registrations');
});
test('uploading an attachment: the file, or why it was refused', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
mockUpload.error = new Error('Unsupported file type');
const refused = await request(app).post('/api/v2/events/e1/attachments');
expect(refused.body.error).toMatchObject({ code: 'VALIDATION_FAILED', fields: { file: 'Unsupported file type' } });
mockUpload.error = null;
mockUpload.file = { originalname: 'packing.pdf', filename: 'event-file-1.pdf', mimetype: 'application/pdf', size: 1200 };
mockDetails.recordAttachment.mockResolvedValue({ id: 'a1', eventId: 'e1', originalName: 'packing.pdf', filename: 'event-file-1.pdf', mimeType: 'application/pdf', size: 1200, url: 'http://x/uploads/event-files/event-file-1.pdf', createdAt: new Date('2026-10-29T10:00:00Z') });
const ok = await request(app).post('/api/v2/events/e1/attachments');
expect(ok.status).toBe(201);
expect(ok.body.data).toMatchObject({ originalName: 'packing.pdf', createdAt: '2026-10-29T10:00:00.000Z' });
});
test('the upload is described as multipart and the calendar as a file', () => {
const doc = buildOpenApi({ version: 'test' }) as { paths: Record<string, Record<string, { requestBody?: { content: Record<string, unknown> }; responses: Record<string, { content: Record<string, unknown> }> }>> };
expect(Object.keys(doc.paths['/events/{id}/attachments'].post.requestBody!.content)).toEqual(['multipart/form-data']);
expect(Object.keys(doc.paths['/events/{id}/calendar.ics'].get.responses['200'].content)).toEqual(['text/calendar']);
});
});
describe('sections, costs, image uploads', () => {
afterEach(() => { mockImage.file = null; });
test('costs: supervisors read them, admins add them; per-item needs a ticket type', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
mockSetup.listCosts.mockResolvedValue([{ id: 'c1', eventId: 'e1', label: 'Hall', costType: 'once_off', amount: 1500, eventOptionId: null, eventOption: null, paidFromMethod: null, notes: null, createdAt: new Date('2026-10-01T10:00:00Z') }]);
expect((await request(app).get('/api/v2/events/e1/costs')).body.data[0]).toMatchObject({ label: 'Hall', createdAt: '2026-10-01T10:00:00.000Z' });
expect((await request(app).post('/api/v2/events/e1/costs').send({ label: 'Hall', costType: 'once_off', amount: 1500 })).status).toBe(403);
signedIn = { id: 'a1', role: 'admin' };
expect((await request(app).post('/api/v2/events/e1/costs').send({ label: 'Hall', costType: 'monthly', amount: 1 })).body.error.fields).toHaveProperty('costType');
mockSetup.createCost.mockRejectedValueOnce(new ServiceError(400, 'eventOptionId is required for per-item costs'));
expect((await request(app).post('/api/v2/events/e1/costs').send({ label: 'Shirt', costType: 'per_item', amount: 80 })).body.error.message).toBe('eventOptionId is required for per-item costs');
});
test('sections need staff to list and a supervisor to add', async () => {
signedIn = { id: 'u1', role: 'user' };
expect((await request(app).get('/api/v2/sections')).status).toBe(403);
signedIn = { id: 's1', role: 'staff' };
mockSetup.listSections.mockResolvedValue([]);
expect((await request(app).get('/api/v2/sections?eventId=e1')).body).toEqual({ data: [] });
expect(mockSetup.listSections).toHaveBeenCalledWith('e1', expect.anything());
});
test('an event picture comes back as a path to save and a full address', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
expect((await request(app).post('/api/v2/uploads/event-image')).body.error.fields).toEqual({ image: 'Choose an image' });
mockImage.file = { filename: 'event-123.jpg' };
const res = await request(app).post('/api/v2/uploads/event-image');
expect(res.body.data.path).toBe('/uploads/events/event-123.jpg');
expect(res.body.data.url).toMatch(/^http:\/\/.*\/uploads\/events\/event-123\.jpg$/);
});
test('branding images are for admins once accounts exist', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
mockImage.file = { filename: 'logo.png' };
expect((await request(app).post('/api/v2/uploads/logo')).status).toBe(403);
signedIn = { id: 'a1', role: 'admin' };
expect((await request(app).post('/api/v2/uploads/logo')).body.data.path).toBe('/uploads/branding/logo.png');
});
});
+48 -12
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-29, after **1.51.5** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-10-29, after **1.51.8** (and Hub 1.5.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -17,6 +17,9 @@ encrypted settings unreadable or break existing installs.
| 1.51.3 | **Express 5** (qs query parser and `req.body = {}` kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on `/api/v2` (`legacyErrorsToV2`, `reason`); v1 `Deprecation` + `successor-version` headers (`src/v2/deprecation.ts`) |
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
| 1.51.8 | **Rest of event set-up on v2**: event in full, calendar, alias, ticket options and variants, attachments, sections, costs, image uploads — `src/services/eventDetails.ts`, `src/services/setup.ts`; frontend `fetchEvent()` and `lib/eventSetup.ts`; v2 `file` and `upload` routes |
| 1.51.7 | **Setting events up on v2** (`/events/manage`, create, `PATCH`, deactivate, copy-from, notify-recipients) via `src/services/events.ts`; `fetchManagedEvents()` for every picker; **pg-boss 11** in schema `pgboss_v11` |
| 1.51.6 | Command palette lists every report (from `ReportCatalog`) and every dashboard page (e2e drift check); exceljs → ExcelTS 10 (`@cj-tech-master/excelts`, no dependencies; e2e `e2e/excel.spec.ts`); backend `engines.node` >= 22 |
| 1.51.0 | **TypeScript backend + `/api/v2` foundations** (§14): tsconfig (`allowJs`), build to `dist/`, launcher `src/index.js` → `src/app.js`; v2 route declarations with zod → validation, response stripping and OpenAPI 3.1 at `/docs/v2`; first endpoints events, event, me, my registrations. Tests `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts` |
| 1.50.2 | Searchable **event picker** (`components/shared/EventPicker.tsx`) on staff/supervisor pages and the kiosk; searchable reports events list; **How webhooks work** guide on Site settings → Webhooks (examples from `EVENT_TYPES[].example`). e2e `e2e/event-picker.spec.ts` |
| 1.50.1 | Long lists: audit-log search/role filter/all actions and inclusive To date, data-request search and paging, webhook-delivery status filter and Load older, supervisor Payments loads only what it needs (`unpaid`, `userId`, `from`, `donations` filters). `components/shared/Pager.tsx`; e2e `e2e/long-lists.spec.ts` |
@@ -65,13 +68,14 @@ encrypted settings unreadable or break existing installs.
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. **1.51.6 – 1.51.11: every endpoint onto v2**, one area
per release (roadmap build order, row 11b): services in TS, screens on `apiV2`, v1 routes into
`SUCCESSORS`. Then **2.0.0** — AI helpers and v1 removed. Inbound provider webhooks and links
already sent in emails stay as permanent aliases.
2. **Waiting on Joshua:** upgrade servers to Node 24 (DEPLOYMENT.md, "Upgrading Node"); approve
replacing exceljs (unmaintained, five deprecated sub-packages) with ExcelTS
(`@cj-tech-master/excelts`, same API, no dependencies — 5.x on Node 20, 10.x on Node 22+).
1. **1.51.9 – 1.51.15: every endpoint onto v2**, one area per release (roadmap build order, row
11b): services in TS, screens on `apiV2`, v1 routes into `SUCCESSORS`. 1.51.15 gives incoming
webhooks and links in sent messages v2 URLs, with the v1 paths still answering (links redirect,
webhooks handled by the same code). Then **2.0.0** — AI helpers and v1 removed.
2. **Servers run Node 24** (Joshua, 2026-10-29), so the backend may need Node 22+. Still to do:
move from ExcelTS — deprecated in favour of `documonster` by the same author (pre-1.0 in Oct
2026) — once documonster is stable. After 1.51.7 has run everywhere, the old `pgboss` schema
can be dropped (`DROP SCHEMA pgboss CASCADE;`).
Languages (Afrikaans, §10) are removed from scope (2026-10-28).
2. AI rules decided 2026-10-29 (see §15 in the roadmap): the AI buttons show only when the
licence's monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub
@@ -99,10 +103,10 @@ a test server (both done 2026-10-28).
change — delete those two blocks from the generated SQL. It also rewrites the comment
in `migration_lock.toml`; `git checkout` that file back. New `AdminAuditAction` values
need a migration.
- **Server Node version:** new installs get Node 24 LTS (1.51.4). Older servers may still run
Node 20 (end of life April 2026) and `deploy.sh` warns on them; until every server is on 24,
dependencies must still support Node 20 (that's why pg-boss is pinned to 10.x and ExcelTS would
have to stay on 5.x). Once they're all on 24: pg-boss 11, ExcelTS 10.
- **Server Node version:** Node 24 LTS on every server (2026-10-29); new installs get it too.
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
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
@@ -462,6 +466,38 @@ a test server (both done 2026-10-28).
`fetchAllUsers` (Payments, Email/WhatsApp attendees, Forms, Manual) — a server-side search
picker would replace them; the coupons list; `GET /api/registrations` without `page` (other callers still use the plain array).
## The rest of event set-up on v2 (1.51.8)
- **One event:** `fetchEvent(id, { authToken, nextOptions })` (`lib/api.ts`, v2 `GET /events/{id}/full`) —
never `/api/events/:id`. Server pages pass `nextOptions` for revalidation.
- **Set-up calls:** `lib/eventSetup.ts` (ticket options, sections, attachments, costs, `uploadImage()`,
which returns the path to save).
- **Downloads and uploads on v2:** `file: { contentType }` + `response: fileResponse` makes a route
send a file; `upload: { field, description }` documents a multipart body (the multer step goes in
`middleware`; check `req.multerError`/`req.file` in the handler).
- `eventController.js` still holds the public list (`getEvents`), the admin list and the attendee
email/WhatsApp senders (messaging, 1.51.13).
## Setting events up on v2 (1.51.7)
- **Staff event lists:** use `fetchManagedEvents(token, { past, inactive, closed, campus })`
(`lib/api.ts`, v2 `GET /events/manage`), never `/api/events/all`.
- **Loose records:** staff endpoints that return whole stored records (events now; more to come)
type the core fields and pass the rest through (`z.looseObject`), serialised with
`JSON.parse(JSON.stringify(...))` so dates are ISO strings. Public and member endpoints stay strict.
- **Campus scope on v2:** reuse the v1 middleware with `middleware: [scopeEvent({ need: 'manage' })]`
— its `{ message }` refusals become v2 errors through `legacyErrorsToV2`.
- **Route order:** fixed paths (`/events/manage`) must be defined before `/events/:id` —
`eventsManage.ts` is imported before `events.ts` in `src/v2/index.ts`.
## Command palette and Excel (1.51.6)
- **Palette:** reports come from `components/reports/ReportCatalog.ts` — add a report there and it
appears. A new dashboard page needs a line in `lib/commandPages.ts`, or an entry in
`NOT_IN_PALETTE` in `e2e/dashboard.spec.ts` (which fails on any page that's neither).
- **Excel:** `require('@cj-tech-master/excelts')` — the exceljs API (`new ExcelJS.Workbook()`,
`xlsx.write(res)`). Reports need `kind: 'table'` (or `'layered'`) for the main table.
## Next.js 16 (1.51.5)
- Turbopack for dev and build (the default). Lint with `npm run lint` / `npx eslint src` (`next lint`
+6 -5
View File
@@ -40,8 +40,9 @@ shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is
(Afrikaans) are removed from scope (2026-10-28), so everything after moves up by one. 1.50.0 **accessibility
pass** shipped. 1.50.1 **pagination and search on long lists** and 1.50.2 **event picker and webhook guide** shipped. 1.51.0 **TypeScript backend and `/api/v2`
foundations**, 1.51.1 **v2 registrations and payments**, 1.51.2 **v2 accounts and check-in** and 1.51.3 **Express 5,
scanner on v2, v1 deprecation headers** shipped. 1.51.4 **security and dependency updates** and 1.51.5 **Next.js 16** shipped. **Next: 1.51.6 – 1.51.11
(every endpoint onto v2), then 2.0.0.**
scanner on v2, v1 deprecation headers** shipped. 1.51.4 **security and dependency updates**, 1.51.5 **Next.js 16** and 1.51.6 **the complete command palette and
ExcelTS** , 1.51.7 **setting events up on v2, pg-boss 11** and 1.51.8 **the rest of event set-up on v2** shipped.
**Next: 1.51.9 – 1.51.15 (every endpoint onto v2), then 2.0.0.**
**Repositories:**
@@ -1010,8 +1011,8 @@ income justify the hardware:
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (Afrikaans removed from scope) | Polish before the big release |
| 11 | 1.51.0 ✅, 1.51.1 ✅, 1.51.2 ✅, 1.51.3 ✅ | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 registrations and payments as TS services, typed frontend calls; 1.51.2 check-in and accounts; 1.51.3 Express 5, the scanner on v2, v1 deprecation headers, v2-shaped shared middleware | The breaking-change groundwork |
| 11b | 1.51.4 ✅, 1.51.5 ✅, 1.51.6 – 1.51.11 | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 events and event set-up (events, sections, costs, uploads, series, season passes, bundles, coupons, campuses); 1.51.7 registrations and money, staff side (manage registrations, payments, cashup, provider transactions, waitlist, sponsorship, consent, forms, feedback); 1.51.8 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.9 kids check-in and volunteer rosters; 1.51.10 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.11 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups). Each moves its area's logic into TS services, its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 removed (all of it is on v2 by then; inbound provider webhooks and links already sent in emails stay as permanent aliases), migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
| 11b | 1.51.4 ✅ – 1.51.8 ✅, 1.51.9 – 1.51.15 | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 registrations and money, staff side (manage registrations, payments, cashup, provider transactions, waitlist, sponsorship, consent, forms, feedback); 1.51.11 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.12 kids check-in and volunteer rosters; 1.51.13 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.14 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.15 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each moves its area's logic into TS services, its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 removed (all of it is on v2 by then; the old webhook URLs and links already sent in messages keep forwarding to v2), migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
Each phase should add matching help content (`frontend/src/content/help/`),
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
@@ -1048,7 +1049,7 @@ feature.
| — | AI in 2.0 | The **headline feature of 2.0**: one-button helpers only (draft an event, summarise reports and feedback, draft messages, improve text) — no chat yet (2026-10-24) |
| — | AI provider and key | One provider, **Claude** (Haiku 5.5 to start, for cost). Joshua provides the key; installs call AI **through the Hub**, which holds the key and meters usage (2026-10-24) |
| — | AI cost limits | A **monthly rand cap per install**, set in the Hub per licence to match what the church pays; admins see their usage in Site settings → AI (2026-10-24) |
| — | v1 in 2.0 | **Every endpoint moves to v2 before 2.0** (1.51.6 – 1.51.11, by area), then 2.0 removes v1. Changed the same day from "retire only what's replaced". Kept for good: inbound provider webhooks (Yoco, PayFast, WAWP post to URLs registered with them) and links already sent in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds) — as permanent aliases, outside the versioned API (2026-10-29) |
| — | v1 in 2.0 | **Every endpoint moves to v2 before 2.0** (1.51.7 – 1.51.15, by area), then 2.0 removes v1. Changed the same day from "retire only what's replaced". Incoming webhooks (Yoco, PayFast, WAWP) and links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds) also get v2 URLs (1.51.15); their v1 paths keep working — links redirect (308) to v2, provider webhooks are handled by the same code — because old messages and provider registrations still use them (2026-10-29) |
| — | AI buttons and markup | AI buttons show only when the licence's monthly cap is **above R0**. When the cap is reached they stay visible but **disabled** until next month or until the cap is raised. Usage is charged at the real cost **plus a markup %** set in the Hub (a default, plus an override per licence). The markup covers small overruns and is the profit. The cap is counted in charged rand, and each call is checked against its worst-case cost before it's sent (2026-10-29) |
| — | AI later | Ask AI (read-only, tools only), help/navigation assistant, actions with confirmation, more helpers, and long-term **self-hosted** AI behind the same Hub endpoint — not tied to versions (2026-10-24) |
| — | POPIA: message kinds | Bulk messages are either **Event updates** (to registrants — still sent after unsubscribing) or **News & announcements** (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
+59 -2
View File
@@ -88,7 +88,64 @@ test.describe("API v2", () => {
const v1 = await request.get(`${API}/api/events`);
expect(v1.headers()["deprecation"]).toMatch(/^@\d+$/);
expect(v1.headers()["link"]).toBe(`</api/v2/events>; rel="successor-version"`);
expect((await request.get(`${API}/api/events/all`, { headers: { Authorization: `Bearer ${seed().users.staff.token}` } })).headers()["deprecation"]).toBeUndefined();
const all = await request.get(`${API}/api/events/all`, { headers: { Authorization: `Bearer ${seed().users.staff.token}` } });
expect(all.headers()["link"]).toBe(`</api/v2/events/manage>; rel="successor-version"`);
// Endpoints with no v2 version yet say nothing.
expect((await request.get(`${API}/api/settings/needs-setup`)).headers()["deprecation"]).toBeUndefined();
});
test("staff list every event; supervisors create, change and admins deactivate", async ({ request }) => {
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
const list = await (await request.get(`${API}/api/v2/events/manage`, { headers: staff })).json();
// The kids event is hidden from the public list but staff see it.
expect(list.data.map((e: { id: string }) => e.id)).toContain(seed().events.kids);
expect((await (await request.get(`${API}/api/v2/events?limit=100`)).json()).data.map((e: { id: string }) => e.id)).not.toContain(seed().events.kids);
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const start = new Date(Date.now() + 20 * 864e5);
const made = await request.post(`${API}/api/v2/events`, { headers: sup, data: { title: "E2E v2 Event", description: "Made through v2", price: 0, location: "1 Test Street", startDate: start.toISOString(), endDate: new Date(start.getTime() + 3600e3).toISOString() } });
expect(made.status()).toBe(201);
const ev = (await made.json()).data;
expect(ev.eventOptions.map((o: { name: string }) => o.name)).toEqual(["Main Ticket"]);
const changed = await (await request.patch(`${API}/api/v2/events/${ev.id}`, { headers: sup, data: { title: "E2E v2 Event (renamed)", waitlistEnabled: true } })).json();
expect(changed.data).toMatchObject({ title: "E2E v2 Event (renamed)", waitlistEnabled: true, sessionChanges: null });
expect((await request.delete(`${API}/api/v2/events/${ev.id}`, { headers: sup })).status()).toBe(403);
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
expect((await (await request.delete(`${API}/api/v2/events/${ev.id}`, { headers: admin })).json()).data).toEqual({ id: ev.id, isActive: false });
});
test("an event in full, its calendar file, ticket options, sections and costs", async ({ request }) => {
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
const full = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`)).json()).data;
expect(full.eventOptions.length).toBeGreaterThan(0);
expect(full).toHaveProperty("attachments");
// The public don't see who gets the emails; staff do.
expect(full.notifyRecipients).toBeUndefined();
expect((await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`, { headers: sup })).json()).data).toHaveProperty("notifyRecipients");
const ics = await request.get(`${API}/api/v2/events/${seed().events.conference}/calendar.ics`);
expect(ics.headers()["content-type"]).toContain("text/calendar");
expect(await ics.text()).toContain("BEGIN:VCALENDAR");
const opt = (await (await request.post(`${API}/api/v2/events/${seed().events.conference}/ticket-options`, { headers: sup, data: { name: "E2E v2 Extra", price: 25 } })).json()).data;
expect(opt).toMatchObject({ name: "E2E v2 Extra", price: 25, isMainTicket: false });
const withVariant = (await (await request.patch(`${API}/api/v2/ticket-options/${opt.id}`, { headers: sup, data: { variants: [{ name: "Large", price: 30 }] } })).json()).data;
expect(withVariant.variants.map((v: { name: string }) => v.name)).toEqual(["Large"]);
expect((await request.delete(`${API}/api/v2/ticket-options/${opt.id}`, { headers: sup })).status()).toBe(403);
expect((await (await request.delete(`${API}/api/v2/ticket-options/${opt.id}`, { headers: admin })).json()).data).toEqual({ id: opt.id, deleted: true });
const section = (await (await request.post(`${API}/api/v2/sections`, { headers: sup, data: { eventId: seed().events.conference, name: "E2E Side door", allowedOptionIds: [full.eventOptions[0].id] } })).json()).data;
expect(section.allowedOptions).toHaveLength(1);
const listed = (await (await request.get(`${API}/api/v2/sections?eventId=${seed().events.conference}`, { headers: sup })).json()).data;
expect(listed.map((x: { id: string }) => x.id)).toContain(section.id);
const cost = await request.post(`${API}/api/v2/events/${seed().events.conference}/costs`, { headers: admin, data: { label: "E2E Hall", costType: "once_off", amount: 500 } });
expect(cost.status()).toBe(201);
const costs = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/costs`, { headers: sup })).json()).data;
expect(costs.map((c: { label: string }) => c.label)).toContain("E2E Hall");
});
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
@@ -97,7 +154,7 @@ test.describe("API v2", () => {
await page.goto(`${API}/docs/v2?token=${seed().users.admin.token}`);
await expect(page.getByRole("heading", { name: /Cross Events API/ })).toBeVisible();
await expect(page.locator(".opblock-summary-path", { hasText: "/events/{id}" })).toBeVisible();
await expect(page.getByRole("button", { name: /^GET \/events\/\{id\} One public/ })).toBeVisible();
// The token isn't left in the address bar.
expect(page.url()).not.toContain("token=");
});
+39
View File
@@ -1,5 +1,18 @@
import fs from "fs";
import path from "path";
import { test, expect, loginAs } from "./fixtures";
// Dashboard pages the palette leaves out on purpose: role home pages it reaches through
// "Dashboard", pages that only redirect or reuse another, and an older form nothing links to.
const NOT_IN_PALETTE = new Set([
"/dashboard/admin", "/dashboard/staff", "/dashboard/supervisor", // role homes ("Dashboard")
"/dashboard/admin/settings", // its tabs are listed one by one
"/dashboard/admin/whatsapp", "/dashboard/supervisor/sections", "/dashboard/admin/forms", // redirects / the same page
"/dashboard/admin/cashup/[id]", // one event's cashup, from Cashup
"/dashboard/supervisor/manual-registration", // the older manual form
"/dashboard/user/reset-password", // from My profile & security
]);
// Dashboard "Today" and "Waiting for you", and the Ctrl+K command palette (1.49.0).
test.describe("dashboard and command palette", () => {
test("the admin dashboard shows what's on today and what's waiting", async ({ page }) => {
@@ -48,4 +61,30 @@ test.describe("dashboard and command palette", () => {
await staffDialog.getByRole("combobox", { name: "Search" }).fill("scan");
await expect(staffDialog.getByRole("option", { name: "Scan tickets" })).toBeVisible();
});
test("the palette lists every report and every dashboard page", async ({ page }) => {
const root = path.join(__dirname, "..", "frontend", "src");
const palette = fs.readFileSync(path.join(root, "lib", "commandPages.ts"), "utf8");
const pages: string[] = [];
const walk = (dir: string) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory()) walk(path.join(dir, e.name));
else if (e.name === "page.tsx") pages.push("/" + path.relative(path.join(root, "app"), dir).split(path.sep).join("/"));
}
};
walk(path.join(root, "app", "dashboard"));
const missing = pages.filter(p => !NOT_IN_PALETTE.has(p) && !palette.includes(`href: "${p}"`) && !palette.includes(`href: "${p}?`));
expect(missing).toEqual([]);
// Reports come from the catalogue: one the old hand-made list didn't have.
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor");
await expect(page.getByRole("button", { name: /Search or jump to/ })).toBeVisible();
await page.keyboard.press("Control+k");
const dialog = page.getByRole("dialog", { name: "Go to or search" });
await dialog.getByRole("combobox", { name: "Search" }).fill("medical");
await expect(dialog.getByRole("option", { name: /Report: Medical & emergency list/ })).toBeVisible();
await dialog.getByRole("combobox", { name: "Search" }).fill("profit");
await expect(dialog.getByRole("option", { name: /Report: Profit report/ })).toBeVisible();
});
});
+48
View File
@@ -0,0 +1,48 @@
import path from "path";
import { test, expect, seed, API } from "./fixtures";
// Excel exports (ExcelTS since 1.51.6): a styled report and the attendance register come back as
// real workbooks that open and hold what was asked for.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Workbook } = require(path.join(__dirname, "..", "backend", "node_modules", "@cj-tech-master", "excelts"));
const XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
async function open(body: Buffer) {
const wb = new Workbook();
await wb.xlsx.load(body);
return wb;
}
test.describe("Excel exports", () => {
test("a report with stats, a chart and a table", async ({ request }) => {
const res = await request.post(`${API}/api/reports/excel`, {
headers: { Authorization: `Bearer ${seed().users.supervisor.token}` },
data: {
title: "E2E Payments",
kind: "table",
subtitle: "1–31 October",
stats: [{ label: "Total", value: "R 1 200.00" }],
chart: { title: "By method", data: [{ label: "Card", value: 900 }, { label: "Cash", value: 300 }] },
table: { columns: ["Name", "Amount"], rows: [["Thandi", "R 600.00"], ["Sipho", "R 600.00"]] },
},
});
expect(res.status()).toBe(200);
expect(res.headers()["content-type"]).toContain(XLSX);
const ws = (await open(await res.body())).worksheets[0];
const values: string[] = [];
ws.eachRow((row: { values: unknown[] }) => values.push(...row.values.filter(Boolean).map(String)));
expect(values).toEqual(expect.arrayContaining(["E2E Payments", "Total", "Card", "Thandi", "Sipho"]));
});
test("the attendance register", async ({ request }) => {
const res = await request.get(`${API}/api/attendance/${seed().events.course}/export?format=xlsx`, {
headers: { Authorization: `Bearer ${seed().users.supervisor.token}` },
});
expect(res.status()).toBe(200);
expect(res.headers()["content-type"]).toContain(XLSX);
const ws = (await open(await res.body())).getWorksheet("Attendance");
expect(ws.getRow(1).getCell(1).value).toBe("Name");
expect(ws.rowCount).toBeGreaterThan(1);
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.51.5",
"version": "1.51.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.51.5",
"version": "1.51.8",
"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.51.5",
"version": "1.51.8",
"private": true,
"scripts": {
"dev": "next dev",
+2 -2
View File
@@ -1,6 +1,5 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { apiFetch } from "@/lib/api";
import type { Event } from "@/types";
export const revalidate = 60;
@@ -20,7 +19,8 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
if (VALID_ALIAS.test(redirectUrl)) {
try {
event = await apiFetch<Event & { message?: string }>(`/api/events/by-alias/${redirectUrl}`);
const { apiV2 } = await import("@/lib/apiV2");
event = (await apiV2("get", "/events/by-alias/{alias}", { params: { alias: redirectUrl } })).data as unknown as Event;
} catch (error) {
console.error("Failed to fetch event:", error);
}
@@ -3,12 +3,13 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchEvent } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import type { EventCost, EventCostType, EventFinancials, CashupMethod, CashupWriteOffCategory, ReconciledByMethod, Event } from "@/types";
import { Wallet, DollarSign, ClipboardCheck, FileBarChart } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { createCost, updateCost, deleteCost } from "@/lib/eventSetup";
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"];
@@ -42,7 +43,7 @@ export default function EventCashupPage() {
try {
const [financials, event] = await Promise.all([
apiFetch<EventFinancials>(`/api/cashups/event/${eventId}`, { authToken: token }),
apiFetch<Event>(`/api/events/${eventId}`, { authToken: token })
fetchEvent<Event>(eventId, { authToken: token })
]);
setData(financials);
setEventOptions((event.eventOptions || []).map((o) => ({ id: o.id, name: o.name })));
@@ -142,11 +143,11 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
setSaving(true);
setErr(null);
try {
const body = { label: label.trim(), costType, amount: parseFloat(amount), eventOptionId: costType === "per_item" ? eventOptionId : null, paidFromMethod: paidFromMethod || null, notes: notes || null };
const body = { label: label.trim(), costType, amount: parseFloat(amount), eventOptionId: costType === "per_item" ? eventOptionId : null, paidFromMethod: (paidFromMethod || null) as "cash" | "card" | "eft" | "other" | null, notes: notes || null };
if (editingId === "new") {
await apiFetch(`/api/events/${eventId}/costs`, { method: "POST", authToken: token, body });
await createCost(token, eventId, body);
} else {
await apiFetch(`/api/costs/${editingId}`, { method: "PUT", authToken: token, body });
await updateCost(token, editingId!, body);
}
setEditingId(null);
onChanged();
@@ -160,7 +161,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
const remove = async (id: string) => {
if (!confirm("Delete this cost?")) return;
try {
await apiFetch(`/api/costs/${id}`, { method: "DELETE", authToken: token });
await deleteCost(token, id);
onChanged();
} catch (e) {
alert(errorMessage(e) || "Failed to delete cost");
@@ -3,7 +3,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Wallet } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -28,7 +28,7 @@ export default function CashupLandingPage() {
setLoading(true);
setError(null);
try {
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
const evs = await fetchManagedEvents<Event>(token, { past: true, inactive: true });
setEvents(Array.isArray(evs) ? evs : []);
} catch (e) {
setError(errorMessage(e) || "Failed to load events");
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ClipboardList } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -97,11 +97,7 @@ export default function AdminRegistrationsPage() {
const loadEvents = useCallback(async () => {
if (!token) return;
try {
const params = new URLSearchParams();
if (includePastEvents) params.set("includePast", "true");
if (includeInactiveEvents) params.set("includeInactive", "true");
const qs = params.toString() ? `?${params.toString()}` : "";
const evs = await apiFetch<Event[]>(`/api/events/all${qs}`, { authToken: token });
const evs = await fetchManagedEvents(token, { past: includePastEvents, inactive: includeInactiveEvents });
setEvents(Array.isArray(evs) ? evs.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
} catch {}
}, [token, includePastEvents, includeInactiveEvents]);
@@ -4,7 +4,7 @@ import React, { Suspense, useCallback, useEffect, useRef, useState } from "react
import { useAuth } from "@/hooks/useAuth";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, MapPinned, type LucideIcon, Webhook } from "lucide-react";
@@ -20,6 +20,7 @@ import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { uploadImage } from "@/lib/eventSetup";
type TabId = "organisation" | "campuses" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "webhooks" | "backups" | "jobs" | "licence";
@@ -232,16 +233,11 @@ function SiteSettingsPageInner() {
});
const uploadBrandingAsset = async (file: File, kind: "logo" | "favicon"): Promise<string> => {
const fd = new FormData();
fd.append("image", file);
const res = await fetch(`${API_BASE}/api/uploads/${kind}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (!res.ok) throw new Error(`${kind === "logo" ? "Logo" : "Favicon"} upload failed`);
const data = await res.json();
return data.url || "";
try {
return await uploadImage(token, kind, file);
} catch {
throw new Error(`${kind === "logo" ? "Logo" : "Favicon"} upload failed`);
}
};
const saveBranding = async () => {
@@ -2,7 +2,7 @@
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter, useSearchParams } from "next/navigation";
import { formatDate } from "@/lib/date";
@@ -41,7 +41,7 @@ function EventTicketsContent() {
(async () => {
try {
if (!token) return;
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token });
const evs = await fetchManagedEvents<Event>(token, { past: true });
const now = Date.now();
const active = (evs || []).filter((ev) => {
const t = new Date(ev.endDate).getTime();
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { QrCode } from "lucide-react";
import { useOfflineScanning } from "@/components/scanner/useOfflineScanning";
@@ -15,6 +15,7 @@ import { EventPicker } from "@/components/shared/EventPicker";
import { activeSessions, currentSessionLabel, formatSessionWhen, sessionLabel } from "@/lib/sessions";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2 } from "@/lib/apiV2";
import { listSections } from "@/lib/eventSetup";
// What the confirm step needs, from GET /api/v2/checkin/{qrCode}. Offline scans build the
// same shape from the downloaded ticket list.
@@ -88,7 +89,7 @@ export default function TicketScanningPage() {
try {
if (!token) return;
setLoadingEvents(true);
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token });
const evs = await fetchManagedEvents<Event>(token, { past: true });
const sorted = (evs || []).sort((a, b) => new Date(a.endDate).getTime() - new Date(b.endDate).getTime());
setAllEvents(sorted);
} catch {
@@ -120,7 +121,7 @@ export default function TicketScanningPage() {
}
try {
setLoadingSections(true);
const list = await apiFetch<Section[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
const list = await listSections<Section>(token, selectedEventId);
setSections(Array.isArray(list) ? list : []);
setSelectedSectionId("all");
} catch {
@@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { DndContext, DragEndEvent, PointerSensor, TouchSensor, useDraggable, useDroppable, useSensor, useSensors } from "@dnd-kit/core";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BedDouble, Plus, Wand2, Printer, Trash2, Pencil, Eraser } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -94,7 +94,7 @@ export default function AllocationsPage() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
fetchManagedEvents<Event>(token, { past: true })
.then(evs => {
const upcoming = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 7 * 864e5)
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
@@ -132,7 +132,7 @@ export default function AtTheDoorPage() {
if (!token) return;
(async () => {
try {
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
const evs = await fetchManagedEvents<Event>(token);
const now = Date.now();
const active = (evs || []).filter(ev => {
@@ -162,7 +162,7 @@ export default function AtTheDoorPage() {
(async () => {
try {
const ev = await apiFetch<Event>(`/api/events/${eventId}`, {
const ev = await fetchEvent<Event>(eventId, {
authToken: token
});
@@ -4,7 +4,7 @@ import React, { Suspense, useCallback, useEffect, useMemo, useState } from "reac
import { useRouter, useSearchParams } from "next/navigation";
import { ClipboardCheck, Check, Download, FileSpreadsheet, FileText, Send, Award, Search, Ban } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { apiFetch, API_BASE, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { errorMessage } from "@/lib/utils";
import { fetchApiFile, saveBlob } from "@/lib/export";
@@ -41,7 +41,7 @@ function AttendancePageInner() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
fetchManagedEvents<Event>(token, { past: true })
.then(evs => {
// Multi-day events first (that's what the register is mostly for), newest first.
const list = (evs || []).filter(e => e.requiresRegistration !== false)
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Gift, Plus, X, Trash2, Pencil, Power, CalendarClock } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -101,7 +101,7 @@ export default function BundlesPage() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<EventLite[]>("/api/events/all?includePast=true", { authToken: token })
fetchManagedEvents<EventLite>(token, { past: true })
.then(list => setAllEvents(list || []))
.catch(e => setError(errorMessage(e) || "Failed to load events"));
}, [token, canView, setError]);
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ShieldCheck, Send, RefreshCw, HeartPulse, Printer } from "lucide-react";
@@ -46,7 +46,7 @@ export default function ConsentTrackerPage() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
fetchManagedEvents<Event>(token, { past: true })
.then(evs => {
const youth = (evs || []).filter(e => e.requiresParentalConsent);
setEvents(youth);
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { TicketPercent, Plus, X, Search, Pencil, Power, Users, CalendarClock, ListChecks } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -164,7 +164,7 @@ export default function CouponsPage() {
try {
const [c, ev] = await Promise.all([
apiFetch<Coupon[]>("/api/coupons", { authToken: token }),
apiFetch<EventLite[]>("/api/events/all?includePast=true", { authToken: token }),
fetchManagedEvents<EventLite>(token, { past: true }),
]);
setCoupons(c || []);
setEvents(ev || []);
@@ -3,7 +3,7 @@
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { apiFetch, fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Mail, Users, Zap, Clock } from "lucide-react";
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
@@ -197,7 +197,7 @@ function EmailAttendeesPageInner() {
const loadEvents = useCallback(async () => {
try {
setLoadingEvents(true);
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token || undefined });
const evs = await fetchManagedEvents<Event>(token || undefined, { past: true });
const sorted = (evs || []).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
setAllEvents(sorted);
} catch (e) {
@@ -3,13 +3,14 @@
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Ticket } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, EventOption } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { createTicketOption, updateTicketOption, deleteTicketOption } from "@/lib/eventSetup";
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
@@ -126,7 +127,7 @@ function EventOptionsContent() {
const loadEvents = useCallback(async () => {
if (!token) return;
try {
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
const evs = await fetchManagedEvents<Event>(token);
setEvents(evs || []);
} catch {
// ignore
@@ -160,11 +161,7 @@ function EventOptionsContent() {
if (!newOpt.name) { setError("Option name is required"); return; }
const priceNum = parseFloat(newOpt.price || "0");
try {
await apiFetch(`/api/events/${encodeURIComponent(selectedEventId)}/options`, {
method: "POST",
authToken: token,
body: { name: newOpt.name, price: priceNum || 0, isMainTicket: !!newOpt.isMainTicket }
});
await createTicketOption(token, selectedEventId, { name: newOpt.name, price: priceNum || 0, isMainTicket: !!newOpt.isMainTicket });
setNewOpt({ name: "", price: "", isMainTicket: false });
setInfo("Option created");
await loadEvents();
@@ -177,11 +174,7 @@ function EventOptionsContent() {
if (!token) return;
setError(null); setInfo(null);
try {
await apiFetch(`/api/events/options/${encodeURIComponent(opt.id)}`, {
method: "PUT",
authToken: token,
body: patch
});
await updateTicketOption(token, opt.id, patch);
setInfo("Option updated");
await loadEvents();
} catch (e) {
@@ -193,10 +186,7 @@ function EventOptionsContent() {
if (!token) return;
setError(null); setInfo(null);
try {
await apiFetch(`/api/events/options/${encodeURIComponent(opt.id)}`, {
method: "DELETE",
authToken: token,
});
await deleteTicketOption(token, opt.id);
setInfo("Option deleted");
await loadEvents();
} catch (e) {
@@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { createPortal } from "react-dom";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, API_BASE } from "@/lib/api";
import { apiFetch, API_BASE, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
@@ -22,6 +22,8 @@ import {
} from "@/components/events/ScheduleEditor";
import { toSessionDraft, scheduleSummary } from "@/lib/sessions";
import { fetchApiFile, openBlob } from "@/lib/export";
import { apiV2 } from "@/lib/apiV2";
import { createTicketOption, updateTicketOption, listSections, createSection, updateSection, deleteSection, listAttachments, uploadAttachment, deleteAttachment, uploadImage } from "@/lib/eventSetup";
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -60,7 +62,7 @@ type FormFieldType = "yes_no" | "text" | "date" | "numeric" | "statement" | "par
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string | null; options?: unknown }[] };
type FieldDef = EventFormDef["fields"][number];
// What PUT /api/events/options/:id takes as earlyBirdTiers (variantId null = option-level).
// What PATCH /api/v2/ticket-options/{id} takes as earlyBirdTiers (variantId null = option-level).
type TierPayload = { id?: string; deadline: string; price: number; stockLimit: number; order: number; variantId: string | null };
// GET /api/feedback/event/:id — the event's post-event feedback form, if it has one.
@@ -119,10 +121,7 @@ function UploadImageButton({ onUploaded, label = "Upload image" }: { onUploaded:
if (!file || !token) return;
setUploading(true);
try {
const fd = new FormData();
fd.append("image", file);
const res = await apiFetch<{ url?: string; path?: string }>("/api/uploads/event-image", { method: "POST", body: fd, authToken: token });
const uploaded = res?.url || res?.path;
const uploaded = await uploadImage(token, "event-image", file);
if (uploaded) onUploaded(uploaded);
} finally { setUploading(false); if (ref.current) ref.current.value = ""; }
}} />
@@ -505,8 +504,8 @@ function SectionsManager({ eventId }: { eventId: string }) {
setLoading(true);
try {
const [secList, evFull] = await Promise.all([
apiFetch<Section[]>("/api/sections", { authToken: token || undefined }),
apiFetch<Event>(`/api/events/${eventId}`)
listSections<Section>(token),
fetchEvent<Event>(eventId)
]);
setSections((secList || []).filter(s => s.eventId === eventId));
setEventOptions(evFull?.eventOptions || []);
@@ -516,17 +515,17 @@ function SectionsManager({ eventId }: { eventId: string }) {
const create = async () => {
if (!newName.trim() || !token) return;
await apiFetch("/api/sections", { method: "POST", authToken: token, body: { eventId, name: newName.trim(), allowedOptionIds: newOptionIds } });
await createSection(token, { eventId, name: newName.trim(), allowedOptionIds: newOptionIds });
setNewName(""); setNewOptionIds([]); load();
};
const del = async (id: string) => {
if (!token) return;
await apiFetch(`/api/sections/${id}`, { method: "DELETE", authToken: token });
await deleteSection(token, id);
load();
};
const update = async (id: string, name: string, optionIds: string[]) => {
if (!token) return;
await apiFetch(`/api/sections/${id}`, { method: "PUT", authToken: token, body: { name, allowedOptionIds: optionIds } });
await updateSection(token, id, { name, allowedOptionIds: optionIds });
load();
};
@@ -594,8 +593,7 @@ function AttachmentsManager({ eventId }: { eventId: string }) {
const ref = useRef<HTMLInputElement | null>(null);
const load = useCallback(async () => {
const res = await apiFetch<EventAttachment[]>(`/api/events/${eventId}/attachments`);
setItems(Array.isArray(res) ? res : []);
setItems(await listAttachments(eventId));
}, [eventId]);
useEffect(() => { load(); }, [load]);
@@ -604,16 +602,14 @@ function AttachmentsManager({ eventId }: { eventId: string }) {
if (!file || !token) return;
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
await apiFetch(`/api/events/${eventId}/attachments`, { method: "POST", body: fd, authToken: token });
await uploadAttachment(token, eventId, file);
await load();
} finally { setUploading(false); if (ref.current) ref.current.value = ""; }
};
const del = async (id: string) => {
if (!token || !confirm("Delete this attachment?")) return;
await apiFetch(`/api/events/${eventId}/attachments/${id}`, { method: "DELETE", authToken: token });
await deleteAttachment(token, eventId, id);
await load();
};
@@ -776,8 +772,8 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
(async () => {
setLoading(true);
try {
const res = await apiFetch<NotifyUser[]>(`/api/events/${eventId}/notify-recipients`, { authToken: token || undefined });
if (!cancelled) setSelected(Array.isArray(res) ? res : []);
const { data: res } = await apiV2("get", "/events/{id}/notify-recipients", { params: { id: eventId }, authToken: token });
if (!cancelled) setSelected(res);
} catch (e) {
if (!cancelled) setError(errorMessage(e) || "Failed to load notification recipients");
} finally {
@@ -794,10 +790,7 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
setSaving(true);
setError(null);
try {
await apiFetch(`/api/events/${eventId}/notify-recipients`, {
method: "PUT", authToken: token || undefined,
body: { userIds: next.map(u => u.id) },
});
await apiV2("put", "/events/{id}/notify-recipients", { params: { id: eventId }, authToken: token, body: { userIds: next.map(u => u.id) } });
} catch (e) {
setSelected(prev);
setError(errorMessage(e) || "Failed to save notification recipients");
@@ -1042,7 +1035,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
// left out (the kids or volunteer ticket) is dropped rather than widened to "all options".
useEffect(() => {
if (!cloned || !source?.id || !token) return;
apiFetch<Section[]>(`/api/sections?eventId=${source.id}`, { authToken: token }).then(list => {
listSections<Section>(token, source.id).then(list => {
const drafts: SectionDraft[] = [];
for (const sec of list || []) {
const ids = (sec.allowedOptions || []).map(o => o.eventOptionId);
@@ -1067,7 +1060,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
const formLoadedRef = useRef(false);
useEffect(() => {
if (mode !== "edit" || !ev?.id || formLoadedRef.current) return;
apiFetch<Event>(`/api/events/${ev.id}`).then(full => {
fetchEvent<Event>(ev.id).then(full => {
const f = full?.form;
if (f) setFormDef({ isRequired: !!f.isRequired, fields: toFieldDefs(f.fields) });
}).catch(() => {}).finally(() => { formLoadedRef.current = true; });
@@ -1182,8 +1175,8 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
// Contact-only events have no ticket options to save — but a form (if any) still needs
// saving in edit mode; for create mode the form is already included in the POST body.
if (mode === "edit") {
await apiFetch(`/api/events/${eventId}`, {
method: "PUT", authToken: token || undefined,
await apiV2("patch", "/events/{id}", {
params: { id: eventId }, authToken: token,
body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) } }
});
}
@@ -1207,10 +1200,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
if (opt.id) {
// Update option + variants first to get/confirm variant IDs
const updated = await apiFetch<EventOption>(`/api/events/options/${opt.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all", variants: variantsPayload }
});
const updated = await updateTicketOption(token, opt.id, { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all", variants: variantsPayload });
// Build variant tiers with confirmed IDs from response
const serverVariants = updated?.variants || [];
@@ -1226,28 +1216,19 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
}
if (allTiers.length > 0 || opt.earlyBirdTiers.length > 0 || opt.variants.some(v => v.earlyBirdTiers.length > 0)) {
await apiFetch(`/api/events/options/${opt.id}`, {
method: "PUT", authToken: token || undefined,
body: { earlyBirdTiers: allTiers }
});
await updateTicketOption(token, opt.id, { earlyBirdTiers: allTiers });
}
// Handle form save (edit only)
await apiFetch(`/api/events/${eventId}`, {
method: "PUT", authToken: token || undefined,
await apiV2("patch", "/events/{id}", {
params: { id: eventId }, authToken: token,
body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) } }
});
} else {
// New option added during edit
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
});
const created = await createTicketOption(token, eventId, { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" });
if (created?.id) {
const updated = await apiFetch<EventOption>(`/api/events/options/${created.id}`, {
method: "PUT", authToken: token || undefined,
body: { variants: variantsPayload }
});
const updated = await updateTicketOption(token, created.id, { variants: variantsPayload });
const serverVariants = updated?.variants || [];
for (let vi = 0; vi < opt.variants.length; vi++) {
const v = opt.variants[vi];
@@ -1260,9 +1241,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
}
}
if (allTiers.length > 0) {
await apiFetch(`/api/events/options/${created.id}`, {
method: "PUT", authToken: token || undefined, body: { earlyBirdTiers: allTiers }
});
await updateTicketOption(token, created.id, { earlyBirdTiers: allTiers });
}
}
}
@@ -1270,7 +1249,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
} else {
// Create mode — backend auto-creates a "Main Ticket" option; replace/create from draft
let createdEvent: Event | null = null;
try { createdEvent = await apiFetch<Event>(`/api/events/${eventId}`); } catch {}
try { createdEvent = await fetchEvent<Event>(eventId); } catch {}
const existingMain = (createdEvent?.eventOptions || []).find((o) => !!o.isMainTicket);
const uiMainIdx = options.findIndex(o => o.isMainTicket);
@@ -1286,16 +1265,10 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
if (i === uiMainIdx && existingMain) {
// Update the auto-created main ticket
await apiFetch(`/api/events/options/${existingMain.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
});
await updateTicketOption(token, existingMain.id, { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" });
optId = existingMain.id;
} else {
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
});
const created = await createTicketOption(token, eventId, { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" });
optId = created?.id;
}
@@ -1307,9 +1280,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
.map((t, ti) => ({ deadline: new Date(t.deadline).toISOString(), price: parseFloat(t.price), stockLimit: parseInt(t.stockLimit || "0"), order: ti, variantId: null }));
if (variantsPayload.length > 0 || opt.variants.some(v => v.earlyBirdTiers.length > 0)) {
const updated = await apiFetch<EventOption>(`/api/events/options/${optId}`, {
method: "PUT", authToken: token || undefined, body: { variants: variantsPayload }
});
const updated = await updateTicketOption(token, optId, { variants: variantsPayload });
const serverVariants = updated?.variants || [];
for (let vi = 0; vi < opt.variants.length; vi++) {
const v = opt.variants[vi];
@@ -1324,9 +1295,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
}
if (allTiers.length > 0) {
await apiFetch(`/api/events/options/${optId}`, {
method: "PUT", authToken: token || undefined, body: { earlyBirdTiers: allTiers }
});
await updateTicketOption(token, optId, { earlyBirdTiers: allTiers });
}
}
}
@@ -1339,10 +1308,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
.map(idx => createdOptions[idx]?.id)
.filter(Boolean);
try {
await apiFetch("/api/sections", {
method: "POST", authToken: token || undefined,
body: { eventId, name: sec.name.trim(), allowedOptionIds: optionIds }
});
await createSection(token, { eventId, name: sec.name.trim(), allowedOptionIds: optionIds as string[] });
} catch {}
}
};
@@ -1412,41 +1378,36 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
if (mode === "edit") {
body.isActive = draft.isActive;
if (!ev) throw new Error("No event loaded to save");
const saved = await apiFetch<Event & { sessionChanges?: { moved: number; cancelled: number } | null }>(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body });
const { data: saved } = await apiV2("patch", "/events/{id}", { params: { id: ev.id }, authToken: token, body });
await saveOptions(ev.id);
// Form is saved inside saveOptions for edit mode; avoid double call
await saveFeedback(ev.id);
const ch = saved?.sessionChanges;
if (ch && (ch.moved > 0 || ch.cancelled > 0)) notice = { eventId: ev.id, moved: ch.moved, cancelled: ch.cancelled };
const ch = saved.sessionChanges;
if (ch && ((ch.moved ?? 0) > 0 || (ch.cancelled ?? 0) > 0)) notice = { eventId: ev.id, moved: ch.moved ?? 0, cancelled: ch.cancelled ?? 0 };
} else {
if (formDef.isRequired || formDef.fields.length > 0) {
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) };
}
if (cloning) body.clonedFromId = source!.id;
const created = await apiFetch<Event>("/api/events", { method: "POST", authToken: token || undefined, body });
const { data: created } = await apiV2("post", "/events", { authToken: token, body: body as typeof body & { title: string; startDate: string; endDate: string } });
if (created?.id) {
await saveOptions(created.id);
await saveFeedback(created.id);
// Groups (and planned costs, for admins) aren't in this form; the server copies them.
if (cloning) {
await apiFetch(`/api/events/${created.id}/copy-from`, { method: "POST", authToken: token || undefined, body: { fromEventId: source!.id } });
await apiV2("post", "/events/{id}/copy-from", { params: { id: created.id }, authToken: token, body: { fromEventId: source!.id } });
}
// Fetch event to get final option IDs for sections
const fullEvent = await apiFetch<Event>(`/api/events/${created.id}`).catch(() => null);
const fullEvent = await fetchEvent<Event>(created.id).catch(() => null);
const createdOptions = fullEvent?.eventOptions || [];
if (sectionDrafts.length > 0) await saveStagedSections(created.id, createdOptions);
// Upload staged attachments
for (const file of stagedFiles) {
const fd = new FormData();
fd.append("file", file);
await apiFetch(`/api/events/${created.id}/attachments`, { method: "POST", body: fd, authToken: token || undefined });
await uploadAttachment(token, created.id, file);
}
// Save staged notify recipients
if (notifyRecipientDrafts.length > 0) {
await apiFetch(`/api/events/${created.id}/notify-recipients`, {
method: "PUT", authToken: token || undefined,
body: { userIds: notifyRecipientDrafts.map(u => u.id) },
}).catch(() => {});
await apiV2("put", "/events/{id}/notify-recipients", { params: { id: created.id }, authToken: token, body: { userIds: notifyRecipientDrafts.map(u => u.id) } }).catch(() => {});
}
}
}
@@ -2061,7 +2022,7 @@ export default function ManageEventsPage() {
if (!token) return;
setLoadingEvents(true);
try {
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
const evs = await fetchManagedEvents<Event>(token, { past: true, inactive: true });
setEvents(Array.isArray(evs) ? evs.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
} catch (e) {
setError(errorMessage(e) || "Failed to load events");
@@ -2095,7 +2056,7 @@ export default function ManageEventsPage() {
const openCreate = () => { setEditingEvent(null); setModalMode("create"); };
const openEdit = async (ev: Event) => {
try {
const full = await apiFetch<Event>(`/api/events/${ev.id}`, { authToken: token || undefined });
const full = await fetchEvent<Event>(ev.id, { authToken: token || undefined });
setEditingEvent(full);
} catch { setEditingEvent(ev); }
setModalMode("edit");
@@ -2104,7 +2065,7 @@ export default function ManageEventsPage() {
const [copyFrom, setCopyFrom] = useState<Event | null>(null);
const openCopy = async (ev: Event) => {
try {
const full = await apiFetch<Event>(`/api/events/${ev.id}`, { authToken: token || undefined });
const full = await fetchEvent<Event>(ev.id, { authToken: token || undefined });
setEditingEvent(null);
setCopyFrom(full);
setModalMode("create");
@@ -4,13 +4,14 @@ import { ExtraFieldInput, isExtraFieldType, ChoicesEditor } from "@/components/s
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { apiFetch, fetchAllUsers, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { FileText } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, EventForm, FormResponse, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2 } from "@/lib/apiV2";
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph' | 'rating' | 'multiple_choice';
@@ -170,7 +171,7 @@ export default function FormsBrowserPage() {
const loadEvents = useCallback(async () => {
if (!token) return;
try {
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
const evs = await fetchManagedEvents<Event>(token, { past: true, inactive: true });
const sorted = (evs || []).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
setAllEvents(Array.isArray(sorted) ? sorted : []);
} catch {}
@@ -307,7 +308,7 @@ export default function FormsBrowserPage() {
if (!eventId) return;
try {
setFormLoading(true);
const evFull = await apiFetch<Event>(`/api/events/${encodeURIComponent(eventId)}`);
const evFull = await fetchEvent<Event>(eventId);
setFormDef(toFormDef(evFull?.form));
setFormLoadedFor(eventId);
} catch (e) {
@@ -328,7 +329,7 @@ export default function FormsBrowserPage() {
setError(null); setInfo(null);
try {
const fields = (formDef.fields || []).filter(f => (f.label || '').trim().length > 0).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph', order: i, helpText: f.helpText || null, options: f.type === 'multiple_choice' && Array.isArray(f.options) ? f.options.map((o) => String(o).trim()).filter(Boolean) : undefined }));
await apiFetch(`/api/events/${encodeURIComponent(selectedEventId)}`, { method: 'PUT', authToken: token, body: { form: { isRequired: !!formDef.isRequired, fields } } });
await apiV2("patch", "/events/{id}", { params: { id: selectedEventId }, authToken: token, body: { form: { isRequired: !!formDef.isRequired, fields } } });
setInfo('Form saved');
} catch (e) {
setError(errorMessage(e) || 'Failed to save form');
@@ -376,7 +377,7 @@ export default function FormsBrowserPage() {
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
setFillRegistration(reg);
if (reg?.event?.id) {
const evFull = await apiFetch<Event>(`/api/events/${encodeURIComponent(reg.event.id)}`);
const evFull = await fetchEvent<Event>(reg.event.id);
setFillForm(toFormDef(evFull?.form));
}
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
@@ -4,7 +4,7 @@ import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormField
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchEvent } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { UserPlus } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -51,7 +51,7 @@ export default function ManualRegistrationPage() {
setCreatedReg(res);
// Load form definition for this event (if any)
try {
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(eventId)}`);
const ev = await fetchEvent<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(eventId);
if (ev?.form) setForm(ev.form);
} catch {}
alert("Manual registration created");
@@ -3,7 +3,7 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { apiFetch, fetchAllUsers, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
@@ -235,7 +235,7 @@ export default function ManualRegistrationPage() {
(async () => {
try {
if (!token) return;
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
const evs = await fetchManagedEvents<Event>(token);
const now = Date.now();
const active = (evs || []).filter(ev => {
const t = new Date(ev.endDate).getTime();
@@ -277,7 +277,7 @@ export default function ManualRegistrationPage() {
useEffect(() => {
if (!selectedEventId) { setBundles([]); setFullEvent(null); return; }
let cancelled = false;
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: token || undefined })
fetchEvent<Event>(selectedEventId, { authToken: token || undefined })
.then(ev => { if (!cancelled) { setBundles(ev?.bundles || []); setFullEvent(ev || null); } })
.catch(() => { if (!cancelled) { setBundles([]); setFullEvent(null); } });
return () => { cancelled = true; };
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { WifiOff, Unlock, CheckCircle2 } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -41,7 +41,7 @@ export default function OfflineScansPage() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
fetchManagedEvents<Event>(token, { past: true })
.then(evs => {
const recent = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 14 * 864e5);
setEvents(recent);
@@ -3,7 +3,7 @@
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
import { apiFetch, fetchAllUsers, fetchAllPayments, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch";
import { Wallet, RotateCcw, HandHeart, ScanLine, Link2 } from "lucide-react";
@@ -375,9 +375,7 @@ function PaymentsContent() {
try {
if (!token) return;
// Past events too: the pickers open on upcoming ones and keep past ones a click away.
const params = new URLSearchParams({ includePast: "true" });
if (eventsIncludeInactive) params.set("includeInactive", "true");
const evs = await apiFetch<Event[]>(`/api/events/all?${params.toString()}`, { authToken: token });
const evs = await fetchManagedEvents(token, { past: true, inactive: eventsIncludeInactive });
setEvents(evs || []);
} catch {}
})();
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ListChecks, ArrowUp, ArrowDown } from "lucide-react";
@@ -58,7 +58,7 @@ export default function WaitlistPage() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<Event[]>("/api/events/all", { authToken: token })
fetchManagedEvents<Event>(token)
.then(evs => {
const withList = (evs || []).filter(e => e.waitlistEnabled);
setEvents(withList);
@@ -3,7 +3,7 @@
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { apiFetch, fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { MessageCircle, Users, Zap, Send, Clock, type LucideIcon } from "lucide-react";
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
@@ -377,7 +377,7 @@ function WhatsAppAttendeesPageInner() {
const loadEvents = async () => {
try {
setLoadingEvents(true);
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token || undefined });
const evs = await fetchManagedEvents<Event>(token || undefined, { past: true });
setAllEvents((evs || []).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()));
} catch (e) {
setError(errorMessage(e) || "Failed to load events");
@@ -3,7 +3,7 @@ import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormField
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchEvent } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { FileText } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -37,7 +37,7 @@ function FormsContent() {
try {
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
setRegistration(reg);
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(reg.eventId)}`);
const ev = await fetchEvent<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(reg.eventId);
if (ev?.form) setEventForm(ev.form);
// Load any saved draft
try {
+3 -3
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, createFullPaymentCheckout } from "@/lib/api";
import { apiFetch, createFullPaymentCheckout, fetchEvent } from "@/lib/api";
import { apiV2 } from "@/lib/apiV2";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { useRouter } from "next/navigation";
@@ -383,7 +383,7 @@ export default function UserDashboardPage() {
}
(async () => {
try {
const ev = await apiFetch<Event>(`/api/events/${encodeURIComponent(ar.eventId)}`);
const ev = await fetchEvent<Event>(ar.eventId);
const has = !!(ev?.form && Array.isArray(ev.form.fields) && ev.form.fields.length > 0);
setActiveEventHasForm(has);
} catch {
@@ -509,7 +509,7 @@ export default function UserDashboardPage() {
setEditLoading(true);
try {
// Load event options (with variants) for this event
const eventData = await apiFetch<Event>(`/api/events/${encodeURIComponent(activeReg.eventId)}`);
const eventData = await fetchEvent<Event>(activeReg.eventId);
const eventOptions = (eventData?.eventOptions || []).filter((eo) => !isPickOption(eventData, eo));
const regOptById = new Map<string, RegistrationOption>((activeReg.registrationOptions || []).map((o) => [o.id, o]));
@@ -53,7 +53,7 @@ export default function ClientActions({ event }: Props) {
Save QR
</button>
<a
href={`${API_BASE}/api/events/${event.id}/ics`}
href={`${API_BASE}/api/v2/events/${event.id}/calendar.ics`}
download
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
>
+2 -2
View File
@@ -59,7 +59,7 @@ type Event = {
nextInSeries?: NextInSeries | null;
};
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
import { ApiError, resolveToApiOrigin, fetchEvent } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { formatDateTimeRange } from "@/lib/date";
import { BundleOffers } from "@/components/shared/BundleDeals";
@@ -142,7 +142,7 @@ function RegisterCta({ event }: { event: Event }) {
// request, so calling this again from the page component below is free.
async function loadEvent(id: string): Promise<Event | null> {
try {
return await apiFetch<Event>(`/api/events/${id}`, { nextOptions: { next: { revalidate } } });
return await fetchEvent<Event>(id, { nextOptions: { next: { revalidate } } });
} catch (e) {
if (e instanceof ApiError && e.status === 404) return null;
throw e;
+2 -2
View File
@@ -3,7 +3,7 @@ import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormField
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchEvent } from "@/lib/api";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { FileText } from "lucide-react";
@@ -42,7 +42,7 @@ function FormsContent() {
// authToken optional — guests access via registrationId alone
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token || undefined });
setRegistration(reg);
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(reg.eventId)}`);
const ev = await fetchEvent<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(reg.eventId);
if (ev?.form) setEventForm(ev.form);
// Load any saved draft to prefill fields
try {
+2 -2
View File
@@ -1,7 +1,7 @@
import { notFound, redirect } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch, ApiError } from "@/lib/api";
import { ApiError, fetchEvent } from "@/lib/api";
import RegisterForm, { type RegisterEvent } from "./RegisterForm";
import { FunnelTracker } from "@/components/events/FunnelTracker";
@@ -13,7 +13,7 @@ export default async function RegisterPage({ params }: { params: Promise<{ event
try {
// Fetched server-side (same pattern as /events/[id]) so the form's content is part of
// the initial HTML instead of showing a loading skeleton after a client-side fetch.
event = await apiFetch<RegisterEvent>(`/api/events/${eventId}`, { nextOptions: { next: { revalidate } } });
event = await fetchEvent<RegisterEvent>(eventId, { nextOptions: { next: { revalidate } } });
} catch (e) {
if (e instanceof ApiError && e.status === 404) notFound();
throw e;
@@ -43,7 +43,7 @@ function RegistrationSuccessContent() {
const r = token ? await (await import('@/lib/api')).apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null;
if (r) {
setReg(r);
const ev = await (await import('@/lib/api')).apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(r.eventId)}`);
const ev = await (await import('@/lib/api')).fetchEvent<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(r.eventId);
if (ev?.form) setForm(ev.form);
}
} catch (e) {
+3 -6
View File
@@ -2,7 +2,7 @@
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { AuthResponse, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
@@ -218,7 +218,7 @@ export default function SelfServicePage() {
useEffect(() => {
if (!selectedEventId) { setKioskBundles([]); setKioskFull(null); return; }
let cancelled = false;
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: supervisorToken || undefined })
fetchEvent<Event>(selectedEventId, { authToken: supervisorToken || undefined })
.then(ev => { if (!cancelled) { setKioskBundles(ev?.bundles || []); setKioskFull(ev || null); } })
.catch(() => { if (!cancelled) { setKioskBundles([]); setKioskFull(null); } });
return () => { cancelled = true; };
@@ -319,10 +319,7 @@ export default function SelfServicePage() {
const loadEvents = useCallback(async (token: string) => {
setEventsLoading(true);
try {
const data: KioskEvent[] = await apiFetch(
"/api/events/all?includePast=false&includeInactive=false&excludeClosed=true",
{ authToken: token }
);
const data = await fetchManagedEvents<KioskEvent>(token, { closed: false });
setEvents(data);
} catch {
setEvents([]);
+4 -27
View File
@@ -2,12 +2,13 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { apiFetch, API_BASE } from "@/lib/api";
import { apiFetch } from "@/lib/api";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { extractDominantColors } from "@/lib/extractColors";
import { errorMessage } from "@/lib/utils";
import { ConnectForm } from "@/components/admin/LicenceTab";
import { uploadImage } from "@/lib/eventSetup";
type Step = 1 | 2 | 3 | 4 | 5 | 6;
@@ -196,36 +197,12 @@ export default function SetupPage() {
// setupToken from /api/setup/register covers that.
let logoUrl = "";
if (logoFile) {
const fd = new FormData();
fd.append("image", logoFile);
const uploadRes = await fetch(`${API_BASE}/api/uploads/logo`, {
method: "POST",
headers: setupToken ? { Authorization: `Bearer ${setupToken}` } : undefined,
body: fd,
});
if (uploadRes.ok) {
const up = await uploadRes.json();
logoUrl = up.url || "";
} else {
throw new Error("Logo upload failed. Please try again.");
}
logoUrl = await uploadImage(setupToken, "logo", logoFile).catch(() => { throw new Error("Logo upload failed. Please try again."); });
}
let faviconUrl = "";
if (faviconFile) {
const fd = new FormData();
fd.append("image", faviconFile);
const uploadRes = await fetch(`${API_BASE}/api/uploads/favicon`, {
method: "POST",
headers: setupToken ? { Authorization: `Bearer ${setupToken}` } : undefined,
body: fd,
});
if (uploadRes.ok) {
const up = await uploadRes.json();
faviconUrl = up.url || "";
} else {
throw new Error("Favicon upload failed. Please try again.");
}
faviconUrl = await uploadImage(setupToken, "favicon", faviconFile).catch(() => { throw new Error("Favicon upload failed. Please try again."); });
}
const settings: Record<string, string> = {
@@ -2,8 +2,8 @@
import React, { useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { uploadImage } from "@/lib/eventSetup";
export function FileUploader({ onUploaded }: { onUploaded: (url: string) => void }) {
const { token } = useAuth();
@@ -16,14 +16,7 @@ export function FileUploader({ onUploaded }: { onUploaded: (url: string) => void
setLoading(true);
setError(null);
try {
const fd = new FormData();
fd.append("image", file);
const data = await apiFetch<{ url: string }>(`/api/uploads/event-image`, {
method: "POST",
body: fd,
authToken: token,
});
onUploaded(data.url);
onUploaded(await uploadImage(token, "event-image", file));
} catch (e) {
setError(errorMessage(e) || "Upload failed");
} finally {
@@ -3,7 +3,7 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { apiFetch, API_BASE, fetchManagedEvents } from "@/lib/api";
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck, Star, MessageSquareHeart, Send, Eye, MousePointerClick, BadgeCheck, Users, Ticket as TicketIcon } from "lucide-react";
import { REPORTS, type ReportKey } from "./ReportCatalog";
@@ -187,7 +187,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
useEffect(() => {
(async () => {
try {
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token || undefined });
const evs = await fetchManagedEvents<Event>(token || undefined, { past: true, inactive: true });
setEvents(Array.isArray(evs) ? evs : []);
} catch (e) {
setError(errorMessage(e) || "Failed to load events");
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Ticket } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import type { Event, SeasonPassView } from "@/types";
@@ -33,7 +33,7 @@ export function SeasonPassesPanel({ seriesId, seriesSlug }: { seriesId: string;
if (!token) return;
const [p, all] = await Promise.all([
apiFetch<SeasonPassView[]>(`/api/season-passes?seriesId=${encodeURIComponent(seriesId)}`, { authToken: token }).catch(() => []),
apiFetch<Event[]>("/api/events/all?includeInactive=true", { authToken: token }).catch(() => []),
fetchManagedEvents<Event>(token, { inactive: true }).catch(() => []),
]);
setPasses(p);
// Upcoming events in this series that take registrations, not youth events.
File diff suppressed because it is too large Load Diff
+2917 -1
View File
File diff suppressed because it is too large Load Diff
+31 -1
View File
@@ -1,4 +1,4 @@
import type { Paginated, Payment, User } from "@/types";
import type { Event, Paginated, Payment, User } from "@/types";
export type ApiOptions = {
method?: string;
@@ -197,6 +197,36 @@ async function fetchAllPages<T>(path: string, token: string, params: Record<stri
return items;
}
/**
* Every event staff can see (API v2 GET /events/manage): upcoming by default; `past`,
* `inactive` widen it, `closed: false` leaves out cashed-up events. Full records — ticket options,
* form, sessions, campus, series — for the event pickers and set-up screens.
*/
export async function fetchManagedEvents<T = Event>(token: string | null | undefined, opts: { past?: boolean; inactive?: boolean; closed?: boolean; campus?: string } = {}): Promise<T[]> {
const { apiV2 } = await import("@/lib/apiV2");
const { data } = await apiV2("get", "/events/manage", {
authToken: token,
query: {
past: opts.past ? "true" : "false",
inactive: opts.inactive ? "true" : "false",
closed: opts.closed === false ? "false" : "true",
...(opts.campus ? { campus: opts.campus } : {}),
},
});
return data as unknown as T[];
}
/**
* One event in full (API v2 GET /events/{id}/full): ticket options with what's left, sessions,
* form, attachments, bundles. Staff signed in (`authToken`) also see inactive and not-yet-live
* events; `nextOptions` passes Next.js caching (e.g. revalidate) through for server pages.
*/
export async function fetchEvent<T = Event>(id: string, options: { authToken?: string | null; nextOptions?: RequestInit } = {}): Promise<T> {
const { apiV2 } = await import("@/lib/apiV2");
const { data } = await apiV2("get", "/events/{id}/full", { params: { id }, authToken: options.authToken, nextOptions: options.nextOptions });
return data as unknown as T;
}
export function fetchAllUsers(token: string, params: Record<string, string> = {}): Promise<User[]> {
return fetchAllPages<User>("/api/users", token, params);
}
+6 -3
View File
@@ -27,18 +27,21 @@ type Body<O> = O extends { requestBody: { content: { "application/json": infer B
type IfAny<T, Then> = [T] extends [never] ? object : Then;
export type V2Options<P extends keyof paths, M extends Method> =
{ authToken?: string | null }
{ authToken?: string | null; nextOptions?: RequestInit }
& IfAny<PathParams<Op<P, M>>, { params: PathParams<Op<P, M>> }>
& IfAny<QueryParams<Op<P, M>>, { query?: QueryParams<Op<P, M>> }>
& IfAny<Body<Op<P, M>>, { body: Body<Op<P, M>> }>;
export function apiV2<M extends Method, P extends PathsWith<M>>(method: M, path: P, ...[options]: object extends V2Options<P, M> ? [V2Options<P, M>?] : [V2Options<P, M>]): Promise<V2Result<P, M>> {
const opts = (options || {}) as { authToken?: string | null; params?: Record<string, string>; query?: Record<string, unknown>; body?: unknown };
const opts = (options || {}) as { authToken?: string | null; nextOptions?: RequestInit; params?: Record<string, string>; query?: Record<string, unknown>; body?: unknown };
let url = String(path).replace(/\{(\w+)\}/g, (_, name: string) => encodeURIComponent(String(opts.params?.[name] ?? "")));
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(opts.query || {})) {
if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
}
if (qs.toString()) url += `?${qs.toString()}`;
return apiFetch<V2Result<P, M>>(`/api/v2${url}`, { method: method.toUpperCase(), authToken: opts.authToken, body: opts.body });
return apiFetch<V2Result<P, M>>(`/api/v2${url}`, { method: method.toUpperCase(), authToken: opts.authToken, body: opts.body, nextOptions: opts.nextOptions });
}
/** The JSON body a v2 endpoint takes. */
export type V2Body<P extends keyof paths, M extends Method> = Body<Op<P, M>>;
+18 -7
View File
@@ -1,5 +1,8 @@
// What the Ctrl+K command palette can jump to without searching (1.49.0): pages, settings tabs,
// reports and quick actions, each for the roles that can open it. `keywords` widen matching.
// Reports come from the report catalogue, so a new report shows up here by itself; a new page
// needs a line below (e2e/dashboard.spec.ts checks every dashboard page is listed or excluded).
import { REPORTS } from "@/components/reports/ReportCatalog";
export type Role = "staff" | "supervisor" | "admin";
export type CommandPage = { label: string; href: string; group: "Actions" | "Pages" | "Settings" | "Reports"; roles: Role[]; keywords?: string };
@@ -18,6 +21,10 @@ export const COMMAND_PAGES: CommandPage[] = [
{ group: "Actions", label: "Email attendees", href: "/dashboard/supervisor/email-attendees", roles: SUP, keywords: "message send mail" },
{ group: "Actions", label: "WhatsApp attendees", href: "/dashboard/supervisor/whatsapp-attendees", roles: SUP, keywords: "message send" },
// Pages
{ group: "Pages", label: "Dashboard", href: "/dashboard", roles: S, keywords: "home today waiting overview" },
{ group: "Pages", label: "Reports", href: "/dashboard/supervisor/reports", roles: SUP, keywords: "all reports export pdf excel" },
{ group: "Pages", label: "Ticket options", href: "/dashboard/supervisor/event-options", roles: SUP, keywords: "event options prices variants early bird" },
{ group: "Pages", label: "My groups", href: "/dashboard/staff/my-groups", roles: S, keywords: "rooms groups leader allocations" },
{ group: "Pages", label: "Events", href: "/dashboard/supervisor/events", roles: SUP, keywords: "manage edit copy" },
{ group: "Pages", label: "Event series", href: "/dashboard/supervisor/series", roles: SUP, keywords: "season pass follow" },
{ group: "Pages", label: "Registrations", href: "/dashboard/admin/registrations", roles: ADM, keywords: "orders cancel status" },
@@ -37,23 +44,27 @@ export const COMMAND_PAGES: CommandPage[] = [
{ group: "Pages", label: "Bundle deals", href: "/dashboard/supervisor/bundles", roles: SUP, keywords: "family deal" },
{ group: "Pages", label: "Event tickets & printing", href: "/dashboard/staff/event-tickets", roles: S, keywords: "print list" },
{ group: "Pages", label: "Offline scanning", href: "/dashboard/supervisor/offline-scans", roles: SUP, keywords: "devices sync" },
{ group: "Pages", label: "Attendee forms", href: "/dashboard/admin/forms", roles: ADM, keywords: "answers" },
{ group: "Pages", label: "Attendee forms", href: "/dashboard/supervisor/forms", roles: S, keywords: "answers questions responses" },
{ group: "Pages", label: "Audit log", href: "/dashboard/admin/audit-log", roles: ADM, keywords: "history who changed" },
{ group: "Pages", label: "Privacy (POPIA)", href: "/dashboard/admin/privacy", roles: ADM, keywords: "notice operators retention breaches readiness" },
{ group: "Pages", label: "Privacy requests", href: "/dashboard/admin/data-requests", roles: ADM, keywords: "delete download correction popia" },
{ group: "Pages", label: "My profile & security", href: "/dashboard/user/profile", roles: S, keywords: "password two-factor" },
// Their own side, as members (staff register for things too)
{ group: "Pages", label: "My events & tickets", href: "/dashboard/user", roles: S, keywords: "my registrations tickets" },
{ group: "Pages", label: "My payments", href: "/dashboard/user/payments", roles: S, keywords: "my receipts invoices" },
{ group: "Pages", label: "My privacy", href: "/dashboard/user/privacy", roles: S, keywords: "my data download delete news popia" },
{ group: "Pages", label: "My kids", href: "/dashboard/user/kids", roles: S, keywords: "children family pickup" },
{ group: "Pages", label: "My serving", href: "/dashboard/user/serving", roles: S, keywords: "volunteer roster cover" },
{ group: "Pages", label: "My attendee forms", href: "/dashboard/user/forms", roles: S, keywords: "fill in answers" },
{ group: "Pages", label: "Make a donation", href: "/dashboard/user/donate", roles: S, keywords: "give donate" },
// Settings
...([
["organisation", "Organisation"], ["campuses", "Campuses"], ["branding", "Branding"], ["notifications", "Notifications"],
["email", "Email"], ["legal", "Legal"], ["whatsapp", "WhatsApp"], ["payments", "Payments"], ["webhooks", "Webhooks"],
["backups", "Backups"], ["jobs", "Background jobs"], ["licence", "Licence & updates"],
] as const).map(([tab, label]): CommandPage => ({ group: "Settings", label: `Settings: ${label}`, href: `/dashboard/admin/settings?tab=${tab}`, roles: ADM, keywords: "site settings" })),
// Reports
...([
["payments", "Payments between dates"], ["revenue", "Revenue summary"], ["regStatus", "Registration status"],
["funnel", "Registration funnel"], ["feedbackSummary", "Feedback summary"], ["usage", "Ticket usage"],
["financeReport", "Finance report"], ["seriesComparison", "Series: year on year"], ["discounts", "Discounts"],
] as const).map(([key, label]): CommandPage => ({ group: "Reports", label: `Report: ${label}`, href: `/dashboard/supervisor/reports?report=${key}`, roles: SUP, keywords: "report" })),
// Reports — every report in the catalogue
...REPORTS.map((r): CommandPage => ({ group: "Reports", label: `Report: ${r.label}`, href: `/dashboard/supervisor/reports?report=${r.key}`, roles: SUP, keywords: `report ${r.category} ${r.description}` })),
];
/** Pages for a role, matching every word of the query (in the label or keywords). */
+97
View File
@@ -0,0 +1,97 @@
import { apiV2, type V2Body } from "@/lib/apiV2";
import type { EventAttachment, EventOption } from "@/types";
/**
* Event set-up calls on API v2 (1.51.8): ticket options and variants, scanning sections,
* attachments, planned costs and image uploads. Each returns what the screens already work with.
*/
type Token = string | null | undefined;
// ─── Ticket options ──────────────────────────────────────────────────────────
export async function createTicketOption(token: Token, eventId: string, body: V2Body<"/events/{id}/ticket-options", "post">): Promise<EventOption> {
const { data } = await apiV2("post", "/events/{id}/ticket-options", { params: { id: eventId }, authToken: token, body });
return data as unknown as EventOption;
}
/** Only what's sent changes; `earlyBirdTiers` and `variants`, when sent, are the full lists. */
export async function updateTicketOption(token: Token, optionId: string, body: V2Body<"/ticket-options/{id}", "patch">): Promise<EventOption> {
const { data } = await apiV2("patch", "/ticket-options/{id}", { params: { id: optionId }, authToken: token, body });
return data as unknown as EventOption;
}
export async function deleteTicketOption(token: Token, optionId: string): Promise<void> {
await apiV2("delete", "/ticket-options/{id}", { params: { id: optionId }, authToken: token });
}
// ─── Scanning sections ───────────────────────────────────────────────────────
export type SectionRecord = { id: string; eventId: string; name: string; allowedOptions?: { eventOptionId: string; eventOption?: { id: string; name: string } }[] };
export async function listSections<T = SectionRecord>(token: Token, eventId?: string): Promise<T[]> {
const { data } = await apiV2("get", "/sections", { authToken: token, query: eventId ? { eventId } : {} });
return data as unknown as T[];
}
export async function createSection(token: Token, body: V2Body<"/sections", "post">): Promise<SectionRecord> {
const { data } = await apiV2("post", "/sections", { authToken: token, body });
return data as unknown as SectionRecord;
}
export async function updateSection(token: Token, sectionId: string, body: V2Body<"/sections/{id}", "patch">): Promise<SectionRecord> {
const { data } = await apiV2("patch", "/sections/{id}", { params: { id: sectionId }, authToken: token, body });
return data as unknown as SectionRecord;
}
export async function deleteSection(token: Token, sectionId: string): Promise<void> {
await apiV2("delete", "/sections/{id}", { params: { id: sectionId }, authToken: token });
}
// ─── Attachments ─────────────────────────────────────────────────────────────
export async function listAttachments(eventId: string): Promise<EventAttachment[]> {
const { data } = await apiV2("get", "/events/{id}/attachments", { params: { id: eventId } });
return data as unknown as EventAttachment[];
}
/** Uploads a file (PDF, Office, text, CSV or ZIP, up to 20 MB) to an event. */
export async function uploadAttachment(token: Token, eventId: string, file: File): Promise<EventAttachment> {
const fd = new FormData();
fd.append("file", file);
const { apiFetch } = await import("@/lib/api");
const { data } = await apiFetch<{ data: EventAttachment }>(`/api/v2/events/${encodeURIComponent(eventId)}/attachments`, { method: "POST", body: fd, authToken: token });
return data;
}
export async function deleteAttachment(token: Token, eventId: string, attachmentId: string): Promise<void> {
await apiV2("delete", "/events/{id}/attachments/{attachmentId}", { params: { id: eventId, attachmentId }, authToken: token });
}
// ─── Planned costs ───────────────────────────────────────────────────────────
export async function createCost(token: Token, eventId: string, body: V2Body<"/events/{id}/costs", "post">): Promise<void> {
await apiV2("post", "/events/{id}/costs", { params: { id: eventId }, authToken: token, body });
}
export async function updateCost(token: Token, costId: string, body: V2Body<"/costs/{id}", "patch">): Promise<void> {
await apiV2("patch", "/costs/{id}", { params: { id: costId }, authToken: token, body });
}
export async function deleteCost(token: Token, costId: string): Promise<void> {
await apiV2("delete", "/costs/{id}", { params: { id: costId }, authToken: token });
}
// ─── Image uploads ───────────────────────────────────────────────────────────
/**
* Uploads an image (field "image") and returns the path to save (`/uploads/…`). `logo` and
* `favicon` work without a token during first-time setup.
*/
export async function uploadImage(token: Token, kind: "event-image" | "logo" | "favicon", file: File | Blob): Promise<string> {
const fd = new FormData();
fd.append("image", file);
const { apiFetch } = await import("@/lib/api");
const { data } = await apiFetch<{ data: { path: string; url: string } }>(`/api/v2/uploads/${kind}`, { method: "POST", body: fd, authToken: token });
return data.path;
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.51.5",
"version": "1.51.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.51.5",
"version": "1.51.8",
"license": "ISC",
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.51.5",
"version": "1.51.8",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",