Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16357e6006 | ||
|
|
9547793f25 | ||
|
|
99efe7a7d0 | ||
|
|
3c983070a1 | ||
|
|
c63c3a3604 | ||
|
|
67d0a83ab8 | ||
|
|
2bcc9ac651 | ||
|
|
b2906011fd | ||
|
|
70850df4f6 | ||
|
|
cbebcdbd48 | ||
|
|
849998acb3 | ||
|
|
c5fe17a1ef | ||
|
|
6164fdcb3b | ||
|
|
1510c2a057 | ||
|
|
fa4191d18c | ||
|
|
e0dae5647f |
+120
@@ -7,6 +7,126 @@ 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
|
||||
|
||||
- **Next.js 16** (from 15.5) and **React 19.3**. Builds and dev use Turbopack (Next 16's default);
|
||||
`tsconfig.json` `jsx` is `react-jsx` (required by Next 16); `images.domains` → `remotePatterns`.
|
||||
- **ESLint 10** (ESLint 9 is out of support) with Next 16's own flat configs
|
||||
(`eslint-config-next/core-web-vitals` and `/typescript`, no more FlatCompat). `npm run lint` is
|
||||
`eslint src` (`next lint` was removed). Next 16's React Compiler-era rules
|
||||
(`react-hooks/set-state-in-effect`, `purity`, `immutability`, `static-components`, `refs`) are
|
||||
warnings for now — 181 places, mostly loading data in `useEffect`.
|
||||
- Removed the empty `middleware.ts` (a pass-through that did nothing; Next 16 renames middleware to
|
||||
"proxy"), which also drops its 117 kB bundle.
|
||||
|
||||
## [1.51.4] - 2026-10-29
|
||||
|
||||
### Security
|
||||
|
||||
- **Next.js 15.5.28** (critical: remote code execution through the image optimiser with SVGs, and a
|
||||
Server Actions denial of service), **@sentry/nextjs 10.78** (critical: middleware bypass),
|
||||
**zod 4.6.7** (high: memory exhaustion on oversized collections; backend and frontend),
|
||||
**multer 2.5.0** (high: denial of service from a pending file filter), **nodemailer 10.3.0**
|
||||
(moderate: three address-parsing flaws that could add recipients), fast-uri 3.1.9 and
|
||||
ip-address 10.7.5 (moderate). Minimum versions raised in package.json so a fresh install can't
|
||||
resolve an older one. `npm audit`: frontend and root clean; the backend's 6 remaining reports are
|
||||
low and dev-only (jest → deepmerge), with no real fix (npm suggests downgrading Jest to 25).
|
||||
|
||||
### Changed
|
||||
|
||||
- **cron-parser 5** (v4 is unmaintained): `CronExpressionParser.parse` in `src/jobs/index.js`.
|
||||
- **Node 24 LTS** for new installs (`scripts/install.sh`); Node 20 reached end of life in April 2026.
|
||||
`deploy.sh` warns on servers older than Node 22; DEPLOYMENT.md has "Upgrading Node".
|
||||
- Roadmap: every endpoint moves to v2 before 2.0 (1.51.5 – 1.51.11).
|
||||
|
||||
### Known
|
||||
|
||||
- Still deprecated, with reasons: exceljs (unmaintained; brings fstream, glob 7, inflight,
|
||||
lodash.isequal, rimraf 2) — replacing it with ExcelTS waits for approval; ESLint 9 (out of
|
||||
support) — ESLint 10 needs Next.js 16 (1.51.5); @opentelemetry/sdk-trace-base — pulled in by the
|
||||
latest @sentry/nextjs, for Sentry to update; glob 10 — inside Jest (dev only).
|
||||
|
||||
## [1.51.3] - 2026-10-29
|
||||
|
||||
### Changed
|
||||
|
||||
+19
-1
@@ -56,7 +56,7 @@ curl -fsSLO https://c-git.crosscode.co.za/joshua/hope-events/raw/branch/main/scr
|
||||
sudo bash install.sh --domain events.yourchurch.org --no-ssl --version v1.41.2
|
||||
```
|
||||
|
||||
It installs Node 20, PostgreSQL, PM2 and nginx, creates the database, writes the config, builds
|
||||
It installs Node 24 LTS, PostgreSQL, PM2 and nginx, creates the database, writes the config, builds
|
||||
and starts the app (in `/opt/hope-events`, run by the `hopeevents` user). Use `--name`, `--dir`,
|
||||
`--web-port` and `--api-port` if other apps already use those names or ports; adjust the ports
|
||||
below to match.
|
||||
@@ -311,6 +311,24 @@ and goes back to the previous version if anything fails first. `backend/.env`,
|
||||
`frontend/.env.production`, `ecosystem.config.js` and the nginx/frpc configs are not in git, so
|
||||
updates keep your changes from this guide.
|
||||
|
||||
### Upgrading Node
|
||||
|
||||
Node 20 stopped getting security fixes in April 2026; Cross Events runs on Node 24 LTS (supported
|
||||
until April 2028). `deploy.sh` warns when a server is still on an older Node. To upgrade:
|
||||
|
||||
```bash
|
||||
node -v # what you have now
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo bash -
|
||||
sudo apt-get install -y nodejs # replaces the old Node
|
||||
sudo npm install -g pm2 # PM2 for the new Node
|
||||
cd /opt/hope-events
|
||||
sudo -u hopeevents -H pm2 update # restart PM2's daemon on the new Node
|
||||
sudo -u hopeevents -H bash scripts/deploy.sh v1.51.4 # reinstall packages and rebuild for Node 24
|
||||
```
|
||||
|
||||
Use the version you're on (or a newer one) in the last line — the deploy reinstalls every package
|
||||
and rebuilds, which a Node upgrade needs.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -107,7 +107,7 @@ curl -fsSLO https://c-git.crosscode.co.za/joshua/hope-events/raw/branch/main/scr
|
||||
sudo bash install.sh --domain events.yourchurch.org --email you@yourchurch.org [--version v1.41.0]
|
||||
```
|
||||
|
||||
It installs Node 20, PostgreSQL (with its own database and a random password), PM2, nginx (the
|
||||
It installs Node 24 LTS, PostgreSQL (with its own database and a random password), PM2, nginx (the
|
||||
API, uploads, `/health` and `/docs` to the backend, everything else to the site) and an HTTPS
|
||||
certificate, writes `backend/.env` and `frontend/.env.production`, migrates, builds and starts the
|
||||
app. Then open `https://<domain>/setup`: create the admin account and enter the **licence key**
|
||||
|
||||
@@ -12,6 +12,36 @@ 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
|
||||
changes in how it works.
|
||||
|
||||
## 1.51.4 — security
|
||||
|
||||
- **Security updates** to the parts the site is built on, including a critical fix in the web
|
||||
framework. Recommended for everyone.
|
||||
|
||||
## 1.51.3
|
||||
|
||||
- **More groundwork for 2.0.** The ticket scanner and check-in at the door now run on the new version
|
||||
|
||||
Generated
+46
-919
File diff suppressed because it is too large
Load Diff
+10
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.3",
|
||||
"version": "1.51.8",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
@@ -20,31 +20,31 @@
|
||||
"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",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"cron-parser": "^4.9.0",
|
||||
"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",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.2.0",
|
||||
"multer": "^2.5.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^10.0.16",
|
||||
"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",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^11.1.1",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -15,10 +15,13 @@
|
||||
* same jobs run on in-process timers instead, as before.
|
||||
*/
|
||||
|
||||
const cronParser = require('cron-parser');
|
||||
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 = [
|
||||
{
|
||||
@@ -105,7 +108,7 @@ function cronFor(job) {
|
||||
const override = process.env[`JOB_SCHEDULE_${job.name.toUpperCase().replace(/-/g, '_')}`];
|
||||
if (override) {
|
||||
try {
|
||||
cronParser.parseExpression(override, { tz: TIMEZONE });
|
||||
CronExpressionParser.parse(override, { tz: TIMEZONE });
|
||||
return override;
|
||||
} catch {
|
||||
console.warn(`[jobs] Ignoring invalid schedule for ${job.name}: "${override}"`);
|
||||
@@ -116,7 +119,7 @@ function cronFor(job) {
|
||||
|
||||
/** When a cron schedule next fires after `from`. */
|
||||
function nextRun(cron, from = new Date()) {
|
||||
return cronParser.parseExpression(cron, { tz: TIMEZONE, currentDate: from }).next().toDate();
|
||||
return CronExpressionParser.parse(cron, { tz: TIMEZONE, currentDate: from }).next().toDate();
|
||||
}
|
||||
|
||||
function summarize(result) {
|
||||
@@ -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
|
||||
|
||||
@@ -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 })) };
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)') } : {}),
|
||||
|
||||
@@ -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)) };
|
||||
},
|
||||
});
|
||||
@@ -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) };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+59
-7
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-29, after **1.51.3** (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,9 +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. **Decide how far v1 goes before 2.0** (roadmap "Still open"): v2 covers what members, guests and
|
||||
the scanner use; the staff/supervisor/admin screens still call ~350 v1 routes. Then **2.0.0 —
|
||||
AI helpers** (§15), AI endpoints on v2 only.
|
||||
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
|
||||
@@ -83,7 +91,7 @@ a test server (both done 2026-10-28).
|
||||
or serious fix) → `git merge --no-ff`
|
||||
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
|
||||
version per feature.
|
||||
- **Before tagging:** run `npx eslint src` in `frontend` over the **whole** app (the deploy runs `next build`, which fails on any lint error, e.g. an unescaped ' or " in JSX text). If a dev server is running, a full check build needs its own folder: `NEXT_DIST_DIR=.next-buildcheck npx next build` (then delete it and `git checkout frontend/tsconfig.json`).
|
||||
- **Before tagging:** run `npx eslint src` in `frontend` over the **whole** app (since Next 16 `next build` no longer lints — it still fails on TypeScript errors — so lint errors only show up if you run it; keep it at 0 errors). If a dev server is running, a full check build needs its own folder: `NEXT_DIST_DIR=.next-buildcheck npx next build` (then delete it and `git checkout frontend/tsconfig.json`).
|
||||
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language. CHANGELOG.md is the
|
||||
full log; WHATS-NEW.md is what churches see before updating (the Hub reads it from main every
|
||||
15 minutes, for tagged versions only and sets the latest version from the tags — nothing to do in the Hub).
|
||||
@@ -95,8 +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:** the deployment notes install Node 20, so dependencies must
|
||||
support Node 20 (that's why pg-boss is pinned to 10.x).
|
||||
- **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
|
||||
@@ -456,6 +466,48 @@ 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`
|
||||
is gone). There's no middleware/proxy file — add `src/proxy.ts` (Next 16's name) only if needed.
|
||||
- **React Compiler lint rules are warnings, not errors** (`eslint.config.mjs`): 181 existing places,
|
||||
mostly `useEffect(() => { load(); }, [...])`. Fix them in a screen when it's moved onto v2
|
||||
(1.51.6 – 1.51.11), then turn the rules back to errors. Don't add new ones.
|
||||
- `eslint-config-next` 16's bundled plugins (react, import, jsx-a11y) don't list ESLint 10 as a peer
|
||||
yet; they work, and npm notes the override. Revisit when they publish ESLint 10 support.
|
||||
|
||||
## TypeScript and API v2 (1.51.0)
|
||||
|
||||
- **Starting the backend:** PM2 runs `src/index.js`, a launcher. With plain Node it runs
|
||||
|
||||
+7
-10
@@ -40,7 +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. **Next: 2.0.0 (AI helpers) — see "Still open" on v1.**
|
||||
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:**
|
||||
|
||||
@@ -1009,7 +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 |
|
||||
| 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 API removed, 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
|
||||
@@ -1046,6 +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.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) |
|
||||
@@ -1068,11 +1072,4 @@ feature.
|
||||
|
||||
## Still open
|
||||
|
||||
- **How much of v1 goes before 2.0** (raised 2026-10-29, after 1.51.3). v2 now covers what members,
|
||||
guests and the scanner use (events, accounts, registering, paying, cancelling, check-in). The
|
||||
staff, supervisor and admin screens still call about 350 v1 routes (reports, cashup, messaging,
|
||||
settings, kids, volunteers…). Porting them all is several releases of work with little gain for
|
||||
churches. Options: (a) port everything, then remove v1 in 2.0 as planned; (b) **recommended** —
|
||||
2.0 removes only the v1 endpoints that have a v2 replacement, and the rest stays as an internal API
|
||||
(no deprecation header, not in the public docs) that moves to v2 when each screen is next
|
||||
worked on; (c) keep all of v1 through 2.0 and decide later.
|
||||
Nothing for now.
|
||||
|
||||
+59
-2
@@ -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=");
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
@@ -39,6 +52,8 @@ test.describe("dashboard and command palette", () => {
|
||||
// Staff don't get admin pages.
|
||||
await loginAs(page, "staff");
|
||||
await page.goto("/dashboard/staff");
|
||||
// The shortcut listens once the signed-in user has loaded (the search button shows then).
|
||||
await expect(page.getByRole("button", { name: /Search or jump to/ })).toBeVisible();
|
||||
await page.keyboard.press("Control+k");
|
||||
const staffDialog = page.getByRole("dialog", { name: "Go to or search" });
|
||||
await staffDialog.getByRole("combobox", { name: "Search" }).fill("users");
|
||||
@@ -46,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
## This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
+23
-13
@@ -1,16 +1,26 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
// Next.js 16's own flat configs (no more FlatCompat / eslintrc). Run with `npm run lint`
|
||||
// (`next lint` was removed in Next 16).
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
// React Compiler-era rules that eslint-plugin-react-hooks 7 (with Next 16) turns on as
|
||||
// errors. They flag patterns the app uses on purpose and that work (mostly loading data in
|
||||
// useEffect), so they're warnings while the code is moved off them screen by screen —
|
||||
// see HANDOVER, "Next.js 16". New code should follow them.
|
||||
rules: {
|
||||
"react-hooks/set-state-in-effect": "warn",
|
||||
"react-hooks/purity": "warn",
|
||||
"react-hooks/immutability": "warn",
|
||||
"react-hooks/static-components": "warn",
|
||||
"react-hooks/refs": "warn",
|
||||
},
|
||||
},
|
||||
globalIgnores([".next/**", ".next-*/**", "out/**", "build/**", "next-env.d.ts", "src/lib/api-v2/schema.d.ts"]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -6,7 +6,8 @@ const nextConfig: NextConfig = {
|
||||
// from clashing with one already running.
|
||||
distDir: process.env.NEXT_DIST_DIR || ".next",
|
||||
images: {
|
||||
domains: ['images.unsplash.com'],
|
||||
// (images.domains is deprecated in Next 16.)
|
||||
remotePatterns: [{ protocol: 'https', hostname: 'images.unsplash.com' }],
|
||||
},
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === 'production',
|
||||
|
||||
Generated
+886
-670
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.3",
|
||||
"version": "1.51.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint src",
|
||||
"api:types": "openapi-typescript src/lib/api-v2/openapi.json -o src/lib/api-v2/schema.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -29,33 +29,33 @@
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toast": "^1.2.14",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@sentry/nextjs": "^10.71.0",
|
||||
"@sentry/nextjs": "^10.78.0",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"idb": "^8.0.4",
|
||||
"lucide-react": "^0.536.0",
|
||||
"next": "^15.5.24",
|
||||
"next": "^16.4.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react": "^19.3.1",
|
||||
"react-day-picker": "^9.8.1",
|
||||
"react-dom": "19.1.0",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-webcam": "^7.2.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.0.15"
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@types/node": "^20",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^15.5.24",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-config-next": "^16.4.3",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "3.4",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// Next.js server instrumentation entry point. A no-op when NEXT_PUBLIC_SENTRY_DSN isn't
|
||||
// set — see instrumentation-client.ts for the browser-side counterpart.
|
||||
//
|
||||
// Node runtime only, deliberately — this app's middleware.ts (which runs in Next's edge
|
||||
// runtime) is a trivial pass-through with no real error surface, so an edge-runtime branch
|
||||
// here would only inflate that shared middleware bundle for no actual coverage benefit.
|
||||
// Node runtime only — the app has no middleware/proxy running in the edge runtime.
|
||||
export async function register() {
|
||||
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) return;
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
|
||||
+5717
-211
File diff suppressed because it is too large
Load Diff
Vendored
+2917
-1
File diff suppressed because it is too large
Load Diff
+31
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// Minimal pass-through middleware to satisfy Next.js requirements
|
||||
export function middleware() {
|
||||
return NextResponse.next();
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
@@ -22,6 +22,6 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next-e2e/types/**/*.ts"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", ".next-e2e/types/**/*.ts", ".next-e2e/dev/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.3",
|
||||
"version": "1.51.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.51.3",
|
||||
"version": "1.51.8",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.3",
|
||||
"version": "1.51.8",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
@@ -53,6 +53,13 @@ checkout() {
|
||||
|
||||
echo "==> Deploying $TARGET (currently $PREVIOUS)"
|
||||
|
||||
# Node 20 reached end of life in April 2026 (no more security fixes). Releases still run on it,
|
||||
# but the server should move to Node 24 LTS — see DEPLOYMENT.md, "Upgrading Node".
|
||||
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
|
||||
if [ "$NODE_MAJOR" -lt 22 ]; then
|
||||
echo "!!> This server runs Node $(node -v), which no longer gets security fixes. Upgrade to Node 24 LTS (DEPLOYMENT.md, \"Upgrading Node\")."
|
||||
fi
|
||||
|
||||
echo "==> Backing up the database"
|
||||
(cd backend && node -e "require('dotenv').config(); require('./src/utils/backupUtils').runBackup().then(r => console.log(' ', r.filename)).catch(e => { console.error(' Backup failed:', e.message); process.exit(1); })")
|
||||
|
||||
|
||||
+5
-4
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installs Cross Events on a fresh Ubuntu server (22.04 or 24.04): Node 20, PostgreSQL, PM2,
|
||||
# Installs Cross Events on a fresh Ubuntu server (22.04 or 24.04): Node 24 LTS, PostgreSQL, PM2,
|
||||
# nginx and an HTTPS certificate, then the app itself. Finish in the browser at /setup.
|
||||
#
|
||||
# Usage (as root, or with sudo):
|
||||
@@ -58,9 +58,10 @@ apt-get update -q
|
||||
apt-get install -y -q curl git nginx postgresql postgresql-client openssl build-essential ca-certificates gnupg
|
||||
[ "$SSL" = 0 ] || apt-get install -y -q certbot python3-certbot-nginx
|
||||
|
||||
step "Node.js 20"
|
||||
if ! command -v node >/dev/null || [ "$(node -p 'process.versions.node.split(".")[0]')" -lt 20 ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
# Node 24 is the LTS line supported until April 2028 (Node 20 reached end of life in April 2026).
|
||||
step "Node.js 24"
|
||||
if ! command -v node >/dev/null || [ "$(node -p 'process.versions.node.split(".")[0]')" -lt 24 ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
|
||||
apt-get install -y -q nodejs
|
||||
fi
|
||||
node -v
|
||||
|
||||
Reference in New Issue
Block a user