Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
254a5daebd | ||
|
|
28e31ffb99 | ||
|
|
6ee1438e23 | ||
|
|
a45417948d | ||
|
|
31ee2a9aee | ||
|
|
da67f80d6a | ||
|
|
4b7fb19f37 | ||
|
|
0fc4f83c34 | ||
|
|
59fde801f7 | ||
|
|
733b23955b | ||
|
|
d6082693c2 |
@@ -7,6 +7,78 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.51.11] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — money:** payments (`/payments`, `/payments/{id}`, receipts, refunds, donations
|
||||
assigned to registrations, stats, providers and their settings, open checkouts), payment links
|
||||
(`/registrations/{id}/payment-link` (+ `/send`), `/payment-links/{id}` — the pay page),
|
||||
`/donations/checkout`, `/me/payments`; cash-ups (`/events/{id}/cashup` and its counts, draft, close
|
||||
and reopen, `/cashup-lines/{id}/pending`, `/cashups/audit`); provider transactions; the waitlist
|
||||
(join, mine, offers by link, the event's list, offer/move/remove); sponsorship; parental consent
|
||||
(the event's list, medical notes, reminders, the consent form by link); `/form-responses`; and
|
||||
feedback (set-up, send now, the summary, the form by link).
|
||||
- **`src/v2/viaV1.ts`:** `v1Route()` — a v2 route answered by the v1 handler, in-process, behind
|
||||
v2's access checks, validation and docs. v1 JSON becomes `data`; v1 errors become v2 errors with
|
||||
v1's `code` as `reason`; piped files become downloads.
|
||||
- The v2 reference (`/docs/v2`) opens with its sections collapsed and has a filter box — with
|
||||
more than about 130 endpoints Swagger UI stopped drawing the expanded list.
|
||||
|
||||
### Changed
|
||||
|
||||
- Every money screen (payments, at the door, cash-up, consent, waitlist, feedback, forms, the pay
|
||||
and donate pages, the user dashboard) uses v2.
|
||||
|
||||
## [1.51.10] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — managing registrations:** the staff list (`GET /registrations`, paged, `unpaid`,
|
||||
`userId`), an event's registrations (`/events/{id}/registrations`), a registration in full
|
||||
(`/registrations/{id}/full`, `/me/registrations/full`), `PATCH /registrations/{id}/status`,
|
||||
registering someone (`POST /registrations/manual`), bulk actions (`/registrations/bulk`), changing
|
||||
items (`PUT /registrations/{id}/items`), the invoice, coupon codes on a registration, named
|
||||
attendees and their form links, attendee form drafts and responses
|
||||
(`/registrations/{id}/form-draft`, `/registrations/{id}/form-responses`).
|
||||
- **`src/services/registrationAdmin.ts`:** everything after a registration is made, moved from
|
||||
`registrationController.js` (now about 60 lines of v1 wrappers).
|
||||
- v2 manual registration accepts the same `notificationPreference` and `couponCode` values v1 did.
|
||||
- Frontend: `fetchAllRegistrations()` reads every page of the staff list. Registrations, at the
|
||||
door, manual registration, self-service, payments, forms, the attendee composers, reports and
|
||||
the user dashboard all use v2.
|
||||
|
||||
### Changed
|
||||
|
||||
- The roadmap gives money (payments, cashup, provider transactions, waitlist, sponsorship,
|
||||
consent, feedback) its own release, 1.51.11; the later areas move up one, and incoming webhooks
|
||||
and links are now 1.51.16.
|
||||
|
||||
## [1.51.9] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — series, season passes, deals and campuses:** `/series` (list, create, `PATCH`, delete,
|
||||
`/series/by-slug/{slug}`, `/series/{id}/compare`, `PUT`/`DELETE /series/{id}/follow`);
|
||||
`/season-passes` (list, create, `PATCH`, delete, `/season-passes/{id}/buy`),
|
||||
`/season-pass-purchases/{id}/checkout`, `/me/season-passes`; bundle deals
|
||||
(`/events/{id}/bundles`, `PATCH`/`DELETE /bundles/{id}`, `/bundles/quote`); coupon codes (`/coupons`,
|
||||
`/coupons/{id}`, `/coupons/redemptions`, `/coupons/check` — always a 200 with `valid`); campuses
|
||||
(`/campuses`, `/campuses/admin`, `/campuses/by-slug/{slug}`, `PUT /campuses/enabled`,
|
||||
`PUT /campuses/order`, create, `PATCH`, delete, `/campuses/{id}/audience`).
|
||||
- **Services:** `src/services/series.ts`, `deals.ts` and `campuses.ts` — the series, season pass,
|
||||
bundle, coupon and campus controllers are now thin v1 wrappers.
|
||||
- **v2 errors carry a service's own code as `reason`** (e.g. `CAMPUS_HAS_EVENTS`), like the shared
|
||||
middleware's codes.
|
||||
- Frontend: `apiV2Data()` — `apiV2` for screens with their own record types (path, parameters and
|
||||
body still checked; cast the answer). Series, season passes, bundles, coupons, campuses, the
|
||||
coupon field and bundle quotes on the register page all use v2.
|
||||
|
||||
### Changed
|
||||
|
||||
- Checking a coupon code on v2 answers `valid: false` with the reason instead of an error; the
|
||||
coupon field shows that reason as before.
|
||||
|
||||
## [1.51.8] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -12,6 +12,23 @@ 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.11
|
||||
|
||||
- **More groundwork for 2.0.** Payments, cash-ups, the waitlist, sponsorship, consent forms and
|
||||
feedback now run on the new version of the server's connection to the app. They work just as
|
||||
before.
|
||||
|
||||
## 1.51.10
|
||||
|
||||
- **More groundwork for 2.0.** Registrations — at the door, in the dashboards, attendee forms and
|
||||
bulk actions — now run on the new version of the server's connection to the app. They work
|
||||
just as before.
|
||||
|
||||
## 1.51.9
|
||||
|
||||
- **More groundwork for 2.0.** Event series, season passes, bundle deals, coupon codes and campuses
|
||||
now run on the new version of the server's connection to the app. They work just as before.
|
||||
|
||||
## 1.51.8
|
||||
|
||||
- **More groundwork for 2.0.** Event pages, ticket types, scanning sections, attachments, planned
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.8",
|
||||
"version": "1.51.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.8",
|
||||
"version": "1.51.11",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@cj-tech-master/excelts": "^10.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.8",
|
||||
"version": "1.51.11",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,325 +1,23 @@
|
||||
const prisma = require('../config/db');
|
||||
// Bundle deals (v1). The logic is in services/deals.ts, shared with /api/v2 (1.51.9).
|
||||
const deals = require('../services/deals');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { KINDS, describeBundle, describeBundleItems, isBundleAvailable } = require('../utils/bundles');
|
||||
const { previewBundles } = require('../utils/coupons');
|
||||
const { basketBuyer, priceBasket } = require('./couponController');
|
||||
const { scopeFor } = require('../utils/campusScope');
|
||||
|
||||
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
|
||||
const fmtR = (n) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
|
||||
const GROUP_PRICING = ['per_ticket', 'percent'];
|
||||
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error), ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
|
||||
|
||||
const optionalDate = (v, label) => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const d = new Date(v);
|
||||
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
|
||||
return d;
|
||||
};
|
||||
const actorOf = (req) => (req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null);
|
||||
|
||||
/** Standard (variant or option) price of one unit of an item. */
|
||||
function itemPrice(i) {
|
||||
const variant = i.variantId ? (i.eventOption?.variants || []).find(v => v.id === i.variantId) : null;
|
||||
return Number((variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price) || 0);
|
||||
}
|
||||
// @route GET /api/bundles/event/:eventId — with how often each has been used
|
||||
const listBundles = async (req, res) => { try { res.json(await deals.listBundles(req.params.eventId)); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/bundles/event/:eventId
|
||||
const createBundle = async (req, res) => { try { res.status(201).json(await deals.createBundle(req.params.eventId, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route PUT /api/bundles/:id
|
||||
const updateBundle = async (req, res) => { try { res.json(await deals.updateBundle(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route DELETE /api/bundles/:id (a used one is switched off instead)
|
||||
const deleteBundle = async (req, res) => { try { res.json(await deals.deleteBundle(req.params.id, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/bundles/quote — the deals a basket gets, before any coupon
|
||||
const quoteBundles = async (req, res) => { try { res.json(await deals.quoteBundles(req.body || {}, actorOf(req))); } catch (e) { fail(res, e); } };
|
||||
|
||||
/**
|
||||
* What the deal is worth at standard prices: `standardValue` is what an exact set costs
|
||||
* separately (or the dearest pick-any set); `savingText` is a short "save …" line, or
|
||||
* null when the headline already says it.
|
||||
*/
|
||||
function valueOf(bundle) {
|
||||
const kind = bundle.kind || 'exact';
|
||||
const items = bundle.items || [];
|
||||
const dearest = items.reduce((m, i) => Math.max(m, itemPrice(i)), 0);
|
||||
const price = Number(bundle.price || 0);
|
||||
if (kind === 'exact') {
|
||||
const standard = round2(items.reduce((s, i) => s + itemPrice(i) * Number(i.quantity || 0), 0));
|
||||
return { standardValue: standard, savingText: standard - price > 0.005 ? `save ${fmtR(standard - price)}` : null };
|
||||
}
|
||||
if (kind === 'mix') {
|
||||
const standard = round2(dearest * Number(bundle.setSize || 0));
|
||||
return { standardValue: standard, savingText: standard - price > 0.005 ? `save up to ${fmtR(standard - price)}` : null };
|
||||
}
|
||||
if (kind === 'free') {
|
||||
return { standardValue: null, savingText: dearest > 0 ? `save up to ${fmtR(dearest * Number(bundle.freeCount || 0))} a set` : null };
|
||||
}
|
||||
if (bundle.groupPricing === 'per_ticket' && dearest - price > 0.005) {
|
||||
return { standardValue: null, savingText: `save up to ${fmtR(dearest - price)} a ticket` };
|
||||
}
|
||||
return { standardValue: null, savingText: null };
|
||||
}
|
||||
|
||||
// Items with prices loaded, so the standard value can be shown next to the bundle price.
|
||||
const DETAIL_INCLUDE = {
|
||||
items: {
|
||||
include: {
|
||||
eventOption: { select: { id: true, name: true, price: true, variants: { select: { id: true, price: true } } } },
|
||||
variant: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function shape(bundle, now = new Date()) {
|
||||
const items = (bundle.items || []).map(({ eventOption, ...i }) => ({ ...i, eventOption: eventOption ? { id: eventOption.id, name: eventOption.name, price: eventOption.price } : null }));
|
||||
return {
|
||||
...bundle,
|
||||
items,
|
||||
summary: describeBundleItems(bundle),
|
||||
headline: describeBundle(bundle),
|
||||
...valueOf(bundle),
|
||||
available: isBundleAvailable(bundle, now),
|
||||
};
|
||||
}
|
||||
|
||||
const PRICING_FIELDS = ['kind', 'price', 'setSize', 'freeCount', 'groupPricing', 'items'];
|
||||
|
||||
/**
|
||||
* Turns a create/update body into Prisma data plus the item list. On update, fields
|
||||
* left out of the body are left alone; when any pricing field is sent, the bundle as a
|
||||
* whole (saved values plus the new ones) is checked. Items must belong to the event.
|
||||
*/
|
||||
async function parseBundleBody(body, eventId, { isCreate, current = null }) {
|
||||
const data = {};
|
||||
if (isCreate || body.name !== undefined) {
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) throw new Error('Give the bundle a name');
|
||||
if (name.length > 80) throw new Error('Keep the name under 80 characters');
|
||||
data.name = name;
|
||||
}
|
||||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||||
if (body.isActive !== undefined) data.isActive = !!body.isActive;
|
||||
if (body.order !== undefined) data.order = Number.isInteger(Number(body.order)) ? Number(body.order) : 0;
|
||||
const validFrom = optionalDate(body.validFrom, 'Available from');
|
||||
const validUntil = optionalDate(body.validUntil, 'Available until');
|
||||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||||
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Available from" must be before "Available until"');
|
||||
|
||||
let items;
|
||||
if (!isCreate && !PRICING_FIELDS.some(f => body[f] !== undefined)) return { data, items };
|
||||
|
||||
const pick = (f) => (body[f] !== undefined ? body[f] : current?.[f]);
|
||||
const kind = pick('kind') || 'exact';
|
||||
if (!KINDS.includes(kind)) throw new Error('Unknown bundle type');
|
||||
data.kind = kind;
|
||||
|
||||
const rawItems = body.items !== undefined ? body.items : (current?.items || []);
|
||||
// The same option/variant twice becomes one line.
|
||||
const merged = new Map();
|
||||
for (const it of Array.isArray(rawItems) ? rawItems : []) {
|
||||
if (!it || !it.eventOptionId) continue;
|
||||
const quantity = kind === 'exact' ? Number(it.quantity) : 1;
|
||||
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Each ticket needs a quantity of at least 1');
|
||||
const key = `${it.eventOptionId}::${it.variantId || ''}`;
|
||||
const prev = merged.get(key);
|
||||
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: kind === 'exact' ? (prev?.quantity || 0) + quantity : 1 });
|
||||
}
|
||||
items = [...merged.values()];
|
||||
if (items.length === 0) throw new Error(kind === 'exact' ? 'Add at least one ticket to the bundle' : 'Choose at least one qualifying ticket');
|
||||
|
||||
const price = Number(pick('price'));
|
||||
const setSize = Number(pick('setSize'));
|
||||
const freeCount = Number(pick('freeCount'));
|
||||
const groupPricing = pick('groupPricing');
|
||||
const priceGiven = pick('price') !== '' && pick('price') !== null && pick('price') !== undefined;
|
||||
|
||||
if (kind === 'exact') {
|
||||
if (items.reduce((s, i) => s + i.quantity, 0) < 2) throw new Error('A bundle needs at least two tickets in it');
|
||||
if (!priceGiven || !(price >= 0)) throw new Error('Enter the bundle price (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize: null, freeCount: null, groupPricing: null });
|
||||
} else if (kind === 'mix') {
|
||||
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A pick-any bundle needs at least 2 tickets per set');
|
||||
if (!priceGiven || !(price >= 0)) throw new Error('Enter the price for the set (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing: null });
|
||||
} else if (kind === 'free') {
|
||||
if (!Number.isInteger(freeCount) || freeCount < 1) throw new Error('At least one ticket must be free');
|
||||
if (!Number.isInteger(setSize) || setSize <= freeCount) throw new Error('Buy at least one ticket to get the free ones');
|
||||
Object.assign(data, { price: 0, setSize, freeCount, groupPricing: null });
|
||||
} else {
|
||||
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A group needs at least 2 tickets');
|
||||
if (!GROUP_PRICING.includes(groupPricing)) throw new Error('Choose a price per ticket or a percentage off');
|
||||
if (groupPricing === 'percent' && !(price > 0 && price <= 100)) throw new Error('Enter a percentage between 1 and 100');
|
||||
if (groupPricing === 'per_ticket' && (!priceGiven || !(price >= 0))) throw new Error('Enter the price per ticket (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing });
|
||||
}
|
||||
|
||||
const options = await prisma.eventOption.findMany({
|
||||
where: { id: { in: items.map(i => i.eventOptionId) } },
|
||||
select: { id: true, eventId: true, variants: { select: { id: true } } },
|
||||
});
|
||||
for (const i of items) {
|
||||
const row = options.find(o => o.id === i.eventOptionId);
|
||||
if (!row || row.eventId !== eventId) throw new Error('One of the tickets isn\'t part of this event');
|
||||
if (i.variantId && !row.variants.some(v => v.id === i.variantId)) throw new Error('One of the selected variants no longer exists');
|
||||
}
|
||||
return { data, items };
|
||||
}
|
||||
|
||||
async function eventOr404(eventId, res) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true } });
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
return event;
|
||||
}
|
||||
|
||||
// @desc An event's bundle deals with how often each has been used
|
||||
// @route GET /api/bundles/event/:eventId
|
||||
// @access Private/Supervisor
|
||||
const listBundles = async (req, res) => {
|
||||
try {
|
||||
await eventOr404(req.params.eventId, res);
|
||||
const bundles = await prisma.eventBundle.findMany({
|
||||
where: { eventId: req.params.eventId },
|
||||
include: DETAIL_INCLUDE,
|
||||
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const totals = await prisma.registrationBundle.groupBy({
|
||||
by: ['bundleId'],
|
||||
where: { bundle: { eventId: req.params.eventId }, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { count: true, discount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const byId = Object.fromEntries(totals.map(t => [t.bundleId, t]));
|
||||
const now = new Date();
|
||||
res.json(bundles.map(b => ({
|
||||
...shape(b, now),
|
||||
uses: byId[b.id]?._sum?.count || 0,
|
||||
registrations: byId[b.id]?._count?._all || 0,
|
||||
totalDiscount: round2(byId[b.id]?._sum?.discount || 0),
|
||||
})));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Create a bundle deal
|
||||
// @route POST /api/bundles/event/:eventId
|
||||
// @access Private/Supervisor
|
||||
const createBundle = async (req, res) => {
|
||||
try {
|
||||
const event = await eventOr404(req.params.eventId, res);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseBundleBody(req.body || {}, event.id, { isCreate: true });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
const bundle = await prisma.eventBundle.create({
|
||||
data: { ...parsed.data, eventId: event.id, items: { create: parsed.items } },
|
||||
include: DETAIL_INCLUDE,
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_created',
|
||||
targetType: 'EventBundle', targetId: bundle.id,
|
||||
metadata: { eventId: event.id, name: bundle.name, kind: bundle.kind, deal: describeBundle(bundle) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.status(201).json({ ...shape(bundle), uses: 0, registrations: 0, totalDiscount: 0 });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Edit a bundle deal (including switching it off). Registrations that already
|
||||
// have it are only re-worked when their own items change.
|
||||
// @route PUT /api/bundles/:id
|
||||
// @access Private/Supervisor
|
||||
const updateBundle = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id }, include: { items: true } });
|
||||
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseBundleBody(req.body || {}, existing.eventId, { isCreate: false, current: existing });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
const bundle = await prisma.eventBundle.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...parsed.data, ...(parsed.items ? { items: { deleteMany: {}, create: parsed.items } } : {}) },
|
||||
include: DETAIL_INCLUDE,
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_updated',
|
||||
targetType: 'EventBundle', targetId: bundle.id,
|
||||
metadata: { eventId: bundle.eventId, name: bundle.name, changedKeys: Object.keys(req.body || {}) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json(shape(bundle));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Delete a bundle deal. One that's been used is switched off instead, so the
|
||||
// registrations that got it keep their price.
|
||||
// @route DELETE /api/bundles/:id
|
||||
// @access Private/Supervisor
|
||||
const deleteBundle = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
|
||||
const used = await prisma.registrationBundle.count({ where: { bundleId: existing.id } });
|
||||
if (used > 0) {
|
||||
await prisma.eventBundle.update({ where: { id: existing.id }, data: { isActive: false } });
|
||||
} else {
|
||||
await prisma.eventBundle.delete({ where: { id: existing.id } });
|
||||
}
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_deleted',
|
||||
targetType: 'EventBundle', targetId: existing.id,
|
||||
metadata: { eventId: existing.eventId, name: existing.name, switchedOff: used > 0 },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json(used > 0
|
||||
? { switchedOff: true, message: 'This bundle has been used, so it\'s been switched off instead. Registrations that got it keep their price.' }
|
||||
: { deleted: true, message: 'Bundle deleted' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The bundle deals a basket gets, before any coupon code
|
||||
// @route POST /api/bundles/quote
|
||||
// @access Public. Body: { eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
|
||||
const quoteBundles = async (req, res) => {
|
||||
try {
|
||||
const { eventId, options, forUserId } = req.body || {};
|
||||
if (!eventId || !Array.isArray(options)) { res.status(400); throw new Error('eventId and options are required'); }
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
|
||||
});
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
const { buyer } = await basketBuyer(req, forUserId);
|
||||
const lines = await priceBasket(event, options, buyer);
|
||||
const subtotal = round2(lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0));
|
||||
const bundles = await previewBundles(eventId, lines);
|
||||
res.json({
|
||||
subtotal,
|
||||
bundleDiscount: bundles.discount,
|
||||
bundles: bundles.applications,
|
||||
total: round2(Math.max(0, subtotal - bundles.discount)),
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
/** The deals to show on an event's page: available bundles, without internal fields. */
|
||||
async function publicBundlesForEvent(eventId) {
|
||||
const bundles = await prisma.eventBundle.findMany({
|
||||
where: { eventId, isActive: true },
|
||||
include: DETAIL_INCLUDE,
|
||||
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const now = new Date();
|
||||
return bundles.filter(b => isBundleAvailable(b, now)).map(b => {
|
||||
const s = shape(b, now);
|
||||
return {
|
||||
id: s.id, name: s.name, description: s.description, validUntil: s.validUntil,
|
||||
kind: s.kind, price: s.price, setSize: s.setSize, freeCount: s.freeCount, groupPricing: s.groupPricing,
|
||||
summary: s.summary, headline: s.headline, standardValue: s.standardValue, savingText: s.savingText,
|
||||
items: s.items.map(i => ({ eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: i.quantity })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles, publicBundlesForEvent };
|
||||
module.exports = { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles, publicBundlesForEvent: deals.publicBundlesForEvent };
|
||||
|
||||
@@ -1,193 +1,28 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
// Campuses (v1). The logic is in services/campuses.ts, shared with /api/v2 (1.51.9).
|
||||
const campuses = require('../services/campuses');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
|
||||
const { SETTING_KEY, CAMPUS_PUBLIC_SELECT, campusesEnabled, cleanCampusInput, campusAudienceWhere } = require('../utils/campuses');
|
||||
const { scopeFor } = require('../utils/campusScope');
|
||||
|
||||
const audit = (req, action, campus, metadata) => logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'Campus', targetId: campus?.id, metadata: { name: campus?.name, ...metadata }, ip: getClientIp(req),
|
||||
});
|
||||
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message, ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
|
||||
|
||||
// A slug that's already taken → a message instead of a Prisma error.
|
||||
function friendlyError(e) {
|
||||
if (e?.code === 'P2002') return 'Another campus already uses that web address — choose a different one.';
|
||||
return e.message || safeErrorMessage(e);
|
||||
}
|
||||
|
||||
const ADMIN_INCLUDE = {
|
||||
notifyRecipients: { select: { id: true, name: true, email: true } },
|
||||
_count: { select: { events: true, homeOf: true, scopedStaff: true } },
|
||||
};
|
||||
|
||||
// @desc Whether campuses are on, and the active campuses (public site filters, event cards)
|
||||
// @route GET /api/campuses
|
||||
// @access Public
|
||||
const getCampuses = async (req, res) => {
|
||||
try {
|
||||
const enabled = await campusesEnabled();
|
||||
const campuses = enabled
|
||||
? await prisma.campus.findMany({ where: { archivedAt: null }, select: CAMPUS_PUBLIC_SELECT, orderBy: [{ order: 'asc' }, { name: 'asc' }] })
|
||||
: [];
|
||||
res.json({ enabled, campuses });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc A campus landing page: the campus (active only)
|
||||
// @route GET /api/campuses/by-slug/:slug
|
||||
// @access Public
|
||||
const getCampusBySlug = async (req, res) => {
|
||||
try {
|
||||
const campus = (await campusesEnabled())
|
||||
? await prisma.campus.findFirst({ where: { slug: String(req.params.slug), archivedAt: null }, select: CAMPUS_PUBLIC_SELECT })
|
||||
: null;
|
||||
if (!campus) return res.status(404).json({ message: 'Campus not found' });
|
||||
res.json(campus);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc All campuses with archived ones, event counts and summary recipients (Site settings → Campuses)
|
||||
// @route GET /api/campuses/admin
|
||||
// @access Private/Admin
|
||||
const getCampusesAdmin = async (req, res) => {
|
||||
try {
|
||||
const [enabled, campuses] = await Promise.all([
|
||||
campusesEnabled(),
|
||||
prisma.campus.findMany({ include: ADMIN_INCLUDE, orderBy: [{ archivedAt: { sort: 'asc', nulls: 'first' } }, { order: 'asc' }, { name: 'asc' }] }),
|
||||
]);
|
||||
res.json({ enabled, campuses });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Turn campuses on or off (off hides campus fields and filters; nothing is deleted)
|
||||
// @route PUT /api/campuses/settings body: { enabled }
|
||||
// @access Private/Admin
|
||||
const updateCampusSettings = async (req, res) => {
|
||||
try {
|
||||
const enabled = req.body?.enabled === true;
|
||||
await prisma.appSetting.upsert({ where: { key: SETTING_KEY }, update: { value: String(enabled) }, create: { key: SETTING_KEY, value: String(enabled) } });
|
||||
invalidateSettingsCache();
|
||||
await warmCache();
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'campuses_toggled', targetType: 'AppSetting', metadata: { enabled }, ip: getClientIp(req) });
|
||||
res.json({ enabled });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Add a campus (added at the end of the list)
|
||||
// @route POST /api/campuses
|
||||
// @access Private/Admin
|
||||
const createCampus = async (req, res) => {
|
||||
try {
|
||||
const { data, error } = cleanCampusInput(req.body);
|
||||
if (error) return res.status(400).json({ message: error });
|
||||
const last = await prisma.campus.aggregate({ _max: { order: true } });
|
||||
const campus = await prisma.campus.create({ data: { ...data, order: (last._max.order ?? -1) + 1 }, include: ADMIN_INCLUDE });
|
||||
audit(req, 'campus_created', campus, { slug: campus.slug });
|
||||
res.status(201).json(campus);
|
||||
} catch (e) {
|
||||
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Change a campus: details, summary recipients, archived or not
|
||||
// @route PUT /api/campuses/:id body: campus fields, notifyRecipientIds?, archived?
|
||||
// @access Private/Admin
|
||||
const updateCampus = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.campus.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) return res.status(404).json({ message: 'Campus not found' });
|
||||
const { data, error } = cleanCampusInput(req.body, { partial: true });
|
||||
if (error) return res.status(400).json({ message: error });
|
||||
|
||||
if (Array.isArray(req.body?.notifyRecipientIds)) {
|
||||
const ids = req.body.notifyRecipientIds.filter(x => typeof x === 'string' && x);
|
||||
// Summaries go to people who run events — never to ordinary users.
|
||||
const staff = await prisma.user.findMany({ where: { id: { in: ids }, role: { in: ['admin', 'supervisor', 'staff'] } }, select: { id: true } });
|
||||
data.notifyRecipients = { set: staff.map(u => ({ id: u.id })) };
|
||||
}
|
||||
let archiveChange = null;
|
||||
if (req.body?.archived !== undefined) {
|
||||
const archived = req.body.archived === true;
|
||||
if (archived !== !!existing.archivedAt) {
|
||||
data.archivedAt = archived ? new Date() : null;
|
||||
archiveChange = archived;
|
||||
}
|
||||
}
|
||||
|
||||
const campus = await prisma.campus.update({ where: { id: existing.id }, data, include: ADMIN_INCLUDE });
|
||||
if (archiveChange !== null) audit(req, 'campus_archived', campus, { archived: archiveChange });
|
||||
const changed = Object.keys(data).filter(k => k !== 'archivedAt');
|
||||
if (changed.length) audit(req, 'campus_updated', campus, { changed });
|
||||
res.json(campus);
|
||||
} catch (e) {
|
||||
res.status(e?.code === 'P2002' ? 409 : 400).json({ message: friendlyError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Put the campuses in this order (filters and lists follow it)
|
||||
// @route PUT /api/campuses/order body: { ids: [...] }
|
||||
// @access Private/Admin
|
||||
const reorderCampuses = async (req, res) => {
|
||||
try {
|
||||
const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(x => typeof x === 'string') : [];
|
||||
if (!ids.length) return res.status(400).json({ message: 'Send the campus ids in their new order.' });
|
||||
await prisma.$transaction(ids.map((id, order) => prisma.campus.updateMany({ where: { id }, data: { order } })));
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Delete a campus that has no events (otherwise archive it). Home campus is cleared.
|
||||
// @route DELETE /api/campuses/:id
|
||||
// @access Private/Admin
|
||||
const deleteCampus = async (req, res) => {
|
||||
try {
|
||||
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true, scopedStaff: true } } } });
|
||||
if (!campus) return res.status(404).json({ message: 'Campus not found' });
|
||||
// Deleting would drop their limit and leave them unscoped (seeing every campus).
|
||||
if (campus._count.scopedStaff > 0) {
|
||||
return res.status(409).json({ code: 'CAMPUS_HAS_SCOPED_STAFF', message: `${campus._count.scopedStaff} staff member${campus._count.scopedStaff === 1 ? ' is' : 's are'} limited to ${campus.name}. Change their campuses in Admin → Users first, or archive the campus.` });
|
||||
}
|
||||
if (campus._count.events > 0) {
|
||||
return res.status(409).json({ code: 'CAMPUS_HAS_EVENTS', message: `${campus.name} has ${campus._count.events} event${campus._count.events === 1 ? '' : 's'}, so it can't be deleted. Archive it instead.` });
|
||||
}
|
||||
await prisma.campus.delete({ where: { id: campus.id } });
|
||||
audit(req, 'campus_deleted', campus, { slug: campus.slug });
|
||||
res.json({ message: 'Campus deleted' });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc "Everyone at campus X" for the email/WhatsApp composers: home campus X, plus (with
|
||||
// ?registrants=true) people registered for X's events in the last 12 months. The
|
||||
// composer sends to these ids as usual, so news consent is still checked on sending.
|
||||
// @route GET /api/campuses/:id/audience?registrants=true
|
||||
// @access Private/Supervisor
|
||||
const getCampusAudience = async (req, res) => {
|
||||
try {
|
||||
const campus = await prisma.campus.findUnique({ where: { id: req.params.id }, select: { id: true, name: true } });
|
||||
if (!campus) return res.status(404).json({ message: 'Campus not found' });
|
||||
const scope = await require('../utils/campusScope').scopeFor(req);
|
||||
if (scope && !scope.campusIds.includes(campus.id)) return res.status(404).json({ message: 'Campus not found' });
|
||||
const users = await prisma.user.findMany({
|
||||
where: campusAudienceWhere(campus.id, { includeRegistrants: req.query.registrants === 'true' }),
|
||||
select: { id: true, name: true, email: true, phoneNumber: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
res.json({ campus, users });
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
// @route GET /api/campuses — whether they're on, and the active ones
|
||||
const getCampuses = async (req, res) => { try { res.json(await campuses.publicCampuses()); } catch (e) { fail(res, e); } };
|
||||
// @route GET /api/campuses/by-slug/:slug
|
||||
const getCampusBySlug = async (req, res) => { try { res.json(await campuses.campusBySlug(req.params.slug)); } catch (e) { fail(res, e); } };
|
||||
// @route GET /api/campuses/admin
|
||||
const getCampusesAdmin = async (req, res) => { try { res.json(await campuses.adminCampuses()); } catch (e) { fail(res, e); } };
|
||||
// @route PUT /api/campuses/settings { enabled }
|
||||
const updateCampusSettings = async (req, res) => { try { res.json(await campuses.setCampusesEnabled(req.body?.enabled === true, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/campuses
|
||||
const createCampus = async (req, res) => { try { res.status(201).json(await campuses.createCampus(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route PUT /api/campuses/:id
|
||||
const updateCampus = async (req, res) => { try { res.json(await campuses.updateCampus(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route PUT /api/campuses/order { ids }
|
||||
const reorderCampuses = async (req, res) => { try { await campuses.reorderCampuses(req.body?.ids); res.json({ ok: true }); } catch (e) { fail(res, e); } };
|
||||
// @route DELETE /api/campuses/:id
|
||||
const deleteCampus = async (req, res) => { try { await campuses.deleteCampus(req.params.id, req.user, await ctxOf(req)); res.json({ message: 'Campus deleted' }); } catch (e) { fail(res, e); } };
|
||||
// @route GET /api/campuses/:id/audience?registrants=true
|
||||
const getCampusAudience = async (req, res) => { try { res.json(await campuses.campusAudience(req.params.id, req.query.registrants === 'true', await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
|
||||
module.exports = { getCampusAudience, getCampuses, getCampusBySlug, getCampusesAdmin, updateCampusSettings, createCampus, updateCampus, reorderCampuses, deleteCampus };
|
||||
|
||||
@@ -1,392 +1,37 @@
|
||||
const prisma = require('../config/db');
|
||||
// Coupon codes (v1). The logic is in services/deals.ts, shared with /api/v2 (1.51.9).
|
||||
const deals = require('../services/deals');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { listWhere } = require('../utils/campusScope');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
|
||||
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
|
||||
const { describeBundle } = require('../utils/bundles');
|
||||
const { scopeFor, couponAllowed, listWhere } = require('../utils/campusScope');
|
||||
const { scopeFor } = require('../utils/campusScope');
|
||||
|
||||
const TYPES = ['percent', 'fixed', 'comp'];
|
||||
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
|
||||
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
|
||||
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error), ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
|
||||
|
||||
const COUPON_DETAIL_INCLUDE = {
|
||||
events: { include: { event: { select: { id: true, title: true, startDate: true, campusId: true } } } },
|
||||
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
|
||||
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
};
|
||||
const actorOf = (req) => (req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null);
|
||||
const dateOrNull = (v) => { if (!v) return null; const d = new Date(v); return isNaN(d.getTime()) ? null : d; };
|
||||
|
||||
// Campus-scoped supervisors: only coupons limited to events at their campuses (couponAllowed).
|
||||
const couponCampuses = coupon => (coupon.events || []).map(ce => ce.event?.campusId ?? null);
|
||||
async function eventIdsAllowed(scope, eventIds) {
|
||||
if (!scope) return true;
|
||||
if (!eventIds || eventIds.length === 0) return false; // a coupon for every event
|
||||
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { campusId: true } });
|
||||
return events.length === eventIds.length && couponAllowed(scope, events.map(e => e.campusId));
|
||||
}
|
||||
const SCOPED_COUPON_MSG = 'Supervisors for some campuses can only make coupons limited to events at their campuses.';
|
||||
|
||||
const optionalDate = (v, label) => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const d = new Date(v);
|
||||
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
|
||||
return d;
|
||||
};
|
||||
|
||||
const optionalInt = (v, label, min = 0) => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < min) throw new Error(`${label} must be a whole number of at least ${min}`);
|
||||
return n;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns a create/update body into Prisma data plus the relation lists. On update,
|
||||
* fields left out of the body are left alone.
|
||||
*/
|
||||
async function parseCouponBody(body, { isCreate }) {
|
||||
const data = {};
|
||||
if (isCreate || body.code !== undefined) {
|
||||
const code = normalizeCode(body.code);
|
||||
if (!/^[A-Z0-9_-]{3,32}$/.test(code)) {
|
||||
throw new Error('Codes are 3–32 letters, numbers, hyphens or underscores');
|
||||
}
|
||||
data.code = code;
|
||||
}
|
||||
if (isCreate || body.type !== undefined) {
|
||||
if (!TYPES.includes(body.type)) throw new Error("Discount type must be 'percent', 'fixed' or 'comp'");
|
||||
data.type = body.type;
|
||||
}
|
||||
if (isCreate || body.value !== undefined) {
|
||||
const type = data.type || body.type;
|
||||
const value = type === 'comp' ? 0 : Number(body.value);
|
||||
if (type !== 'comp' && !(value > 0)) throw new Error('Enter a discount value greater than zero');
|
||||
if (type === 'percent' && value > 100) throw new Error('A percentage discount can\'t be more than 100');
|
||||
data.value = value;
|
||||
}
|
||||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||||
if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird;
|
||||
if (body.active !== undefined) data.active = !!body.active;
|
||||
const validFrom = optionalDate(body.validFrom, 'Valid from');
|
||||
const validUntil = optionalDate(body.validUntil, 'Valid until');
|
||||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||||
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Valid from" must be before "Valid until"');
|
||||
const maxUses = optionalInt(body.maxUses, 'Max total uses', 1);
|
||||
const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1);
|
||||
const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1);
|
||||
if (maxUses !== undefined) data.maxUses = maxUses;
|
||||
if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser;
|
||||
if (minQuantity !== undefined) data.minQuantity = minQuantity;
|
||||
if (body.restrictRoles !== undefined) {
|
||||
const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : [];
|
||||
if (roles.some(r => !ROLES.includes(r))) throw new Error('Unknown role in restrictions');
|
||||
data.restrictRoles = roles;
|
||||
}
|
||||
if (body.restrictDomain !== undefined) {
|
||||
const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
|
||||
if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) throw new Error('Enter an email domain like church.org');
|
||||
data.restrictDomain = domain || null;
|
||||
}
|
||||
|
||||
const eventIds = body.eventIds !== undefined ? [...new Set((body.eventIds || []).filter(Boolean))] : undefined;
|
||||
const scopes = body.options !== undefined
|
||||
? (body.options || []).filter(o => o && o.eventOptionId).map(o => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null }))
|
||||
: undefined;
|
||||
const userIds = body.userIds !== undefined ? [...new Set((body.userIds || []).filter(Boolean))] : undefined;
|
||||
|
||||
if (scopes && scopes.length > 0) {
|
||||
const optionRows = await prisma.eventOption.findMany({
|
||||
where: { id: { in: scopes.map(s => s.eventOptionId) } },
|
||||
select: { id: true, eventId: true, variants: { select: { id: true } } },
|
||||
});
|
||||
for (const s of scopes) {
|
||||
const row = optionRows.find(o => o.id === s.eventOptionId);
|
||||
if (!row) throw new Error('One of the selected ticket options no longer exists');
|
||||
if (s.variantId && !row.variants.some(v => v.id === s.variantId)) throw new Error('One of the selected variants no longer exists');
|
||||
// A scoped option implies its event, so the code can't be used elsewhere by mistake.
|
||||
if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) {
|
||||
throw new Error('Selected ticket options must belong to the selected events');
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data, eventIds, scopes, userIds };
|
||||
}
|
||||
|
||||
function relationWrites({ eventIds, scopes, userIds }, isCreate) {
|
||||
const out = {};
|
||||
if (eventIds !== undefined) out.events = { ...(isCreate ? {} : { deleteMany: {} }), create: eventIds.map(eventId => ({ eventId })) };
|
||||
if (scopes !== undefined) out.options = { ...(isCreate ? {} : { deleteMany: {} }), create: scopes };
|
||||
if (userIds !== undefined) out.allowedUsers = { ...(isCreate ? {} : { deleteMany: {} }), create: userIds.map(userId => ({ userId })) };
|
||||
return out;
|
||||
}
|
||||
|
||||
// @desc List coupons with their use counts
|
||||
// @route GET /api/coupons
|
||||
// @access Private/Supervisor
|
||||
const listCoupons = async (req, res) => {
|
||||
try {
|
||||
const scope = await scopeFor(req);
|
||||
const coupons = (await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } }))
|
||||
.filter(c => couponAllowed(scope, couponCampuses(c)));
|
||||
const totals = await prisma.couponRedemption.groupBy({
|
||||
by: ['couponId'],
|
||||
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
|
||||
_count: { _all: true },
|
||||
_sum: { discountAmount: true },
|
||||
});
|
||||
const byId = Object.fromEntries(totals.map(t => [t.couponId, t]));
|
||||
res.json(coupons.map(c => ({
|
||||
...c,
|
||||
summary: describeCoupon(c),
|
||||
uses: byId[c.id]?._count?._all || 0,
|
||||
totalDiscount: byId[c.id]?._sum?.discountAmount || 0,
|
||||
})));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get one coupon with its redemptions
|
||||
// @route GET /api/coupons/:id
|
||||
// @access Private/Supervisor
|
||||
const getCoupon = async (req, res) => {
|
||||
try {
|
||||
const coupon = await prisma.coupon.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
...COUPON_DETAIL_INCLUDE,
|
||||
redemptions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
appliedBy: { select: { id: true, name: true } },
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!coupon || !couponAllowed(await scopeFor(req), couponCampuses(coupon))) { res.status(404); throw new Error('Coupon not found'); }
|
||||
res.json({ ...coupon, summary: describeCoupon(coupon) });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Create a coupon
|
||||
// @route POST /api/coupons
|
||||
// @access Private/Supervisor
|
||||
const createCoupon = async (req, res) => {
|
||||
try {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseCouponBody(req.body || {}, { isCreate: true });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
if (!(await eventIdsAllowed(await scopeFor(req), parsed.eventIds))) { res.status(403); throw new Error(SCOPED_COUPON_MSG); }
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
|
||||
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
|
||||
}
|
||||
const coupon = await prisma.coupon.create({
|
||||
data: { ...parsed.data, createdById: req.user.id, ...relationWrites(parsed, true) },
|
||||
include: COUPON_DETAIL_INCLUDE,
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'coupon_created',
|
||||
targetType: 'Coupon', targetId: coupon.id,
|
||||
metadata: { code: coupon.code, type: coupon.type, value: coupon.value },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.status(201).json({ ...coupon, summary: describeCoupon(coupon), uses: 0, totalDiscount: 0 });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Update a coupon (including turning it off). Discounts already applied keep
|
||||
// their locked amount.
|
||||
// @route PUT /api/coupons/:id
|
||||
// @access Private/Supervisor
|
||||
const updateCoupon = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id }, include: { events: { include: { event: { select: { campusId: true } } } } } });
|
||||
const scope = await scopeFor(req);
|
||||
if (!existing || !couponAllowed(scope, couponCampuses(existing))) { res.status(404); throw new Error('Coupon not found'); }
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseCouponBody({ type: existing.type, ...req.body }, { isCreate: false });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
if (parsed.eventIds !== undefined && !(await eventIdsAllowed(scope, parsed.eventIds))) { res.status(403); throw new Error(SCOPED_COUPON_MSG); }
|
||||
if (parsed.data.code && parsed.data.code !== existing.code) {
|
||||
const used = await prisma.couponRedemption.count({ where: { couponId: existing.id } });
|
||||
if (used > 0) { res.status(400); throw new Error('This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.'); }
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
|
||||
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
|
||||
}
|
||||
}
|
||||
const coupon = await prisma.coupon.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...parsed.data, ...relationWrites(parsed, false) },
|
||||
include: COUPON_DETAIL_INCLUDE,
|
||||
});
|
||||
const disabled = existing.active && coupon.active === false;
|
||||
const changedKeys = Object.keys(req.body || {});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role,
|
||||
action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated',
|
||||
targetType: 'Coupon', targetId: coupon.id,
|
||||
metadata: { code: coupon.code, changedKeys },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
const { usesTotal } = await countCouponUses(coupon.id, null);
|
||||
res.json({ ...coupon, summary: describeCoupon(coupon), uses: usesTotal });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Who a basket is being priced for: the logged-in buyer, or, for staff, the attendee
|
||||
* (`forUserId`) or a walk-in with no account yet (`forUserId: null`).
|
||||
*/
|
||||
async function basketBuyer(req, forUserId) {
|
||||
const isStaff = !!(req.user && STAFF_ROLES.includes(req.user.role));
|
||||
let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
|
||||
if (isStaff && forUserId) {
|
||||
buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
|
||||
} else if (isStaff && forUserId === null) {
|
||||
buyer = null; // a walk-in without an account yet
|
||||
}
|
||||
return { buyer, isStaff };
|
||||
}
|
||||
|
||||
/** Prices a basket the same way registration will, as coupon/bundle lines. */
|
||||
async function priceBasket(event, options, buyer) {
|
||||
const registrationOptions = [];
|
||||
// A "pick sessions" line counts once per chosen session (an all-sessions ticket with
|
||||
// sessions ticked is still one line at one price).
|
||||
const { isPickOption } = require('../utils/sessions');
|
||||
const perSession = (o) => isPickOption(event, event.eventOptions.find(x => x.id === o.eventOptionId));
|
||||
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length && perSession(o) ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
|
||||
for (const o of expanded) {
|
||||
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
|
||||
const qty = Number(o.quantity || 0);
|
||||
if (!eo || qty < 1) continue;
|
||||
const resolved = o.variantId
|
||||
? await resolveVariantTierPrice(eo, o.variantId, qty, buyer)
|
||||
: await resolveOptionPrice(eo, qty, buyer);
|
||||
registrationOptions.push({
|
||||
eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo,
|
||||
tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }],
|
||||
});
|
||||
}
|
||||
return buildCouponLines({ registrationOptions });
|
||||
}
|
||||
|
||||
// @desc Check a code against the items someone is about to register for
|
||||
// @route POST /api/coupons/validate
|
||||
// @access Public (login needed for restricted codes)
|
||||
// Body: { code, eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
|
||||
// Staff can pass forUserId to check on an attendee's behalf, or leave it out for a new
|
||||
// attendee — restricted codes are then checked when the registration is saved.
|
||||
// @route GET /api/coupons — with use counts
|
||||
const listCoupons = async (req, res) => { try { res.json(await deals.listCoupons(await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route GET /api/coupons/:id — with its redemptions
|
||||
const getCoupon = async (req, res) => { try { res.json(await deals.getCoupon(req.params.id, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/coupons
|
||||
const createCoupon = async (req, res) => { try { res.status(201).json(await deals.createCoupon(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route PUT /api/coupons/:id
|
||||
const updateCoupon = async (req, res) => { try { res.json(await deals.updateCoupon(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/coupons/validate — a code against a basket ({ valid: false } answers 400, as before)
|
||||
const validateCode = async (req, res) => {
|
||||
try {
|
||||
const { code, eventId, options, forUserId } = req.body || {};
|
||||
if (!code || !eventId || !Array.isArray(options) || options.length === 0) {
|
||||
res.status(400); throw new Error('code, eventId and options are required');
|
||||
}
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
|
||||
});
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
|
||||
const { buyer, isStaff } = await basketBuyer(req, forUserId);
|
||||
const lines = await priceBasket(event, options, buyer);
|
||||
const subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||||
|
||||
try {
|
||||
const { coupon, discount, bundles } = await previewCoupon({
|
||||
code, eventId, user: buyer, lines,
|
||||
deferRestrictions: isStaff && !buyer,
|
||||
});
|
||||
res.json({
|
||||
valid: true,
|
||||
code: coupon.code,
|
||||
type: coupon.type,
|
||||
value: coupon.value,
|
||||
summary: describeCoupon(coupon),
|
||||
description: coupon.description,
|
||||
discount,
|
||||
bundleDiscount: bundles.discount,
|
||||
bundles: bundles.applications,
|
||||
subtotal,
|
||||
total: Math.max(0, subtotal - bundles.discount - discount),
|
||||
});
|
||||
} catch (e) {
|
||||
if (!e.isCouponError) throw e;
|
||||
res.status(400).json({ valid: false, message: e.message });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
const result = await deals.checkCode(req.body || {}, actorOf(req));
|
||||
res.status(result.valid ? 200 : 400).json(result);
|
||||
} catch (e) { fail(res, e); }
|
||||
};
|
||||
|
||||
// @desc Coupon and bundle-deal uses for the Discounts report
|
||||
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to=
|
||||
// @access Private/Supervisor
|
||||
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to= — the Discounts report
|
||||
const listRedemptions = async (req, res) => {
|
||||
try {
|
||||
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const from = req.query.from ? new Date(req.query.from) : null;
|
||||
const to = req.query.to ? new Date(req.query.to) : null;
|
||||
const scopeEvent = await listWhere(req, 'event'); // { event: … } or {}
|
||||
const where = {
|
||||
releasedAt: null,
|
||||
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...scopeEvent },
|
||||
...(from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}),
|
||||
};
|
||||
const rows = await prisma.couponRedemption.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
coupon: { select: { id: true, code: true, type: true, value: true } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
appliedBy: { select: { id: true, name: true } },
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
// Bundle deals, in the same shape. Their rows are rebuilt whenever a registration's
|
||||
// discounts are recalculated, so dates go by when the registration was made.
|
||||
const regDate = from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {};
|
||||
const bundleRows = await prisma.registrationBundle.findMany({
|
||||
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate, ...scopeEvent } },
|
||||
include: {
|
||||
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
|
||||
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
res.json([
|
||||
...rows.map(r => ({ ...r, kind: 'coupon', uses: 1, summary: describeCoupon(r.coupon) })),
|
||||
...bundleRows.map(b => ({
|
||||
id: b.id,
|
||||
kind: 'bundle',
|
||||
uses: b.count,
|
||||
discountAmount: b.discount,
|
||||
createdAt: b.registration.createdAt,
|
||||
summary: describeBundle(b.bundle),
|
||||
coupon: { id: b.bundleId, code: b.bundle.name },
|
||||
user: b.registration.user,
|
||||
appliedBy: null,
|
||||
registration: { id: b.registration.id, status: b.registration.status, event: b.registration.event },
|
||||
})),
|
||||
]);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
res.json(await deals.listRedemptions({ eventIds, from: dateOrNull(req.query.from), to: dateOrNull(req.query.to) }, await listWhere(req, 'event')));
|
||||
} catch (e) { fail(res, e); }
|
||||
};
|
||||
|
||||
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions, basketBuyer, priceBasket };
|
||||
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,146 +1,24 @@
|
||||
const prisma = require('../config/db');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
// Season passes (v1). The logic is in services/series.ts, shared with /api/v2 (1.51.9).
|
||||
const series = require('../services/series');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { scopeFor, canManage } = require('../utils/campusScope');
|
||||
const passes = require('../utils/seasonPass');
|
||||
const { scopeFor } = require('../utils/campusScope');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message });
|
||||
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message, ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
|
||||
|
||||
const audit = (req, pass, action, metadata = {}) => logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'season_pass_changed', targetType: 'SeasonPass', targetId: pass.id,
|
||||
metadata: { action, name: pass.name, ...metadata }, ip: getClientIp(req),
|
||||
});
|
||||
|
||||
/** Campus-scoped supervisors may only put their own campuses' events on a pass. */
|
||||
async function assertCampuses(req, campusIds) {
|
||||
const scope = await scopeFor(req);
|
||||
if (scope && campusIds.some(c => !canManage(scope, c))) {
|
||||
const err = new Error('You can only put events from your own campuses on a pass (not events for all campuses).');
|
||||
err.statusCode = 403;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const frontendUrl = () => String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
|
||||
// @desc A series' passes with their events, shares, and how many were sold
|
||||
// @route GET /api/season-passes?seriesId=
|
||||
// @access Private/Supervisor
|
||||
const listPasses = async (req, res) => {
|
||||
try {
|
||||
const where = req.query.seriesId ? { seriesId: String(req.query.seriesId) } : {};
|
||||
const rows = await prisma.seasonPass.findMany({ where, orderBy: { createdAt: 'asc' }, include: passes.PASS_INCLUDE });
|
||||
const sold = await prisma.seasonPassPurchase.groupBy({ by: ['passId', 'status'], where: { passId: { in: rows.map(r => r.id) } }, _sum: { quantity: true } });
|
||||
res.json(rows.map(p => ({
|
||||
...passes.passView(p),
|
||||
sold: sold.filter(s => s.passId === p.id && s.status === 'paid').reduce((n, s) => n + (s._sum.quantity || 0), 0),
|
||||
unpaid: sold.filter(s => s.passId === p.id && s.status === 'pending').reduce((n, s) => n + (s._sum.quantity || 0), 0),
|
||||
})));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @route POST /api/season-passes { seriesId, name, description?, price, saleEndsAt?, items: [{ eventId, eventOptionId, variantId? }] }
|
||||
// @access Private/Supervisor
|
||||
const createPass = async (req, res) => {
|
||||
try {
|
||||
const seriesId = String(req.body?.seriesId || '');
|
||||
if (!(await prisma.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true } }))) return res.status(404).json({ message: 'Series not found' });
|
||||
const { data, items } = await passes.cleanPassInput(req.body, seriesId);
|
||||
await assertCampuses(req, items.map(i => i.campusId));
|
||||
const pass = await prisma.seasonPass.create({
|
||||
data: { ...data, seriesId, items: { create: items.map(({ campusId, ...i }) => i) } },
|
||||
});
|
||||
audit(req, pass, 'created', { events: items.length, price: pass.price });
|
||||
res.status(201).json(passes.passView(await passes.loadPass(pass.id)));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Change a pass. Its events can only change while nobody has bought it.
|
||||
// @route PUT /api/season-passes/:id
|
||||
// @access Private/Supervisor
|
||||
const updatePass = async (req, res) => {
|
||||
try {
|
||||
const current = await passes.loadPass(req.params.id);
|
||||
await assertCampuses(req, current.items.map(i => i.event.campusId));
|
||||
const { data, items } = await passes.cleanPassInput(req.body, current.seriesId, current);
|
||||
const bought = await prisma.seasonPassPurchase.count({ where: { passId: current.id } });
|
||||
if (bought > 0 && (items || (data.price !== undefined && data.price !== current.price))) {
|
||||
return res.status(409).json({ message: 'People have bought this pass, so its events and price can\'t change. Switch it off and make a new one instead.' });
|
||||
}
|
||||
if (items) await assertCampuses(req, items.map(i => i.campusId));
|
||||
await prisma.$transaction(async tx => {
|
||||
await tx.seasonPass.update({ where: { id: current.id }, data });
|
||||
if (items) {
|
||||
await tx.seasonPassItem.deleteMany({ where: { passId: current.id } });
|
||||
await tx.seasonPassItem.createMany({ data: items.map(({ campusId, ...i }) => ({ ...i, passId: current.id })) });
|
||||
}
|
||||
});
|
||||
audit(req, current, 'updated', { changedKeys: [...Object.keys(data), ...(items ? ['items'] : [])] });
|
||||
res.json(passes.passView(await passes.loadPass(current.id)));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Delete a pass nobody has bought (otherwise switch it off)
|
||||
// @route DELETE /api/season-passes/:id
|
||||
// @access Private/Supervisor
|
||||
const deletePass = async (req, res) => {
|
||||
try {
|
||||
const current = await passes.loadPass(req.params.id);
|
||||
await assertCampuses(req, current.items.map(i => i.event.campusId));
|
||||
if (await prisma.seasonPassPurchase.count({ where: { passId: current.id } })) {
|
||||
return res.status(409).json({ message: 'People have bought this pass, so it can\'t be deleted — switch it off instead.' });
|
||||
}
|
||||
await prisma.seasonPass.delete({ where: { id: current.id } });
|
||||
audit(req, current, 'deleted');
|
||||
res.json({ ok: true });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Buy passes: registers the buyer at every event on it, then the checkout (or tickets, if free)
|
||||
// @route POST /api/season-passes/:id/buy { quantity, provider? }
|
||||
// @access Private
|
||||
const buy = async (req, res) => {
|
||||
try {
|
||||
const pass = await prisma.seasonPass.findUnique({ where: { id: req.params.id }, select: { series: { select: { slug: true } } } });
|
||||
if (!pass) return res.status(404).json({ message: 'Season pass not found' });
|
||||
const back = `${frontendUrl()}/series/${pass.series.slug}`;
|
||||
const result = await passes.buyPass(req.params.id, req.user.id, {
|
||||
quantity: req.body?.quantity ?? 1, provider: req.body?.provider || null,
|
||||
successUrl: `${back}?pass=paid`, cancelUrl: `${back}?pass=cancelled`, failureUrl: `${back}?pass=failed`,
|
||||
});
|
||||
res.status(201).json(result);
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Pay what's still owed on a purchase (a new checkout)
|
||||
// @route POST /api/season-passes/purchases/:id/checkout
|
||||
// @access Private (the buyer)
|
||||
const payPurchase = async (req, res) => {
|
||||
try {
|
||||
const purchase = await prisma.seasonPassPurchase.findUnique({ where: { id: req.params.id }, select: { userId: true, pass: { select: { series: { select: { slug: true } } } } } });
|
||||
if (!purchase || purchase.userId !== req.user.id) return res.status(404).json({ message: 'Purchase not found' });
|
||||
const back = `${frontendUrl()}/series/${purchase.pass.series.slug}`;
|
||||
res.json(await passes.startPassCheckout(req.params.id, req.user.id, {
|
||||
provider: req.body?.provider || null, successUrl: `${back}?pass=paid`, cancelUrl: `${back}?pass=cancelled`, failureUrl: `${back}?pass=failed`,
|
||||
}));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc The signed-in person's pass purchases
|
||||
// @route GET /api/season-passes/mine
|
||||
// @access Private
|
||||
const myPurchases = async (req, res) => {
|
||||
try {
|
||||
const rows = await prisma.seasonPassPurchase.findMany({
|
||||
where: { userId: req.user.id }, orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true, quantity: true, total: true, status: true, createdAt: true,
|
||||
pass: { select: { id: true, name: true, series: { select: { name: true, slug: true } } } },
|
||||
registrations: { select: { id: true, status: true, event: { select: { id: true, title: true, startDate: true } } } },
|
||||
},
|
||||
});
|
||||
res.json(rows);
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
// @route GET /api/season-passes?seriesId=
|
||||
const listPasses = async (req, res) => { try { res.json(await series.listPasses(req.query.seriesId ? String(req.query.seriesId) : undefined)); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/season-passes
|
||||
const createPass = async (req, res) => { try { res.status(201).json(await series.createPass(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route PUT /api/season-passes/:id (events and price fixed once someone has bought it)
|
||||
const updatePass = async (req, res) => { try { res.json(await series.updatePass(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route DELETE /api/season-passes/:id
|
||||
const deletePass = async (req, res) => { try { await series.deletePass(req.params.id, req.user, await ctxOf(req)); res.json({ ok: true }); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/season-passes/:id/buy { quantity, provider? }
|
||||
const buy = async (req, res) => { try { res.status(201).json(await series.buyPass(req.params.id, req.user.id, { quantity: req.body?.quantity ?? 1, provider: req.body?.provider || null })); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/season-passes/purchases/:id/checkout
|
||||
const payPurchase = async (req, res) => { try { res.json(await series.payPassPurchase(req.params.id, req.user.id, { provider: req.body?.provider || null })); } catch (e) { fail(res, e); } };
|
||||
// @route GET /api/season-passes/mine
|
||||
const myPurchases = async (req, res) => { try { res.json(await series.myPassPurchases(req.user.id)); } catch (e) { fail(res, e); } };
|
||||
|
||||
module.exports = { listPasses, createPass, updatePass, deletePass, buy, payPurchase, myPurchases };
|
||||
|
||||
@@ -1,126 +1,25 @@
|
||||
const prisma = require('../config/db');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
// Event series (v1). The logic is in services/series.ts, shared with /api/v2 (1.51.9).
|
||||
const series = require('../services/series');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { cleanSeriesInput, publicEventWhere, splitEvents, follow, unfollow, compareSeries } = require('../utils/series');
|
||||
const { scopeFor, canView, eventWhere } = require('../utils/campusScope');
|
||||
const { EVENT_CAMPUS_SELECT } = require('../utils/campuses');
|
||||
const { scopeFor } = require('../utils/campusScope');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || (error.code === 'P2002' ? 409 : 400)).json({
|
||||
message: error.code === 'P2002' ? 'Another series already uses that web address' : error.message,
|
||||
});
|
||||
const ctxOf = async (req) => ({ scope: await scopeFor(req), ip: getClientIp(req) });
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message, ...(error.code && !/^P\d/.test(error.code) ? { code: error.code } : {}) });
|
||||
|
||||
const audit = (req, action, series, metadata = {}) => logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'EventSeries', targetId: series.id,
|
||||
metadata: { name: series.name, ...metadata }, ip: getClientIp(req),
|
||||
});
|
||||
|
||||
// @desc Every series, with its events (the ones this person may see) and number of followers
|
||||
// @route GET /api/series
|
||||
// @access Private/Supervisor
|
||||
const listSeries = async (req, res) => {
|
||||
try {
|
||||
const scope = await scopeFor(req);
|
||||
const rows = await prisma.eventSeries.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
events: { orderBy: { startDate: 'desc' }, select: { id: true, title: true, startDate: true, endDate: true, isActive: true, isHidden: true, campusId: true, campus: { select: EVENT_CAMPUS_SELECT } } },
|
||||
_count: { select: { followers: true, events: true } },
|
||||
},
|
||||
});
|
||||
// A series spans campuses; campus-scoped staff see the series, but only their campuses' events in it.
|
||||
res.json(rows.map(({ _count, events, ...s }) => ({
|
||||
...s,
|
||||
followers: _count.followers,
|
||||
eventCount: _count.events,
|
||||
events: events.filter(e => canView(scope, e.campusId)),
|
||||
})));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Create a series
|
||||
// @route POST /api/series
|
||||
// @access Private/Supervisor
|
||||
const createSeries = async (req, res) => {
|
||||
try {
|
||||
const series = await prisma.eventSeries.create({ data: cleanSeriesInput(req.body) });
|
||||
audit(req, 'series_created', series);
|
||||
res.status(201).json(series);
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Rename a series or change its page
|
||||
// @route PUT /api/series/:id
|
||||
// @access Private/Supervisor
|
||||
const updateSeries = async (req, res) => {
|
||||
try {
|
||||
const current = await prisma.eventSeries.findUnique({ where: { id: req.params.id } });
|
||||
if (!current) return res.status(404).json({ message: 'Series not found' });
|
||||
const data = cleanSeriesInput(req.body, current);
|
||||
const series = await prisma.eventSeries.update({ where: { id: current.id }, data });
|
||||
audit(req, 'series_updated', series, { changedKeys: Object.keys(data) });
|
||||
res.json(series);
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Delete a series (its events stay, just without a series; followers are dropped)
|
||||
// @route DELETE /api/series/:id
|
||||
// @access Private/Admin
|
||||
const deleteSeries = async (req, res) => {
|
||||
try {
|
||||
const current = await prisma.eventSeries.findUnique({ where: { id: req.params.id }, include: { _count: { select: { events: true, followers: true } } } });
|
||||
if (!current) return res.status(404).json({ message: 'Series not found' });
|
||||
if (await prisma.seasonPassPurchase.count({ where: { pass: { seriesId: current.id } } })) {
|
||||
return res.status(409).json({ message: 'People have bought season passes for this series, so it can\'t be deleted.' });
|
||||
}
|
||||
await prisma.eventSeries.delete({ where: { id: current.id } });
|
||||
audit(req, 'series_deleted', current, { events: current._count.events, followers: current._count.followers });
|
||||
res.json({ ok: true });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc A series' public page: its details, upcoming and past public events, and whether the
|
||||
// signed-in person follows it
|
||||
// @route GET /api/series/by-slug/:slug
|
||||
// @access Public
|
||||
const getSeriesBySlug = async (req, res) => {
|
||||
try {
|
||||
const series = await prisma.eventSeries.findUnique({
|
||||
where: { slug: String(req.params.slug) },
|
||||
select: { id: true, name: true, slug: true, description: true, picture: true },
|
||||
});
|
||||
if (!series) return res.status(404).json({ message: 'Series not found' });
|
||||
const events = await prisma.event.findMany({
|
||||
where: { ...publicEventWhere(), seriesId: series.id },
|
||||
select: { id: true, title: true, startDate: true, endDate: true, location: true, campus: { select: EVENT_CAMPUS_SELECT } },
|
||||
});
|
||||
const { upcoming, past } = splitEvents(events);
|
||||
const following = req.user ? !!(await prisma.seriesFollow.findUnique({ where: { seriesId_userId: { seriesId: series.id, userId: req.user.id } } })) : false;
|
||||
// Season passes switched on (1.47.3); `unavailable` says why one can't be bought now.
|
||||
const { passView, PASS_INCLUDE } = require('../utils/seasonPass');
|
||||
const passes = (await prisma.seasonPass.findMany({ where: { seriesId: series.id, isActive: true }, orderBy: { createdAt: 'asc' }, include: PASS_INCLUDE }))
|
||||
.map(p => passView(p))
|
||||
.map(({ items, ...p }) => ({ ...p, items: items.map(({ campusId, ...i }) => i) }));
|
||||
res.json({ ...series, upcoming, past, following, passes });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Year-on-year: registrations, tickets, income and attendance per event in the series
|
||||
// (the events this person may see), oldest first, with the change from the one before
|
||||
// @route GET /api/series/:id/compare
|
||||
// @access Private/Supervisor
|
||||
const compare = async (req, res) => {
|
||||
try {
|
||||
res.json(await compareSeries(req.params.id, { scopeWhere: eventWhere(await scopeFor(req)) }));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @route POST /api/series/:id/follow · DELETE /api/series/:id/follow
|
||||
// @access Private
|
||||
const followSeries = async (req, res) => {
|
||||
try { await follow(req.params.id, req.user.id); res.json({ following: true }); } catch (error) { fail(res, error); }
|
||||
};
|
||||
const unfollowSeries = async (req, res) => {
|
||||
try { await unfollow(req.params.id, req.user.id); res.json({ following: false }); } catch (error) { fail(res, error); }
|
||||
};
|
||||
// @route GET /api/series — every series, its events (the ones this person may see) and followers
|
||||
const listSeries = async (req, res) => { try { res.json(await series.listSeries(await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route POST /api/series
|
||||
const createSeries = async (req, res) => { try { res.status(201).json(await series.createSeries(req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route PUT /api/series/:id
|
||||
const updateSeries = async (req, res) => { try { res.json(await series.updateSeries(req.params.id, req.body || {}, req.user, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route DELETE /api/series/:id (events stay, without a series; followers are dropped)
|
||||
const deleteSeries = async (req, res) => { try { await series.deleteSeries(req.params.id, req.user, await ctxOf(req)); res.json({ ok: true }); } catch (e) { fail(res, e); } };
|
||||
// @route GET /api/series/by-slug/:slug — the public page
|
||||
const getSeriesBySlug = async (req, res) => { try { res.json(await series.seriesPage(req.params.slug, req.user?.id || null)); } catch (e) { fail(res, e); } };
|
||||
// @route GET /api/series/:id/compare — year on year
|
||||
const compare = async (req, res) => { try { res.json(await series.compareSeries(req.params.id, await ctxOf(req))); } catch (e) { fail(res, e); } };
|
||||
// @route POST|DELETE /api/series/:id/follow
|
||||
const followSeries = async (req, res) => { try { res.json(await series.setFollowing(req.params.id, req.user.id, true)); } catch (e) { fail(res, e); } };
|
||||
const unfollowSeries = async (req, res) => { try { res.json(await series.setFollowing(req.params.id, req.user.id, false)); } catch (e) { fail(res, e); } };
|
||||
|
||||
module.exports = { listSeries, createSeries, updateSeries, deleteSeries, getSeriesBySlug, followSeries, unfollowSeries, compare };
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Campuses (1.51.9): the public list and landing pages, Site settings → Campuses, and "everyone at
|
||||
* campus X" for messages. Moved from campusController.js so v1 and /api/v2 share it.
|
||||
*/
|
||||
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 */
|
||||
const campusesUtil = require('../utils/campuses') as {
|
||||
SETTING_KEY: string;
|
||||
CAMPUS_PUBLIC_SELECT: Record<string, true>;
|
||||
campusesEnabled(): Promise<boolean>;
|
||||
cleanCampusInput(body: any, opts?: { partial?: boolean }): { data: Record<string, any>; error?: string };
|
||||
campusAudienceWhere(campusId: string, opts: { includeRegistrants: boolean }): object;
|
||||
};
|
||||
const settingsCache = require('../utils/settingsCache') as { invalidate(): void; warmCache(): Promise<unknown> };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
|
||||
export type Actor = { id: string; role: Role };
|
||||
export type Ctx = { scope: { campusIds: string[] } | null; ip?: string };
|
||||
|
||||
const ADMIN_INCLUDE = {
|
||||
notifyRecipients: { select: { id: true, name: true, email: true } },
|
||||
_count: { select: { events: true, homeOf: true, scopedStaff: true } },
|
||||
} as const;
|
||||
|
||||
const audit = (actor: Actor, ctx: Ctx, action: string, campus: { id?: string; name?: string } | null, metadata: object) => logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action, targetType: 'Campus', targetId: campus?.id, metadata: { name: campus?.name, ...metadata }, ip: ctx.ip,
|
||||
});
|
||||
|
||||
/** A taken web address → a message instead of a database error. */
|
||||
async function slugSafe<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'P2002') throw new ServiceError(409, 'Another campus already uses that web address — choose a different one.');
|
||||
if (e instanceof ServiceError) throw e;
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'The campus could not be saved');
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether campuses are on, and the active ones in order (public filters, event cards). */
|
||||
export async function publicCampuses() {
|
||||
const enabled = await campusesUtil.campusesEnabled();
|
||||
const campuses = enabled
|
||||
? await prisma.campus.findMany({ where: { archivedAt: null }, select: campusesUtil.CAMPUS_PUBLIC_SELECT, orderBy: [{ order: 'asc' }, { name: 'asc' }] })
|
||||
: [];
|
||||
return { enabled, campuses };
|
||||
}
|
||||
|
||||
/** A campus landing page (active campuses, while campuses are on). */
|
||||
export async function campusBySlug(slug: string) {
|
||||
const campus = (await campusesUtil.campusesEnabled())
|
||||
? await prisma.campus.findFirst({ where: { slug: String(slug), archivedAt: null }, select: campusesUtil.CAMPUS_PUBLIC_SELECT })
|
||||
: null;
|
||||
if (!campus) return fail(404, 'Campus not found');
|
||||
return campus;
|
||||
}
|
||||
|
||||
/** Every campus, archived ones last, with event counts and summary recipients. */
|
||||
export async function adminCampuses() {
|
||||
const [enabled, campuses] = await Promise.all([
|
||||
campusesUtil.campusesEnabled(),
|
||||
prisma.campus.findMany({ include: ADMIN_INCLUDE, orderBy: [{ archivedAt: { sort: 'asc', nulls: 'first' } }, { order: 'asc' }, { name: 'asc' }] }),
|
||||
]);
|
||||
return { enabled, campuses };
|
||||
}
|
||||
|
||||
/** Campuses on or off (off hides campus fields and filters; nothing is deleted). */
|
||||
export async function setCampusesEnabled(enabled: boolean, actor: Actor, ctx: Ctx) {
|
||||
await prisma.appSetting.upsert({ where: { key: campusesUtil.SETTING_KEY }, update: { value: String(enabled) }, create: { key: campusesUtil.SETTING_KEY, value: String(enabled) } });
|
||||
settingsCache.invalidate();
|
||||
await settingsCache.warmCache();
|
||||
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'campuses_toggled', targetType: 'AppSetting', metadata: { enabled }, ip: ctx.ip });
|
||||
return { enabled };
|
||||
}
|
||||
|
||||
/** A new campus, at the end of the list. */
|
||||
export function createCampus(body: any, actor: Actor, ctx: Ctx) {
|
||||
return slugSafe(async () => {
|
||||
const { data, error } = campusesUtil.cleanCampusInput(body);
|
||||
if (error) fail(400, error);
|
||||
const last = await prisma.campus.aggregate({ _max: { order: true } });
|
||||
const campus = await prisma.campus.create({ data: { ...(data as any), order: (last._max.order ?? -1) + 1 }, include: ADMIN_INCLUDE });
|
||||
audit(actor, ctx, 'campus_created', campus, { slug: campus.slug });
|
||||
return campus;
|
||||
});
|
||||
}
|
||||
|
||||
/** Changes a campus: details, who gets its summaries (staff only), archived or not. */
|
||||
export function updateCampus(campusId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return slugSafe(async () => {
|
||||
const existing = await prisma.campus.findUnique({ where: { id: campusId } });
|
||||
if (!existing) return fail(404, 'Campus not found');
|
||||
const { data, error } = campusesUtil.cleanCampusInput(body, { partial: true });
|
||||
if (error) fail(400, error);
|
||||
if (Array.isArray(body?.notifyRecipientIds)) {
|
||||
const ids = body.notifyRecipientIds.filter((x: unknown) => typeof x === 'string' && x);
|
||||
// Summaries go to people who run events — never to ordinary users.
|
||||
const staff = await prisma.user.findMany({ where: { id: { in: ids }, role: { in: ['admin', 'supervisor', 'staff'] } }, select: { id: true } });
|
||||
data.notifyRecipients = { set: staff.map(u => ({ id: u.id })) };
|
||||
}
|
||||
let archiveChange: boolean | null = null;
|
||||
if (body?.archived !== undefined) {
|
||||
const archived = body.archived === true;
|
||||
if (archived !== !!existing.archivedAt) {
|
||||
data.archivedAt = archived ? new Date() : null;
|
||||
archiveChange = archived;
|
||||
}
|
||||
}
|
||||
const campus = await prisma.campus.update({ where: { id: existing.id }, data, include: ADMIN_INCLUDE });
|
||||
if (archiveChange !== null) audit(actor, ctx, 'campus_archived', campus, { archived: archiveChange });
|
||||
const changed = Object.keys(data).filter(k => k !== 'archivedAt');
|
||||
if (changed.length) audit(actor, ctx, 'campus_updated', campus, { changed });
|
||||
return campus;
|
||||
});
|
||||
}
|
||||
|
||||
/** Puts the campuses in this order (filters and lists follow it). */
|
||||
export async function reorderCampuses(ids: string[]) {
|
||||
const clean = (Array.isArray(ids) ? ids : []).filter(x => typeof x === 'string');
|
||||
if (!clean.length) fail(400, 'Send the campus ids in their new order.');
|
||||
await prisma.$transaction(clean.map((id, order) => prisma.campus.updateMany({ where: { id }, data: { order } })));
|
||||
}
|
||||
|
||||
/** Deletes a campus with no events and no staff limited to it (otherwise archive it). */
|
||||
export async function deleteCampus(campusId: string, actor: Actor, ctx: Ctx) {
|
||||
const campus = await prisma.campus.findUnique({ where: { id: campusId }, include: { _count: { select: { events: true, scopedStaff: true } } } });
|
||||
if (!campus) return fail(404, 'Campus not found');
|
||||
// Deleting would drop their limit and leave them unscoped (seeing every campus).
|
||||
if (campus._count.scopedStaff > 0) {
|
||||
fail(409, `${campus._count.scopedStaff} staff member${campus._count.scopedStaff === 1 ? ' is' : 's are'} limited to ${campus.name}. Change their campuses in Admin → Users first, or archive the campus.`, 'CAMPUS_HAS_SCOPED_STAFF');
|
||||
}
|
||||
if (campus._count.events > 0) {
|
||||
fail(409, `${campus.name} has ${campus._count.events} event${campus._count.events === 1 ? '' : 's'}, so it can't be deleted. Archive it instead.`, 'CAMPUS_HAS_EVENTS');
|
||||
}
|
||||
await prisma.campus.delete({ where: { id: campus.id } });
|
||||
audit(actor, ctx, 'campus_deleted', campus, { slug: campus.slug });
|
||||
}
|
||||
|
||||
/**
|
||||
* "Everyone at campus X" for the email/WhatsApp composers: home campus X, plus (with
|
||||
* `includeRegistrants`) people registered for X's events in the last 12 months. News consent is
|
||||
* still checked when sending. Campus-scoped staff only get their own campuses.
|
||||
*/
|
||||
export async function campusAudience(campusId: string, includeRegistrants: boolean, ctx: Ctx) {
|
||||
const campus = await prisma.campus.findUnique({ where: { id: campusId }, select: { id: true, name: true } });
|
||||
if (!campus || (ctx.scope && !ctx.scope.campusIds.includes(campus.id))) return fail(404, 'Campus not found');
|
||||
const users = await prisma.user.findMany({
|
||||
where: campusesUtil.campusAudienceWhere(campus.id, { includeRegistrants }),
|
||||
select: { id: true, name: true, email: true, phoneNumber: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return { campus, users };
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* Bundle deals and coupon codes (1.51.9). Moved from bundleController.js and couponController.js
|
||||
* so v1 and /api/v2 share them; matching codes and working out discounts stays in
|
||||
* utils/coupons.js and utils/bundles.js.
|
||||
*/
|
||||
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 */
|
||||
const bundlesUtil = require('../utils/bundles') as {
|
||||
KINDS: string[];
|
||||
describeBundle(b: any): string;
|
||||
describeBundleItems(b: any): string;
|
||||
isBundleAvailable(b: any, now: Date): boolean;
|
||||
};
|
||||
const coupons = require('../utils/coupons') as {
|
||||
normalizeCode(code: unknown): string;
|
||||
previewCoupon(args: object): Promise<{ coupon: any; discount: number; bundles: { discount: number; applications: any[] } }>;
|
||||
previewBundles(eventId: string, lines: any[]): Promise<{ discount: number; applications: any[] }>;
|
||||
buildCouponLines(reg: any): any[];
|
||||
describeCoupon(c: any): string;
|
||||
countCouponUses(couponId: string, userId: string | null): Promise<{ usesTotal: number }>;
|
||||
};
|
||||
const pricing = require('../utils/pricing') as {
|
||||
resolveOptionPrice(option: any, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
|
||||
resolveVariantTierPrice(option: any, variantId: string, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
|
||||
};
|
||||
const campusScope = require('../utils/campusScope') as { couponAllowed(scope: object | null, campusIds: Array<string | null>): boolean };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
|
||||
export type Actor = { id: string; role: Role; email?: string };
|
||||
export type Ctx = { scope: object | null; ip?: string };
|
||||
|
||||
const round2 = (n: unknown) => Math.round(Number(n || 0) * 100) / 100;
|
||||
const fmtR = (n: number) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
|
||||
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
|
||||
|
||||
/** Plain errors from the parsers and helpers are the caller's mistake (400), like v1. */
|
||||
async function clientErrors<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 || 'That could not be saved');
|
||||
}
|
||||
}
|
||||
|
||||
const optionalDate = (v: unknown, label: string): Date | null | undefined => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const d = new Date(String(v));
|
||||
if (isNaN(d.getTime())) fail(400, `${label} is not a valid date`);
|
||||
return d;
|
||||
};
|
||||
|
||||
// ─── Pricing a basket (bundle quotes and coupon checks) ──────────────────────
|
||||
|
||||
/**
|
||||
* Who a basket is being priced for: the signed-in buyer, or — for staff — the attendee
|
||||
* (`forUserId`) or a walk-in with no account yet (`forUserId: null`).
|
||||
*/
|
||||
export async function basketBuyer(actor: Actor | null, forUserId: string | null | undefined) {
|
||||
const isStaff = !!(actor && STAFF_ROLES.includes(actor.role));
|
||||
let buyer: { id: string; role: string; email?: string } | null = actor ? { id: actor.id, role: actor.role, email: actor.email } : null;
|
||||
if (isStaff && forUserId) buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
|
||||
else if (isStaff && forUserId === null) buyer = null;
|
||||
return { buyer, isStaff };
|
||||
}
|
||||
|
||||
export type BasketLine = { eventOptionId: string; variantId?: string | null; quantity: number; sessionIds?: string[] };
|
||||
|
||||
/** Prices a basket the way registering will, as coupon/bundle lines. */
|
||||
export async function priceBasket(event: { eventOptions: any[] } & Record<string, any>, options: BasketLine[], buyer: any) {
|
||||
const registrationOptions: any[] = [];
|
||||
// A "pick sessions" line counts once per chosen session.
|
||||
const { isPickOption } = require('../utils/sessions') as { isPickOption(ev: any, opt: any): boolean };
|
||||
const perSession = (o: BasketLine) => isPickOption(event, event.eventOptions.find(x => x.id === o.eventOptionId));
|
||||
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length && perSession(o) ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
|
||||
for (const o of expanded) {
|
||||
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
|
||||
const qty = Number(o.quantity || 0);
|
||||
if (!eo || qty < 1) continue;
|
||||
const resolved = o.variantId ? await pricing.resolveVariantTierPrice(eo, o.variantId, qty, buyer) : await pricing.resolveOptionPrice(eo, qty, buyer);
|
||||
registrationOptions.push({ eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo, tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }] });
|
||||
}
|
||||
return coupons.buildCouponLines({ registrationOptions });
|
||||
}
|
||||
|
||||
const basketEvent = async (eventId: string) => {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } } });
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
return event;
|
||||
};
|
||||
|
||||
/** The bundle deals a basket gets, before any coupon. */
|
||||
export function quoteBundles(input: { eventId?: string; options?: BasketLine[]; forUserId?: string | null }, actor: Actor | null) {
|
||||
return clientErrors(async () => {
|
||||
if (!input.eventId || !Array.isArray(input.options)) fail(400, 'eventId and options are required');
|
||||
const event = await basketEvent(input.eventId!);
|
||||
const { buyer } = await basketBuyer(actor, input.forUserId);
|
||||
const lines = await priceBasket(event, input.options!, buyer);
|
||||
const subtotal = round2(lines.reduce((s: number, l: any) => s + l.unitPrice * l.quantity, 0));
|
||||
const b = await coupons.previewBundles(event.id, lines);
|
||||
return { subtotal, bundleDiscount: b.discount, bundles: b.applications, total: round2(Math.max(0, subtotal - b.discount)) };
|
||||
});
|
||||
}
|
||||
|
||||
export type CodeCheck =
|
||||
| { valid: true; code: string; type: string; value: number; summary: string; description: string | null; discount: number; bundleDiscount: number; bundles: any[]; subtotal: number; total: number }
|
||||
| { valid: false; message: string };
|
||||
|
||||
/**
|
||||
* Checks a code against a basket. Staff can check on an attendee's behalf (`forUserId`), or
|
||||
* leave it out for a new attendee — restricted codes are then checked when they register.
|
||||
*/
|
||||
export function checkCode(input: { code?: string; eventId?: string; options?: BasketLine[]; forUserId?: string | null }, actor: Actor | null): Promise<CodeCheck> {
|
||||
return clientErrors(async () => {
|
||||
if (!input.code || !input.eventId || !Array.isArray(input.options) || input.options.length === 0) fail(400, 'code, eventId and options are required');
|
||||
const event = await basketEvent(input.eventId!);
|
||||
const { buyer, isStaff } = await basketBuyer(actor, input.forUserId);
|
||||
const lines = await priceBasket(event, input.options!, buyer);
|
||||
const subtotal = lines.reduce((s: number, l: any) => s + l.unitPrice * l.quantity, 0);
|
||||
try {
|
||||
const { coupon, discount, bundles } = await coupons.previewCoupon({ code: input.code, eventId: event.id, user: buyer, lines, deferRestrictions: isStaff && !buyer });
|
||||
return {
|
||||
valid: true as const, code: coupon.code, type: coupon.type, value: coupon.value, summary: coupons.describeCoupon(coupon), description: coupon.description,
|
||||
discount, bundleDiscount: bundles.discount, bundles: bundles.applications, subtotal, total: Math.max(0, subtotal - bundles.discount - discount),
|
||||
};
|
||||
} catch (e: any) {
|
||||
if (!e.isCouponError) throw e;
|
||||
return { valid: false as const, message: e.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Bundle deals ────────────────────────────────────────────────────────────
|
||||
|
||||
const GROUP_PRICING = ['per_ticket', 'percent'];
|
||||
const PRICING_FIELDS = ['kind', 'price', 'setSize', 'freeCount', 'groupPricing', 'items'];
|
||||
const DETAIL_INCLUDE = {
|
||||
items: {
|
||||
include: {
|
||||
eventOption: { select: { id: true, name: true, price: true, variants: { select: { id: true, price: true } } } },
|
||||
variant: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Standard (variant or option) price of one unit of an item. */
|
||||
function itemPrice(i: any) {
|
||||
const variant = i.variantId ? (i.eventOption?.variants || []).find((v: any) => v.id === i.variantId) : null;
|
||||
return Number((variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price) || 0);
|
||||
}
|
||||
|
||||
/** What the deal is worth at standard prices, and a short "save …" line (or null when the headline says it). */
|
||||
function valueOf(bundle: any): { standardValue: number | null; savingText: string | null } {
|
||||
const kind = bundle.kind || 'exact';
|
||||
const items = bundle.items || [];
|
||||
const dearest = items.reduce((m: number, i: any) => Math.max(m, itemPrice(i)), 0);
|
||||
const price = Number(bundle.price || 0);
|
||||
if (kind === 'exact') {
|
||||
const standard = round2(items.reduce((s: number, i: any) => s + itemPrice(i) * Number(i.quantity || 0), 0));
|
||||
return { standardValue: standard, savingText: standard - price > 0.005 ? `save ${fmtR(standard - price)}` : null };
|
||||
}
|
||||
if (kind === 'mix') {
|
||||
const standard = round2(dearest * Number(bundle.setSize || 0));
|
||||
return { standardValue: standard, savingText: standard - price > 0.005 ? `save up to ${fmtR(standard - price)}` : null };
|
||||
}
|
||||
if (kind === 'free') return { standardValue: null, savingText: dearest > 0 ? `save up to ${fmtR(dearest * Number(bundle.freeCount || 0))} a set` : null };
|
||||
if (bundle.groupPricing === 'per_ticket' && dearest - price > 0.005) return { standardValue: null, savingText: `save up to ${fmtR(dearest - price)} a ticket` };
|
||||
return { standardValue: null, savingText: null };
|
||||
}
|
||||
|
||||
function shapeBundle(bundle: any, now = new Date()) {
|
||||
const items = (bundle.items || []).map(({ eventOption, ...i }: any) => ({ ...i, eventOption: eventOption ? { id: eventOption.id, name: eventOption.name, price: eventOption.price } : null }));
|
||||
return { ...bundle, items, summary: bundlesUtil.describeBundleItems(bundle), headline: bundlesUtil.describeBundle(bundle), ...valueOf(bundle), available: bundlesUtil.isBundleAvailable(bundle, now) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A create/update body as Prisma data plus the item list. On update, fields left out stay as
|
||||
* they are; when any pricing field is sent, the whole deal (saved + new values) is checked.
|
||||
*/
|
||||
async function parseBundleBody(body: any, eventId: string, { isCreate, current = null }: { isCreate: boolean; current?: any }) {
|
||||
const data: Record<string, any> = {};
|
||||
if (isCreate || body.name !== undefined) {
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) fail(400, 'Give the bundle a name');
|
||||
if (name.length > 80) fail(400, 'Keep the name under 80 characters');
|
||||
data.name = name;
|
||||
}
|
||||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||||
if (body.isActive !== undefined) data.isActive = !!body.isActive;
|
||||
if (body.order !== undefined) data.order = Number.isInteger(Number(body.order)) ? Number(body.order) : 0;
|
||||
const validFrom = optionalDate(body.validFrom, 'Available from');
|
||||
const validUntil = optionalDate(body.validUntil, 'Available until');
|
||||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||||
if (validFrom && validUntil && validFrom > validUntil) fail(400, '"Available from" must be before "Available until"');
|
||||
|
||||
if (!isCreate && !PRICING_FIELDS.some(f => body[f] !== undefined)) return { data, items: undefined };
|
||||
|
||||
const pick = (f: string) => (body[f] !== undefined ? body[f] : current?.[f]);
|
||||
const kind = pick('kind') || 'exact';
|
||||
if (!bundlesUtil.KINDS.includes(kind)) fail(400, 'Unknown bundle type');
|
||||
data.kind = kind;
|
||||
|
||||
const rawItems = body.items !== undefined ? body.items : (current?.items || []);
|
||||
// The same option/variant twice becomes one line.
|
||||
const merged = new Map<string, { eventOptionId: string; variantId: string | null; quantity: number }>();
|
||||
for (const it of Array.isArray(rawItems) ? rawItems : []) {
|
||||
if (!it || !it.eventOptionId) continue;
|
||||
const quantity = kind === 'exact' ? Number(it.quantity) : 1;
|
||||
if (!Number.isInteger(quantity) || quantity < 1) fail(400, 'Each ticket needs a quantity of at least 1');
|
||||
const key = `${it.eventOptionId}::${it.variantId || ''}`;
|
||||
const prev = merged.get(key);
|
||||
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: kind === 'exact' ? (prev?.quantity || 0) + quantity : 1 });
|
||||
}
|
||||
const items = [...merged.values()];
|
||||
if (items.length === 0) fail(400, kind === 'exact' ? 'Add at least one ticket to the bundle' : 'Choose at least one qualifying ticket');
|
||||
|
||||
const price = Number(pick('price'));
|
||||
const setSize = Number(pick('setSize'));
|
||||
const freeCount = Number(pick('freeCount'));
|
||||
const groupPricing = pick('groupPricing');
|
||||
const priceGiven = pick('price') !== '' && pick('price') !== null && pick('price') !== undefined;
|
||||
|
||||
if (kind === 'exact') {
|
||||
if (items.reduce((s, i) => s + i.quantity, 0) < 2) fail(400, 'A bundle needs at least two tickets in it');
|
||||
if (!priceGiven || !(price >= 0)) fail(400, 'Enter the bundle price (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize: null, freeCount: null, groupPricing: null });
|
||||
} else if (kind === 'mix') {
|
||||
if (!Number.isInteger(setSize) || setSize < 2) fail(400, 'A pick-any bundle needs at least 2 tickets per set');
|
||||
if (!priceGiven || !(price >= 0)) fail(400, 'Enter the price for the set (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing: null });
|
||||
} else if (kind === 'free') {
|
||||
if (!Number.isInteger(freeCount) || freeCount < 1) fail(400, 'At least one ticket must be free');
|
||||
if (!Number.isInteger(setSize) || setSize <= freeCount) fail(400, 'Buy at least one ticket to get the free ones');
|
||||
Object.assign(data, { price: 0, setSize, freeCount, groupPricing: null });
|
||||
} else {
|
||||
if (!Number.isInteger(setSize) || setSize < 2) fail(400, 'A group needs at least 2 tickets');
|
||||
if (!GROUP_PRICING.includes(groupPricing)) fail(400, 'Choose a price per ticket or a percentage off');
|
||||
if (groupPricing === 'percent' && !(price > 0 && price <= 100)) fail(400, 'Enter a percentage between 1 and 100');
|
||||
if (groupPricing === 'per_ticket' && (!priceGiven || !(price >= 0))) fail(400, 'Enter the price per ticket (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing });
|
||||
}
|
||||
|
||||
const options = await prisma.eventOption.findMany({ where: { id: { in: items.map(i => i.eventOptionId) } }, select: { id: true, eventId: true, variants: { select: { id: true } } } });
|
||||
for (const i of items) {
|
||||
const row = options.find(o => o.id === i.eventOptionId);
|
||||
if (!row || row.eventId !== eventId) fail(400, 'One of the tickets isn\'t part of this event');
|
||||
if (i.variantId && !row!.variants.some(v => v.id === i.variantId)) fail(400, 'One of the selected variants no longer exists');
|
||||
}
|
||||
return { data, items };
|
||||
}
|
||||
|
||||
const eventOr404 = async (eventId: string) => {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true } });
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
return event;
|
||||
};
|
||||
|
||||
/** An event's bundle deals with how often each has been used and the discount given. */
|
||||
export async function listBundles(eventId: string) {
|
||||
await eventOr404(eventId);
|
||||
const bundles = await prisma.eventBundle.findMany({ where: { eventId }, include: DETAIL_INCLUDE, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }] });
|
||||
const totals = await prisma.registrationBundle.groupBy({
|
||||
by: ['bundleId'],
|
||||
where: { bundle: { eventId }, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { count: true, discount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const byId = Object.fromEntries(totals.map(t => [t.bundleId, t]));
|
||||
const now = new Date();
|
||||
return bundles.map(b => ({
|
||||
...shapeBundle(b, now),
|
||||
uses: byId[b.id]?._sum?.count || 0,
|
||||
registrations: byId[b.id]?._count?._all || 0,
|
||||
totalDiscount: round2(byId[b.id]?._sum?.discount || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
export function createBundle(eventId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return clientErrors(async () => {
|
||||
const event = await eventOr404(eventId);
|
||||
const parsed = await parseBundleBody(body || {}, event.id, { isCreate: true });
|
||||
const bundle = await prisma.eventBundle.create({ data: { ...(parsed.data as any), eventId: event.id, items: { create: parsed.items } }, include: DETAIL_INCLUDE });
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'bundle_created', targetType: 'EventBundle', targetId: bundle.id,
|
||||
metadata: { eventId: event.id, name: bundle.name, kind: bundle.kind, deal: bundlesUtil.describeBundle(bundle) }, ip: ctx.ip,
|
||||
});
|
||||
return { ...shapeBundle(bundle), uses: 0, registrations: 0, totalDiscount: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/** Edits a deal (or switches it off). Registrations that have it are re-worked only when their own items change. */
|
||||
export function updateBundle(bundleId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return clientErrors(async () => {
|
||||
const existing = await prisma.eventBundle.findUnique({ where: { id: bundleId }, include: { items: true } });
|
||||
if (!existing) return fail(404, 'Bundle not found');
|
||||
const parsed = await parseBundleBody(body || {}, existing.eventId, { isCreate: false, current: existing });
|
||||
const bundle = await prisma.eventBundle.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...parsed.data, ...(parsed.items ? { items: { deleteMany: {}, create: parsed.items } } : {}) },
|
||||
include: DETAIL_INCLUDE,
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'bundle_updated', targetType: 'EventBundle', targetId: bundle.id,
|
||||
metadata: { eventId: bundle.eventId, name: bundle.name, changedKeys: Object.keys(body || {}) }, ip: ctx.ip,
|
||||
});
|
||||
return shapeBundle(bundle);
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes a deal; one that's been used is switched off instead, so registrations keep their price. */
|
||||
export async function deleteBundle(bundleId: string, actor: Actor, ctx: Ctx) {
|
||||
const existing = await prisma.eventBundle.findUnique({ where: { id: bundleId } });
|
||||
if (!existing) return fail(404, 'Bundle not found');
|
||||
const used = await prisma.registrationBundle.count({ where: { bundleId: existing.id } });
|
||||
if (used > 0) await prisma.eventBundle.update({ where: { id: existing.id }, data: { isActive: false } });
|
||||
else await prisma.eventBundle.delete({ where: { id: existing.id } });
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'bundle_deleted', targetType: 'EventBundle', targetId: existing.id,
|
||||
metadata: { eventId: existing.eventId, name: existing.name, switchedOff: used > 0 }, ip: ctx.ip,
|
||||
});
|
||||
return used > 0
|
||||
? { switchedOff: true as const, message: 'This bundle has been used, so it\'s been switched off instead. Registrations that got it keep their price.' }
|
||||
: { deleted: true as const, message: 'Bundle deleted' };
|
||||
}
|
||||
|
||||
/** The deals shown on an event's page: available bundles, without internal fields. */
|
||||
export async function publicBundlesForEvent(eventId: string) {
|
||||
const bundles = await prisma.eventBundle.findMany({ where: { eventId, isActive: true }, include: DETAIL_INCLUDE, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }] });
|
||||
const now = new Date();
|
||||
return bundles.filter(b => bundlesUtil.isBundleAvailable(b, now)).map(b => {
|
||||
const s = shapeBundle(b, now);
|
||||
return {
|
||||
id: s.id, name: s.name, description: s.description, validUntil: s.validUntil,
|
||||
kind: s.kind, price: s.price, setSize: s.setSize, freeCount: s.freeCount, groupPricing: s.groupPricing,
|
||||
summary: s.summary, headline: s.headline, standardValue: s.standardValue, savingText: s.savingText,
|
||||
items: s.items.map((i: any) => ({ eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: i.quantity })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Coupon codes ────────────────────────────────────────────────────────────
|
||||
|
||||
const TYPES = ['percent', 'fixed', 'comp'];
|
||||
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
|
||||
const COUPON_DETAIL_INCLUDE = {
|
||||
events: { include: { event: { select: { id: true, title: true, startDate: true, campusId: true } } } },
|
||||
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
|
||||
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
} as const;
|
||||
const SCOPED_COUPON_MSG = 'Supervisors for some campuses can only make coupons limited to events at their campuses.';
|
||||
|
||||
// Campus-scoped supervisors: only coupons limited to events at their campuses.
|
||||
const couponCampuses = (coupon: any): Array<string | null> => (coupon.events || []).map((ce: any) => ce.event?.campusId ?? null);
|
||||
async function eventIdsAllowed(scope: object | null, eventIds: string[] | undefined) {
|
||||
if (!scope) return true;
|
||||
if (!eventIds || eventIds.length === 0) return false; // a coupon for every event
|
||||
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { campusId: true } });
|
||||
return events.length === eventIds.length && campusScope.couponAllowed(scope, events.map(e => e.campusId));
|
||||
}
|
||||
|
||||
const optionalInt = (v: unknown, label: string, min = 0): number | null | undefined => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < min) fail(400, `${label} must be a whole number of at least ${min}`);
|
||||
return n;
|
||||
};
|
||||
|
||||
/** A create/update body as Prisma data plus the event, ticket and person lists. On update, fields left out stay. */
|
||||
async function parseCouponBody(body: any, { isCreate }: { isCreate: boolean }) {
|
||||
const data: Record<string, any> = {};
|
||||
if (isCreate || body.code !== undefined) {
|
||||
const code = coupons.normalizeCode(body.code);
|
||||
if (!/^[A-Z0-9_-]{3,32}$/.test(code)) fail(400, 'Codes are 3–32 letters, numbers, hyphens or underscores');
|
||||
data.code = code;
|
||||
}
|
||||
if (isCreate || body.type !== undefined) {
|
||||
if (!TYPES.includes(body.type)) fail(400, "Discount type must be 'percent', 'fixed' or 'comp'");
|
||||
data.type = body.type;
|
||||
}
|
||||
if (isCreate || body.value !== undefined) {
|
||||
const type = data.type || body.type;
|
||||
const value = type === 'comp' ? 0 : Number(body.value);
|
||||
if (type !== 'comp' && !(value > 0)) fail(400, 'Enter a discount value greater than zero');
|
||||
if (type === 'percent' && value > 100) fail(400, 'A percentage discount can\'t be more than 100');
|
||||
data.value = value;
|
||||
}
|
||||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||||
if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird;
|
||||
if (body.active !== undefined) data.active = !!body.active;
|
||||
const validFrom = optionalDate(body.validFrom, 'Valid from');
|
||||
const validUntil = optionalDate(body.validUntil, 'Valid until');
|
||||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||||
if (validFrom && validUntil && validFrom > validUntil) fail(400, '"Valid from" must be before "Valid until"');
|
||||
const maxUses = optionalInt(body.maxUses, 'Max total uses', 1);
|
||||
const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1);
|
||||
const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1);
|
||||
if (maxUses !== undefined) data.maxUses = maxUses;
|
||||
if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser;
|
||||
if (minQuantity !== undefined) data.minQuantity = minQuantity;
|
||||
if (body.restrictRoles !== undefined) {
|
||||
const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : [];
|
||||
if (roles.some((r: string) => !ROLES.includes(r))) fail(400, 'Unknown role in restrictions');
|
||||
data.restrictRoles = roles;
|
||||
}
|
||||
if (body.restrictDomain !== undefined) {
|
||||
const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
|
||||
if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) fail(400, 'Enter an email domain like church.org');
|
||||
data.restrictDomain = domain || null;
|
||||
}
|
||||
const eventIds: string[] | undefined = body.eventIds !== undefined ? [...new Set<string>((body.eventIds || []).filter(Boolean))] : undefined;
|
||||
const scopes: Array<{ eventOptionId: string; variantId: string | null }> | undefined = body.options !== undefined
|
||||
? (body.options || []).filter((o: any) => o && o.eventOptionId).map((o: any) => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null }))
|
||||
: undefined;
|
||||
const userIds: string[] | undefined = body.userIds !== undefined ? [...new Set<string>((body.userIds || []).filter(Boolean))] : undefined;
|
||||
if (scopes && scopes.length > 0) {
|
||||
const optionRows = await prisma.eventOption.findMany({ where: { id: { in: scopes.map(s => s.eventOptionId) } }, select: { id: true, eventId: true, variants: { select: { id: true } } } });
|
||||
for (const s of scopes) {
|
||||
const row = optionRows.find(o => o.id === s.eventOptionId);
|
||||
if (!row) return fail(400, 'One of the selected ticket options no longer exists');
|
||||
if (s.variantId && !row.variants.some(v => v.id === s.variantId)) fail(400, 'One of the selected variants no longer exists');
|
||||
// A scoped option implies its event, so the code can't be used elsewhere by mistake.
|
||||
if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) fail(400, 'Selected ticket options must belong to the selected events');
|
||||
}
|
||||
}
|
||||
return { data, eventIds, scopes, userIds };
|
||||
}
|
||||
|
||||
function relationWrites({ eventIds, scopes, userIds }: Awaited<ReturnType<typeof parseCouponBody>>, isCreate: boolean) {
|
||||
const out: Record<string, any> = {};
|
||||
const replace = isCreate ? {} : { deleteMany: {} };
|
||||
if (eventIds !== undefined) out.events = { ...replace, create: eventIds.map(eventId => ({ eventId })) };
|
||||
if (scopes !== undefined) out.options = { ...replace, create: scopes };
|
||||
if (userIds !== undefined) out.allowedUsers = { ...replace, create: userIds.map(userId => ({ userId })) };
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Codes the caller may see, with how often each has been used and the discount given. */
|
||||
export async function listCoupons(ctx: Ctx) {
|
||||
const list = (await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } })).filter(c => campusScope.couponAllowed(ctx.scope, couponCampuses(c)));
|
||||
const totals = await prisma.couponRedemption.groupBy({
|
||||
by: ['couponId'],
|
||||
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
|
||||
_count: { _all: true },
|
||||
_sum: { discountAmount: true },
|
||||
});
|
||||
const byId = Object.fromEntries(totals.map(t => [t.couponId, t]));
|
||||
return list.map(c => ({ ...c, summary: coupons.describeCoupon(c), uses: byId[c.id]?._count?._all || 0, totalDiscount: byId[c.id]?._sum?.discountAmount || 0 }));
|
||||
}
|
||||
|
||||
/** One code with its redemptions. */
|
||||
export async function getCoupon(couponId: string, ctx: Ctx) {
|
||||
const coupon = await prisma.coupon.findUnique({
|
||||
where: { id: couponId },
|
||||
include: {
|
||||
...COUPON_DETAIL_INCLUDE,
|
||||
redemptions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
appliedBy: { select: { id: true, name: true } },
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!coupon || !campusScope.couponAllowed(ctx.scope, couponCampuses(coupon))) return fail(404, 'Coupon not found');
|
||||
return { ...coupon, summary: coupons.describeCoupon(coupon) };
|
||||
}
|
||||
|
||||
export function createCoupon(body: any, actor: Actor, ctx: Ctx) {
|
||||
return clientErrors(async () => {
|
||||
const parsed = await parseCouponBody(body || {}, { isCreate: true });
|
||||
if (!(await eventIdsAllowed(ctx.scope, parsed.eventIds))) fail(403, SCOPED_COUPON_MSG);
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) fail(409, `The code ${parsed.data.code} already exists`);
|
||||
const coupon = await prisma.coupon.create({ data: { ...(parsed.data as any), createdById: actor.id, ...relationWrites(parsed, true) }, include: COUPON_DETAIL_INCLUDE });
|
||||
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'coupon_created', targetType: 'Coupon', targetId: coupon.id, metadata: { code: coupon.code, type: coupon.type, value: coupon.value }, ip: ctx.ip });
|
||||
return { ...coupon, summary: coupons.describeCoupon(coupon), uses: 0, totalDiscount: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/** Changes a code (or turns it off). Discounts already applied keep their locked amount; a used code can't be renamed. */
|
||||
export function updateCoupon(couponId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return clientErrors(async () => {
|
||||
const existing = await prisma.coupon.findUnique({ where: { id: couponId }, include: { events: { include: { event: { select: { campusId: true } } } } } });
|
||||
if (!existing || !campusScope.couponAllowed(ctx.scope, couponCampuses(existing))) return fail(404, 'Coupon not found');
|
||||
const parsed = await parseCouponBody({ type: existing.type, ...body }, { isCreate: false });
|
||||
if (parsed.eventIds !== undefined && !(await eventIdsAllowed(ctx.scope, parsed.eventIds))) fail(403, SCOPED_COUPON_MSG);
|
||||
if (parsed.data.code && parsed.data.code !== existing.code) {
|
||||
if (await prisma.couponRedemption.count({ where: { couponId: existing.id } }) > 0) fail(400, 'This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.');
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) fail(409, `The code ${parsed.data.code} already exists`);
|
||||
}
|
||||
const coupon = await prisma.coupon.update({ where: { id: existing.id }, data: { ...parsed.data, ...relationWrites(parsed, false) }, include: COUPON_DETAIL_INCLUDE });
|
||||
const disabled = existing.active && coupon.active === false;
|
||||
const changedKeys = Object.keys(body || {});
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated',
|
||||
targetType: 'Coupon', targetId: coupon.id, metadata: { code: coupon.code, changedKeys }, ip: ctx.ip,
|
||||
});
|
||||
const { usesTotal } = await coupons.countCouponUses(coupon.id, null);
|
||||
return { ...coupon, summary: coupons.describeCoupon(coupon), uses: usesTotal };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Coupon and bundle-deal uses for the Discounts report, newest first. `scopeWhere` limits it to
|
||||
* the caller's campuses (`{ event: … }` or `{}`). Bundle dates go by when the registration was made.
|
||||
*/
|
||||
export async function listRedemptions(opts: { eventIds: string[]; from: Date | null; to: Date | null }, scopeWhere: object) {
|
||||
const range = (opts.from || opts.to) ? { createdAt: { ...(opts.from ? { gte: opts.from } : {}), ...(opts.to ? { lte: opts.to } : {}) } } : {};
|
||||
const events = opts.eventIds.length ? { eventId: { in: opts.eventIds } } : {};
|
||||
const rows = await prisma.couponRedemption.findMany({
|
||||
where: { releasedAt: null, registration: { status: { not: 'cancelled' }, ...events, ...scopeWhere }, ...range },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
coupon: { select: { id: true, code: true, type: true, value: true } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
appliedBy: { select: { id: true, name: true } },
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
const bundleRows = await prisma.registrationBundle.findMany({
|
||||
where: { registration: { status: { not: 'cancelled' }, ...events, ...range, ...scopeWhere } },
|
||||
include: {
|
||||
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
|
||||
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
return [
|
||||
...rows.map(r => ({ ...r, kind: 'coupon' as const, uses: 1, summary: coupons.describeCoupon(r.coupon) })),
|
||||
...bundleRows.map(b => ({
|
||||
id: b.id, kind: 'bundle' as const, uses: b.count, discountAmount: b.discount, createdAt: b.registration.createdAt, summary: bundlesUtil.describeBundle(b.bundle),
|
||||
coupon: { id: b.bundleId, code: b.bundle.name }, user: b.registration.user, appliedBy: null,
|
||||
registration: { id: b.registration.id, status: b.registration.status, event: b.registration.event },
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail } from './errors';
|
||||
import { SESSIONS_INCLUDE, SERIES_SELECT, withSchedule } from './events';
|
||||
import { publicBundlesForEvent } from './deals';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const sessionsUtil = require('../utils/sessions') as {
|
||||
@@ -106,7 +107,6 @@ export async function getEventDetail(eventId: string, viewer: Viewer, urlOf: Url
|
||||
}));
|
||||
|
||||
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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Event series and season passes (1.51.9). Moved from seriesController.js and
|
||||
* seasonPassController.js so v1 and /api/v2 share them; the rules themselves stay in
|
||||
* utils/series.js and utils/seasonPass.js.
|
||||
*/
|
||||
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 */
|
||||
const seriesUtil = require('../utils/series') as {
|
||||
cleanSeriesInput(body: any, current?: any): any;
|
||||
publicEventWhere(now?: Date): object;
|
||||
splitEvents(events: any[]): { upcoming: any[]; past: any[] };
|
||||
follow(seriesId: string, userId: string): Promise<unknown>;
|
||||
unfollow(seriesId: string, userId: string): Promise<unknown>;
|
||||
compareSeries(seriesId: string, opts: { scopeWhere: object }): Promise<unknown>;
|
||||
};
|
||||
const passes = require('../utils/seasonPass') as {
|
||||
PASS_INCLUDE: any;
|
||||
passView(pass: any, now?: Date): any;
|
||||
cleanPassInput(body: any, seriesId: string, current?: any): Promise<{ data: any; items: any[] | undefined }>;
|
||||
loadPass(id: string): Promise<any>;
|
||||
buyPass(passId: string, userId: string, opts: object): Promise<any>;
|
||||
startPassCheckout(purchaseId: string, userId: string, opts: object): Promise<any>;
|
||||
};
|
||||
const campusScope = require('../utils/campusScope') as {
|
||||
canView(scope: object | null, campusId: string | null): boolean;
|
||||
canManage(scope: object, campusId: string | null): boolean;
|
||||
eventWhere(scope: object | null): object;
|
||||
};
|
||||
const { EVENT_CAMPUS_SELECT } = require('../utils/campuses') as { EVENT_CAMPUS_SELECT: Record<string, true> };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
|
||||
export type Actor = { id: string; role: Role };
|
||||
export type Ctx = { scope: object | null; ip?: string };
|
||||
|
||||
/** A taken web address is a CONFLICT with a message people understand. */
|
||||
async function slugSafe<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'P2002') throw new ServiceError(409, 'Another series already uses that web address');
|
||||
if (e instanceof ServiceError) throw e;
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'The series could not be saved');
|
||||
}
|
||||
}
|
||||
|
||||
const auditSeries = (actor: Actor, ctx: Ctx, action: string, series: { id: string; name: string }, metadata: object = {}) => logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action, targetType: 'EventSeries', targetId: series.id, metadata: { name: series.name, ...metadata }, ip: ctx.ip,
|
||||
});
|
||||
|
||||
// ─── Series ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every series with its events and number of followers. A series spans campuses: campus-scoped
|
||||
* staff see every series, but only their campuses' events in it.
|
||||
*/
|
||||
export async function listSeries(ctx: Ctx) {
|
||||
const rows = await prisma.eventSeries.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
events: { orderBy: { startDate: 'desc' }, select: { id: true, title: true, startDate: true, endDate: true, isActive: true, isHidden: true, campusId: true, campus: { select: EVENT_CAMPUS_SELECT } } },
|
||||
_count: { select: { followers: true, events: true } },
|
||||
},
|
||||
});
|
||||
return rows.map(({ _count, events, ...s }) => ({
|
||||
...s,
|
||||
followers: _count.followers,
|
||||
eventCount: _count.events,
|
||||
events: events.filter(e => campusScope.canView(ctx.scope, e.campusId)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function createSeries(body: object, actor: Actor, ctx: Ctx) {
|
||||
return slugSafe(async () => {
|
||||
const series = await prisma.eventSeries.create({ data: seriesUtil.cleanSeriesInput(body) });
|
||||
auditSeries(actor, ctx, 'series_created', series);
|
||||
return series;
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSeries(seriesId: string, body: object, actor: Actor, ctx: Ctx) {
|
||||
return slugSafe(async () => {
|
||||
const current = await prisma.eventSeries.findUnique({ where: { id: seriesId } });
|
||||
if (!current) return fail(404, 'Series not found');
|
||||
const data = seriesUtil.cleanSeriesInput(body, current);
|
||||
const series = await prisma.eventSeries.update({ where: { id: current.id }, data });
|
||||
auditSeries(actor, ctx, 'series_updated', series, { changedKeys: Object.keys(data) });
|
||||
return series;
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes a series: its events stay (without a series) and followers are dropped. Not once passes are sold. */
|
||||
export async function deleteSeries(seriesId: string, actor: Actor, ctx: Ctx) {
|
||||
const current = await prisma.eventSeries.findUnique({ where: { id: seriesId }, include: { _count: { select: { events: true, followers: true } } } });
|
||||
if (!current) return fail(404, 'Series not found');
|
||||
if (await prisma.seasonPassPurchase.count({ where: { pass: { seriesId: current.id } } })) {
|
||||
fail(409, 'People have bought season passes for this series, so it can\'t be deleted.');
|
||||
}
|
||||
await prisma.eventSeries.delete({ where: { id: current.id } });
|
||||
auditSeries(actor, ctx, 'series_deleted', current, { events: current._count.events, followers: current._count.followers });
|
||||
}
|
||||
|
||||
/** A series' public page: details, upcoming and past public events, its passes, and whether `userId` follows it. */
|
||||
export async function seriesPage(slug: string, userId: string | null) {
|
||||
const series = await prisma.eventSeries.findUnique({ where: { slug: String(slug) }, select: { id: true, name: true, slug: true, description: true, picture: true } });
|
||||
if (!series) return fail(404, 'Series not found');
|
||||
const events = await prisma.event.findMany({
|
||||
where: { ...seriesUtil.publicEventWhere(), seriesId: series.id },
|
||||
select: { id: true, title: true, startDate: true, endDate: true, location: true, campus: { select: EVENT_CAMPUS_SELECT } },
|
||||
});
|
||||
const { upcoming, past } = seriesUtil.splitEvents(events);
|
||||
const following = userId ? !!(await prisma.seriesFollow.findUnique({ where: { seriesId_userId: { seriesId: series.id, userId } } })) : false;
|
||||
// Passes switched on; `unavailable` says why one can't be bought now.
|
||||
const list = (await prisma.seasonPass.findMany({ where: { seriesId: series.id, isActive: true }, orderBy: { createdAt: 'asc' }, include: passes.PASS_INCLUDE }))
|
||||
.map(p => passes.passView(p))
|
||||
.map(({ items, ...p }: any) => ({ ...p, items: items.map(({ campusId: _c, ...i }: any) => i) }));
|
||||
return { ...series, upcoming, past, following, passes: list };
|
||||
}
|
||||
|
||||
/** Year on year: each event in the series (that the caller may see), oldest first. */
|
||||
export function compareSeries(seriesId: string, ctx: Ctx) {
|
||||
return seriesUtil.compareSeries(seriesId, { scopeWhere: campusScope.eventWhere(ctx.scope) });
|
||||
}
|
||||
|
||||
export async function setFollowing(seriesId: string, userId: string, following: boolean) {
|
||||
try {
|
||||
if (following) await seriesUtil.follow(seriesId, userId);
|
||||
else await seriesUtil.unfollow(seriesId, userId);
|
||||
} catch (e: any) {
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'Could not change following');
|
||||
}
|
||||
return { following };
|
||||
}
|
||||
|
||||
// ─── Season passes ───────────────────────────────────────────────────────────
|
||||
|
||||
const auditPass = (actor: Actor, ctx: Ctx, pass: { id: string; name: string }, action: string, metadata: object = {}) => logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'season_pass_changed', targetType: 'SeasonPass', targetId: pass.id, metadata: { action, name: pass.name, ...metadata }, ip: ctx.ip,
|
||||
});
|
||||
|
||||
/** Campus-scoped supervisors may only put their own campuses' events on a pass. */
|
||||
function assertCampuses(ctx: Ctx, campusIds: Array<string | null>) {
|
||||
if (ctx.scope && campusIds.some(c => !campusScope.canManage(ctx.scope!, c))) {
|
||||
fail(403, 'You can only put events from your own campuses on a pass (not events for all campuses).');
|
||||
}
|
||||
}
|
||||
|
||||
const asServiceError = async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e: any) {
|
||||
if (e instanceof ServiceError) throw e;
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'Something went wrong with the pass');
|
||||
}
|
||||
};
|
||||
|
||||
/** A series' passes (or all) with their events, price shares, and how many were sold or are unpaid. */
|
||||
export async function listPasses(seriesId?: string) {
|
||||
const rows = await prisma.seasonPass.findMany({ where: seriesId ? { seriesId } : {}, orderBy: { createdAt: 'asc' }, include: passes.PASS_INCLUDE });
|
||||
const sold = await prisma.seasonPassPurchase.groupBy({ by: ['passId', 'status'], where: { passId: { in: rows.map(r => r.id) } }, _sum: { quantity: true } });
|
||||
const count = (passId: string, status: string) => sold.filter(s => s.passId === passId && s.status === status).reduce((n, s) => n + (s._sum.quantity || 0), 0);
|
||||
return rows.map(p => ({ ...passes.passView(p), sold: count(p.id, 'paid'), unpaid: count(p.id, 'pending') }));
|
||||
}
|
||||
|
||||
export function createPass(body: any, actor: Actor, ctx: Ctx) {
|
||||
return asServiceError(async () => {
|
||||
const seriesId = String(body?.seriesId || '');
|
||||
if (!(await prisma.eventSeries.findUnique({ where: { id: seriesId }, select: { id: true } }))) fail(404, 'Series not found');
|
||||
const { data, items } = await passes.cleanPassInput(body, seriesId);
|
||||
assertCampuses(ctx, (items || []).map(i => i.campusId));
|
||||
const pass = await prisma.seasonPass.create({ data: { ...data, seriesId, items: { create: (items || []).map(({ campusId: _c, ...i }) => i) } } });
|
||||
auditPass(actor, ctx, pass, 'created', { events: (items || []).length, price: pass.price });
|
||||
return passes.passView(await passes.loadPass(pass.id));
|
||||
});
|
||||
}
|
||||
|
||||
/** Changes a pass. Its events and price can't change once somebody has bought it. */
|
||||
export function updatePass(passId: string, body: any, actor: Actor, ctx: Ctx) {
|
||||
return asServiceError(async () => {
|
||||
const current = await passes.loadPass(passId);
|
||||
assertCampuses(ctx, current.items.map((i: any) => i.event.campusId));
|
||||
const { data, items } = await passes.cleanPassInput(body, current.seriesId, current);
|
||||
const bought = await prisma.seasonPassPurchase.count({ where: { passId: current.id } });
|
||||
if (bought > 0 && (items || (data.price !== undefined && data.price !== current.price))) {
|
||||
fail(409, 'People have bought this pass, so its events and price can\'t change. Switch it off and make a new one instead.');
|
||||
}
|
||||
if (items) assertCampuses(ctx, items.map(i => i.campusId));
|
||||
await prisma.$transaction(async tx => {
|
||||
await tx.seasonPass.update({ where: { id: current.id }, data });
|
||||
if (items) {
|
||||
await tx.seasonPassItem.deleteMany({ where: { passId: current.id } });
|
||||
await tx.seasonPassItem.createMany({ data: items.map(({ campusId: _c, ...i }) => ({ ...i, passId: current.id })) });
|
||||
}
|
||||
});
|
||||
auditPass(actor, ctx, current, 'updated', { changedKeys: [...Object.keys(data), ...(items ? ['items'] : [])] });
|
||||
return passes.passView(await passes.loadPass(current.id));
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes a pass nobody has bought (otherwise switch it off). */
|
||||
export function deletePass(passId: string, actor: Actor, ctx: Ctx) {
|
||||
return asServiceError(async () => {
|
||||
const current = await passes.loadPass(passId);
|
||||
assertCampuses(ctx, current.items.map((i: any) => i.event.campusId));
|
||||
if (await prisma.seasonPassPurchase.count({ where: { passId: current.id } })) fail(409, 'People have bought this pass, so it can\'t be deleted — switch it off instead.');
|
||||
await prisma.seasonPass.delete({ where: { id: current.id } });
|
||||
auditPass(actor, ctx, current, 'deleted');
|
||||
});
|
||||
}
|
||||
|
||||
const frontendUrl = () => String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const backTo = (slug: string) => {
|
||||
const back = `${frontendUrl()}/series/${slug}`;
|
||||
return { successUrl: `${back}?pass=paid`, cancelUrl: `${back}?pass=cancelled`, failureUrl: `${back}?pass=failed` };
|
||||
};
|
||||
|
||||
/** Buys passes: registers the buyer at every event on it, then a checkout (or tickets when it's free). */
|
||||
export function buyPass(passId: string, userId: string, opts: { quantity?: number; provider?: string | null }) {
|
||||
return asServiceError(async () => {
|
||||
const pass = await prisma.seasonPass.findUnique({ where: { id: passId }, select: { series: { select: { slug: true } } } });
|
||||
if (!pass) return fail(404, 'Season pass not found');
|
||||
return passes.buyPass(passId, userId, { quantity: opts.quantity ?? 1, provider: opts.provider || null, ...backTo(pass.series.slug) });
|
||||
});
|
||||
}
|
||||
|
||||
/** Pays what's still owed on a pass purchase (a new checkout). The buyer only. */
|
||||
export function payPassPurchase(purchaseId: string, userId: string, opts: { provider?: string | null }) {
|
||||
return asServiceError(async () => {
|
||||
const purchase = await prisma.seasonPassPurchase.findUnique({ where: { id: purchaseId }, select: { userId: true, pass: { select: { series: { select: { slug: true } } } } } });
|
||||
if (!purchase || purchase.userId !== userId) return fail(404, 'Purchase not found');
|
||||
return passes.startPassCheckout(purchaseId, userId, { provider: opts.provider || null, ...backTo(purchase.pass.series.slug) });
|
||||
});
|
||||
}
|
||||
|
||||
export function myPassPurchases(userId: string) {
|
||||
return prisma.seasonPassPurchase.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true, quantity: true, total: true, status: true, createdAt: true,
|
||||
pass: { select: { id: true, name: true, series: { select: { name: true, slug: true } } } },
|
||||
registrations: { select: { id: true, status: true, event: { select: { id: true, title: true, startDate: true } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -46,12 +46,120 @@ const SUCCESSORS: Successor[] = [
|
||||
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/copy-from$/, v2: m => `/api/v2/events/${m[1]}/copy-from` },
|
||||
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
|
||||
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
|
||||
// 1.51.11: money (payments, cash-ups, provider transactions, waitlist, sponsorship, consent, forms, feedback)
|
||||
{ method: 'GET', v1: /^\/api\/payments\/providers$/, v2: () => '/api/v2/payments/providers' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/providers\/settings$/, v2: () => '/api/v2/payments/providers/settings' },
|
||||
{ method: 'PUT', v1: /^\/api\/payments\/providers\/settings$/, v2: () => '/api/v2/payments/providers/settings' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/pay\/([^/]+)$/, v2: m => `/api/v2/payment-links/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/pay\/([^/]+)$/, v2: m => `/api/v2/payment-links/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/checkouts\/open$/, v2: () => '/api/v2/payments/open-checkouts' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/checkouts\/check$/, v2: () => '/api/v2/payments/open-checkouts/check' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/(yoco-)?checkout\/send$/, v2: () => '/api/v2/registrations/{id}/payment-link' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/mypayments$/, v2: () => '/api/v2/me/payments' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/admin\/stats$/, v2: () => '/api/v2/payments/stats' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/registration\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/payments` },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/payments` },
|
||||
{ method: 'PUT', v1: /^\/api\/payments\/assign-donation$/, v2: () => '/api/v2/payments/donations/assign' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/unassign-donation$/, v2: () => '/api/v2/payments/donations/unassign' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/refund$/, v2: () => '/api/v2/payments/refunds' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/([^/]+)\/send-receipt$/, v2: m => `/api/v2/payments/${m[1]}/receipt` },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/?$/, v2: () => '/api/v2/payments' },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/?$/, v2: () => '/api/v2/payments' },
|
||||
{ method: 'GET', v1: /^\/api\/payments\/([^/]+)$/, v2: m => `/api/v2/payments/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/cashups\/audit$/, v2: () => '/api/v2/cashups/audit' },
|
||||
{ method: 'GET', v1: /^\/api\/cashups\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/cashup` },
|
||||
{ method: 'GET', v1: /^\/api\/cashups\/event\/([^/]+)\/cash-by-user$/, v2: m => `/api/v2/events/${m[1]}/cashup/cash-by-user` },
|
||||
{ method: 'PUT', v1: /^\/api\/cashups\/event\/([^/]+)\/person-cash\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/cashup/person-cash/${m[2]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/cashups\/event\/([^/]+)\/draft$/, v2: m => `/api/v2/events/${m[1]}/cashup/draft` },
|
||||
{ method: 'POST', v1: /^\/api\/cashups\/event\/([^/]+)\/close$/, v2: m => `/api/v2/events/${m[1]}/cashup/close` },
|
||||
{ method: 'POST', v1: /^\/api\/cashups\/event\/([^/]+)\/reopen$/, v2: m => `/api/v2/events/${m[1]}/cashup/reopen` },
|
||||
{ method: 'POST', v1: /^\/api\/cashups\/lines\/([^/]+)\/pending$/, v2: m => `/api/v2/cashup-lines/${m[1]}/pending` },
|
||||
{ method: 'GET', v1: /^\/api\/(provider|yoco)-transactions\/?$/, v2: () => '/api/v2/provider-transactions' },
|
||||
{ method: 'GET', v1: /^\/api\/(provider|yoco)-transactions\/unreconciled$/, v2: () => '/api/v2/provider-transactions/unreconciled' },
|
||||
{ method: 'POST', v1: /^\/api\/(?:provider|yoco)-transactions\/([^/]+)\/reconcile$/, v2: m => `/api/v2/provider-transactions/${m[1]}/reconcile` },
|
||||
{ method: 'POST', v1: /^\/api\/(?:provider|yoco)-transactions\/([^/]+)\/ignore$/, v2: m => `/api/v2/provider-transactions/${m[1]}/ignore` },
|
||||
{ method: 'POST', v1: /^\/api\/waitlist\/?$/, v2: () => '/api/v2/waitlist' },
|
||||
{ method: 'GET', v1: /^\/api\/waitlist\/mine$/, v2: () => '/api/v2/me/waitlist' },
|
||||
{ method: 'GET', v1: /^\/api\/waitlist\/claim\/([^/]+)$/, v2: m => `/api/v2/waitlist/claims/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/waitlist\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/waitlist` },
|
||||
{ method: 'POST', v1: /^\/api\/waitlist\/([^/]+)\/(offer|move|remove)$/, v2: m => `/api/v2/waitlist/${m[1]}/${m[2]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/waitlist\/([^/]+)$/, v2: m => `/api/v2/waitlist/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/sponsorships\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/sponsorship` },
|
||||
{ method: 'GET', v1: /^\/api\/sponsorships\/event\/([^/]+)\/pool$/, v2: m => `/api/v2/events/${m[1]}/sponsorship/pool` },
|
||||
{ method: 'POST', v1: /^\/api\/sponsorships\/checkout$/, v2: () => '/api/v2/sponsorships/checkout' },
|
||||
{ method: 'GET', v1: /^\/api\/consent\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/consent` },
|
||||
{ method: 'GET', v1: /^\/api\/consent\/events\/([^/]+)\/medical$/, v2: m => `/api/v2/events/${m[1]}/consent/medical` },
|
||||
{ method: 'POST', v1: /^\/api\/consent\/events\/([^/]+)\/(resend-pending|rerequest)$/, v2: m => `/api/v2/events/${m[1]}/consent/${m[2]}` },
|
||||
{ method: 'POST', v1: /^\/api\/consent\/requests\/([^/]+)\/resend$/, v2: m => `/api/v2/consent-requests/${m[1]}/resend` },
|
||||
{ method: 'GET', v1: /^\/api\/consent\/([^/]+)$/, v2: m => `/api/v2/consent/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/consent\/([^/]+)$/, v2: m => `/api/v2/consent/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/forms\/responses$/, v2: () => '/api/v2/form-responses' },
|
||||
{ method: 'GET', v1: /^\/api\/feedback\/summary$/, v2: () => '/api/v2/feedback/summary' },
|
||||
{ method: 'GET', v1: /^\/api\/feedback\/public\/([^/]+)$/, v2: m => `/api/v2/feedback-forms/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/feedback\/public\/([^/]+)$/, v2: m => `/api/v2/feedback-forms/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
|
||||
{ method: 'PUT', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
|
||||
{ method: 'DELETE', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
|
||||
{ method: 'POST', v1: /^\/api\/feedback\/event\/([^/]+)\/send-now$/, v2: m => `/api/v2/events/${m[1]}/feedback/send-now` },
|
||||
// 1.51.10: managing registrations
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/myregistrations$/, v2: () => '/api/v2/me/registrations/full' },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/registrations` },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/manual$/, v2: () => '/api/v2/registrations/manual' },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/bulk$/, v2: () => '/api/v2/registrations/bulk' },
|
||||
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/options$/, v2: m => `/api/v2/registrations/${m[1]}/items` },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/send-invoice$/, v2: m => `/api/v2/registrations/${m[1]}/invoice` },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/coupon$/, v2: m => `/api/v2/registrations/${m[1]}/coupon` },
|
||||
{ method: 'DELETE', v1: /^\/api\/registrations\/([^/]+)\/coupon$/, v2: m => `/api/v2/registrations/${m[1]}/coupon` },
|
||||
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/attendees$/, v2: m => `/api/v2/registrations/${m[1]}/attendees` },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/attendees\/([^/]+)\/send-form-link$/, v2: m => `/api/v2/registrations/${m[1]}/attendees/${m[2]}/form-link` },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/([^/]+)\/forms\/draft$/, v2: m => `/api/v2/registrations/${m[1]}/form-draft` },
|
||||
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/forms\/draft$/, v2: m => `/api/v2/registrations/${m[1]}/form-draft` },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/([^/]+)\/forms\/responses$/, v2: m => `/api/v2/registrations/${m[1]}/form-responses` },
|
||||
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)\/forms\/responses$/, v2: m => `/api/v2/registrations/${m[1]}/form-responses` },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/full` },
|
||||
{ method: 'PUT', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/status` },
|
||||
// 1.51.9: series, season passes, bundles, coupons, campuses
|
||||
{ method: 'GET', v1: /^\/api\/series\/?$/, v2: () => '/api/v2/series' },
|
||||
{ method: 'POST', v1: /^\/api\/series\/?$/, v2: () => '/api/v2/series' },
|
||||
{ method: 'GET', v1: /^\/api\/series\/by-slug\/([^/]+)$/, v2: m => `/api/v2/series/by-slug/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/series\/([^/]+)\/compare$/, v2: m => `/api/v2/series/${m[1]}/compare` },
|
||||
{ method: 'POST', v1: /^\/api\/series\/([^/]+)\/follow$/, v2: m => `/api/v2/series/${m[1]}/follow` },
|
||||
{ method: 'DELETE', v1: /^\/api\/series\/([^/]+)\/follow$/, v2: m => `/api/v2/series/${m[1]}/follow` },
|
||||
{ method: 'PUT', v1: /^\/api\/series\/([^/]+)$/, v2: m => `/api/v2/series/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/series\/([^/]+)$/, v2: m => `/api/v2/series/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/season-passes\/mine$/, v2: () => '/api/v2/me/season-passes' },
|
||||
{ method: 'POST', v1: /^\/api\/season-passes\/purchases\/([^/]+)\/checkout$/, v2: m => `/api/v2/season-pass-purchases/${m[1]}/checkout` },
|
||||
{ method: 'POST', v1: /^\/api\/season-passes\/([^/]+)\/buy$/, v2: m => `/api/v2/season-passes/${m[1]}/buy` },
|
||||
{ method: 'GET', v1: /^\/api\/season-passes\/?$/, v2: () => '/api/v2/season-passes' },
|
||||
{ method: 'POST', v1: /^\/api\/season-passes\/?$/, v2: () => '/api/v2/season-passes' },
|
||||
{ method: 'PUT', v1: /^\/api\/season-passes\/([^/]+)$/, v2: m => `/api/v2/season-passes/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/season-passes\/([^/]+)$/, v2: m => `/api/v2/season-passes/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/bundles\/quote$/, v2: () => '/api/v2/bundles/quote' },
|
||||
{ method: 'GET', v1: /^\/api\/bundles\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/bundles` },
|
||||
{ method: 'POST', v1: /^\/api\/bundles\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/bundles` },
|
||||
{ method: 'PUT', v1: /^\/api\/bundles\/([^/]+)$/, v2: m => `/api/v2/bundles/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/bundles\/([^/]+)$/, v2: m => `/api/v2/bundles/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/coupons\/validate$/, v2: () => '/api/v2/coupons/check' },
|
||||
{ method: 'GET', v1: /^\/api\/coupons\/redemptions$/, v2: () => '/api/v2/coupons/redemptions' },
|
||||
{ method: 'GET', v1: /^\/api\/coupons\/?$/, v2: () => '/api/v2/coupons' },
|
||||
{ method: 'POST', v1: /^\/api\/coupons\/?$/, v2: () => '/api/v2/coupons' },
|
||||
{ method: 'GET', v1: /^\/api\/coupons\/([^/]+)$/, v2: m => `/api/v2/coupons/${m[1]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/coupons\/([^/]+)$/, v2: m => `/api/v2/coupons/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/campuses\/?$/, v2: () => '/api/v2/campuses' },
|
||||
{ method: 'GET', v1: /^\/api\/campuses\/admin$/, v2: () => '/api/v2/campuses/admin' },
|
||||
{ method: 'GET', v1: /^\/api\/campuses\/by-slug\/([^/]+)$/, v2: m => `/api/v2/campuses/by-slug/${m[1]}` },
|
||||
{ method: 'PUT', v1: /^\/api\/campuses\/settings$/, v2: () => '/api/v2/campuses/enabled' },
|
||||
{ method: 'PUT', v1: /^\/api\/campuses\/order$/, v2: () => '/api/v2/campuses/order' },
|
||||
{ method: 'POST', v1: /^\/api\/campuses\/?$/, v2: () => '/api/v2/campuses' },
|
||||
{ method: 'GET', v1: /^\/api\/campuses\/([^/]+)\/audience$/, v2: m => `/api/v2/campuses/${m[1]}/audience` },
|
||||
{ method: 'PUT', v1: /^\/api\/campuses\/([^/]+)$/, v2: m => `/api/v2/campuses/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/campuses\/([^/]+)$/, v2: m => `/api/v2/campuses/${m[1]}` },
|
||||
{ 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' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/login$/, v2: () => '/api/v2/auth/login' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/login\/2fa$/, v2: () => '/api/v2/auth/login/code' },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/myregistrations$/, v2: () => '/api/v2/me/registrations' },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
|
||||
{ method: 'DELETE', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/cancel` },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/checkout$/, v2: () => '/api/v2/registrations/{id}/checkout' },
|
||||
|
||||
@@ -31,6 +31,8 @@ export class ApiError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly status: number;
|
||||
readonly fields?: Record<string, string>;
|
||||
/** A more specific cause, e.g. CAMPUS_HAS_EVENTS or TWO_FACTOR_REQUIRED. */
|
||||
reason?: string;
|
||||
|
||||
constructor(code: ErrorCode, message: string, fields?: Record<string, string>) {
|
||||
super(message);
|
||||
@@ -69,14 +71,19 @@ export function toApiError(err: unknown, responseStatus: number): ApiError {
|
||||
// utils throw errors with statusCode. Those messages are written for people already.
|
||||
const status = e.statusCode || e.status || (responseStatus >= 400 ? responseStatus : 500);
|
||||
const code = STATUS_CODE[status];
|
||||
if (code) return new ApiError(code, e.message || 'Request failed.');
|
||||
if (code) {
|
||||
const out = new ApiError(code, e.message || 'Request failed.');
|
||||
// A service's own code (not a Prisma one) says more precisely what happened.
|
||||
if (typeof e.code === 'string' && e.code && !/^P\d{4}$/.test(e.code)) out.reason = e.code;
|
||||
return out;
|
||||
}
|
||||
return new ApiError('INTERNAL', 'Something went wrong on the server.');
|
||||
}
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
|
||||
const apiErr = toApiError(err, res.statusCode);
|
||||
if (apiErr.code === 'INTERNAL') console.error('[api/v2]', req.method, req.originalUrl, err);
|
||||
res.status(apiErr.status).json({ error: { code: apiErr.code, message: apiErr.message, ...(apiErr.fields ? { fields: apiErr.fields } : {}) } });
|
||||
res.status(apiErr.status).json({ error: { code: apiErr.code, message: apiErr.message, ...(apiErr.fields ? { fields: apiErr.fields } : {}), ...(apiErr.reason ? { reason: apiErr.reason } : {}) } });
|
||||
};
|
||||
|
||||
export const notFoundHandler: RequestHandler = (_req, _res, next) => next(new ApiError('NOT_FOUND', 'There is no such API endpoint.'));
|
||||
|
||||
@@ -9,9 +9,12 @@ import auth from '../middleware/authMiddleware';
|
||||
import './resources/eventsManage';
|
||||
import './resources/eventDetails';
|
||||
import './resources/setup';
|
||||
import './resources/seriesDeals';
|
||||
import './resources/events';
|
||||
import './resources/me';
|
||||
import './resources/registrations';
|
||||
import './resources/registrationsManage';
|
||||
import './resources/money';
|
||||
import './resources/checkin';
|
||||
import './resources/auth';
|
||||
|
||||
@@ -84,7 +87,7 @@ const DOCS_HTML = `<!doctype html>
|
||||
sessionStorage.setItem('v2docs_token', t);
|
||||
if (params.has('token')) history.replaceState(null, '', location.pathname);
|
||||
document.getElementById('login').hidden = true;
|
||||
var ui = SwaggerUIBundle({ spec: spec, dom_id: '#ui', deepLinking: true, persistAuthorization: false });
|
||||
var ui = SwaggerUIBundle({ spec: spec, dom_id: '#ui', deepLinking: true, persistAuthorization: false, docExpansion: 'none', filter: true });
|
||||
ui.preauthorizeApiKey('bearerAuth', t);
|
||||
}).catch(function (e) {
|
||||
document.getElementById('login').hidden = false;
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Money on v2 (1.51.11): payments and refunds, payment links and providers, cash-ups, provider
|
||||
* transactions, the waitlist, sponsorship, consent, attendee form answers and feedback. Each
|
||||
* route runs the v1 handler (viaV1.ts) behind the same access and campus checks as v1.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { id, idParams } from '../schemas';
|
||||
import { listData, v1Route } from '../viaV1';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
|
||||
const rateLimit = require('express-rate-limit') as (opts: object) => any;
|
||||
const cs = require('../../utils/campusScope') as {
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown; ownerOk?: boolean }): any;
|
||||
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
|
||||
scopeList: any;
|
||||
unscopedOnly: any;
|
||||
};
|
||||
const pay = require('../../controllers/paymentController');
|
||||
const providers = require('../../controllers/paymentProvidersController');
|
||||
const cashup = require('../../controllers/cashupController');
|
||||
const ptx = require('../../controllers/providerTransactionsController');
|
||||
const waitlist = require('../../controllers/waitlistController');
|
||||
const sponsor = require('../../controllers/sponsorshipController');
|
||||
const consent = require('../../controllers/consentController');
|
||||
const forms = require('../../controllers/formController');
|
||||
const feedback = require('../../controllers/feedbackController');
|
||||
|
||||
/** Optional text filters, passed to v1 as they come. */
|
||||
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
|
||||
const paymentFilters = filters('page', 'limit', 'userId', 'from', 'donations', 'method', 'kind', 'startDate', 'endDate');
|
||||
const tokenParams = z.object({ token: z.string().min(1).max(200) });
|
||||
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
|
||||
// Links in messages are credentials, so guessing stays slow (as on v1).
|
||||
const linkLimiter = () => rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
|
||||
const UNSCOPED = 'unscopedOnly (admins and unscoped supervisors)';
|
||||
|
||||
// ─── Payments ────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/providers', tag: 'Payments', summary: 'The payment providers switched on',
|
||||
access: 'public', v1: providers.listPaymentProviders,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/providers/settings', tag: 'Payments', summary: 'Payment provider settings',
|
||||
description: 'Admins. Keys are masked.', access: 'admin', v1: providers.getPaymentProviderSettings,
|
||||
});
|
||||
v1Route({
|
||||
method: 'put', path: '/payments/providers/settings', tag: 'Payments', summary: 'Change payment provider settings',
|
||||
access: 'admin', body: z.looseObject({}), v1: providers.updatePaymentProviderSettings,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/open-checkouts', tag: 'Payments', summary: 'Checkouts started but not finished',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], v1: providers.listOpenCheckouts,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/open-checkouts/check', tag: 'Payments', summary: 'Ask the provider about open checkouts',
|
||||
description: 'Records any that were paid (for when a webhook was missed).',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], body: z.looseObject({}), v1: providers.checkCheckoutStatus,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/stats', tag: 'Payments', summary: 'Payment totals', access: 'admin', query: paymentFilters, v1: pay.getPaymentStats,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments', tag: 'Payments', summary: 'Every payment',
|
||||
description: 'For the events you may see. `page` and `limit` page it (otherwise everything); see v1 for the filters it takes.',
|
||||
access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: paymentFilters, v1: pay.getPayments,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments', tag: 'Payments', summary: 'Record a payment',
|
||||
description: 'Cash, card, EFT or other, against a registration or (a donation) an event. Paid in full issues and sends the tickets.',
|
||||
access: 'supervisor', campusScope: 'scopeBody(registrationId, eventId; deny orphans)', status: 201,
|
||||
middleware: [cs.scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' })],
|
||||
body: z.looseObject({ registrationId: id.nullish(), eventId: id.nullish(), amount: z.union([z.number(), z.string()]), method: z.string().max(20).optional() }),
|
||||
v1: pay.createPayment,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/refunds', tag: 'Payments', summary: 'Refund a payment',
|
||||
access: 'supervisor', campusScope: 'scopeBody(paymentId, registrationId; manage, deny orphans)', status: 201,
|
||||
middleware: [cs.scopeBody([['paymentId', 'payment'], ['registrationId', 'registration']], { need: 'manage', orphan: 'deny' })],
|
||||
body: z.looseObject({ paymentId: id.optional(), registrationId: id.optional() }), v1: pay.createRefund,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/donations/assign', tag: 'Payments', summary: 'Put a donation towards a registration',
|
||||
access: 'supervisor', campusScope: 'scopeBody(paymentId deny orphans; registrationId)',
|
||||
middleware: [cs.scopeBody([['paymentId', 'payment']], { orphan: 'deny' }), cs.scopeBody([['registrationId', 'registration']])],
|
||||
body: z.looseObject({ paymentId: id, registrationId: id }), v1: pay.assignDonationToRegistration,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/donations/unassign', tag: 'Payments', summary: 'Take a donation back off a registration',
|
||||
access: 'supervisor', campusScope: 'scopeBody(legId; deny orphans)', middleware: [cs.scopeBody([['legId', 'payment']], { orphan: 'deny' })],
|
||||
body: z.looseObject({ legId: id }), v1: pay.unassignDonationFromRegistration,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/donations/checkout', tag: 'Payments', summary: 'Give to an event',
|
||||
description: 'Starts a checkout for a donation of `amount` to `eventId`; answers where to send the person (`redirectUrl`).',
|
||||
access: 'user', body: z.looseObject({ eventId: id, amount: z.union([z.number(), z.string()]) }), v1: pay.createCheckout,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/me/payments', tag: 'Me', summary: 'My payments', access: 'user', query: paymentFilters, v1: pay.getUserPayments,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payments/:id', tag: 'Payments', summary: 'One payment',
|
||||
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams, v1: pay.getPaymentById,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payments/:id/receipt', tag: 'Payments', summary: 'Send the receipt again',
|
||||
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams,
|
||||
body: z.looseObject({ channel: z.string().max(20).optional() }), v1: pay.sendReceipt,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/registrations/:id/payments', tag: 'Payments', summary: 'A registration\'s payments',
|
||||
access: 'user', campusScope: 'scopeEvent(registration, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'registration', ownerOk: true })], params: idParams,
|
||||
response: listData, map: ctx => ({ params: { registrationId: ctx.params.id } }), v1: pay.getPaymentsByRegistration,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/registrations/:id/payment-link', tag: 'Payments', summary: 'Make a link to pay',
|
||||
description: 'Supervisors: a checkout for `amount` (part of what\x27s owed) for someone else to pay. Answers `redirectUrl` (the link) — or `priceUpdated` when an early-bird price ran out.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
|
||||
body: z.looseObject({ amount: z.union([z.number(), z.string()]).optional() }), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), v1: pay.createCheckout,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/registrations/:id/payment-link/send', tag: 'Payments', summary: 'Send someone a link to pay',
|
||||
description: 'By email and/or WhatsApp, for what\'s still owing.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
|
||||
body: z.looseObject({}), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), v1: pay.sendPaymentLink,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/payments', tag: 'Payments', summary: 'An event\'s payments',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: pay.getPaymentsByEvent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/payment-links/:id', tag: 'Payments', summary: 'A pay page',
|
||||
description: 'What a payment link shows: what\'s owed and the providers to pay with. The id in the link is the credential.',
|
||||
access: 'public', params: idParams, v1: providers.getPayPage,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/payment-links/:id', tag: 'Payments', summary: 'Pay from a pay page',
|
||||
description: 'Starts a checkout with the chosen provider; answers where to send the person.',
|
||||
access: 'public', params: idParams, body: z.looseObject({ provider: z.string().max(40).optional() }), v1: providers.choosePayProvider,
|
||||
});
|
||||
|
||||
// ─── Cash-ups ────────────────────────────────────────────────────────────────
|
||||
|
||||
const cashupBase = '/events/:id/cashup';
|
||||
v1Route({
|
||||
method: 'get', path: '/cashups/audit', tag: 'Cash-ups', summary: 'Cash-up history', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'from', 'to'), v1: cashup.getCashupAudit,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: cashupBase, tag: 'Cash-ups', summary: 'An event\'s cash-up',
|
||||
description: 'Income by method, costs, what each drawer should hold, and the saved draft.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: cashup.getEventCashup,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: `${cashupBase}/cash-by-user`, tag: 'Cash-ups', summary: 'Cash taken by each person',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: cashup.getCashByRecordedUser,
|
||||
});
|
||||
v1Route({
|
||||
method: 'put', path: `${cashupBase}/person-cash/:userId`, tag: 'Cash-ups', summary: 'Count one person\'s cash',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: z.object({ id, userId: id }),
|
||||
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id, userId: ctx.params.userId } }), v1: cashup.savePersonCash,
|
||||
});
|
||||
v1Route({
|
||||
method: 'put', path: `${cashupBase}/draft`, tag: 'Cash-ups', summary: 'Save the cash-up as a draft',
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.saveEventCashupDraft,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: `${cashupBase}/close`, tag: 'Cash-ups', summary: 'Close the event',
|
||||
description: 'Admins. Nothing about the event\'s money can change until it\'s reopened.',
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.closeEvent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: `${cashupBase}/reopen`, tag: 'Cash-ups', summary: 'Reopen a closed event',
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.reopenEvent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/cashup-lines/:id/pending', tag: 'Cash-ups', summary: 'Settle a pending cash-up line',
|
||||
access: 'admin', params: idParams, body: z.looseObject({}), map: ctx => ({ params: { lineId: ctx.params.id } }), v1: cashup.resolvePending,
|
||||
});
|
||||
|
||||
// ─── Provider transactions ───────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/provider-transactions', tag: 'Payments', summary: 'Transactions from Yoco and PayFast',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], query: filters('provider'), v1: ptx.getAllProviderTransactions,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/provider-transactions/unreconciled', tag: 'Payments', summary: 'Provider transactions not matched yet',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], v1: ptx.getUnreconciledProviderTransactions,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/provider-transactions/:id/reconcile', tag: 'Payments', summary: 'Match a provider transaction to a registration',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), v1: ptx.reconcileProviderTransaction,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/provider-transactions/:id/ignore', tag: 'Payments', summary: 'Ignore a provider transaction',
|
||||
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), v1: ptx.ignoreProviderTransaction,
|
||||
});
|
||||
|
||||
// ─── Waitlist ────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'post', path: '/waitlist', tag: 'Waitlist', summary: 'Join a sold-out event\'s waitlist',
|
||||
description: 'Signed in, or with a name and email or phone. When places free up, people are offered them in order.',
|
||||
access: 'optional', status: 201, body: z.looseObject({ eventId: id }), v1: waitlist.joinWaitlist,
|
||||
});
|
||||
v1Route({ method: 'get', path: '/me/waitlist', tag: 'Me', summary: 'Waitlists I\'m on', access: 'user', v1: waitlist.myWaitlist });
|
||||
v1Route({
|
||||
method: 'get', path: '/waitlist/claims/:token', tag: 'Waitlist', summary: 'A waitlist offer',
|
||||
description: 'What an offer link shows (the token is the credential). Register with `waitlistToken` to take it.',
|
||||
access: 'public', middleware: [linkLimiter()], params: tokenParams, v1: waitlist.claimInfo,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/waitlist', tag: 'Waitlist', summary: 'An event\'s waitlist',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, query: filters('all'), map: asEvent, v1: waitlist.eventWaitlist,
|
||||
});
|
||||
for (const [action, summary, handler] of [['offer', 'Offer someone their places now', waitlist.offerManually], ['move', 'Move someone up or down the waitlist', waitlist.moveEntry], ['remove', 'Take someone off the waitlist', waitlist.removeEntry]] as const) {
|
||||
v1Route({
|
||||
method: 'post', path: `/waitlist/:id/${action}`, tag: 'Waitlist', summary,
|
||||
access: 'supervisor', campusScope: 'scopeEvent(waitlistEntry)', middleware: [cs.scopeEvent({ resolve: 'waitlistEntry' })], params: idParams, body: z.looseObject({}), v1: handler,
|
||||
});
|
||||
}
|
||||
v1Route({ method: 'delete', path: '/waitlist/:id', tag: 'Waitlist', summary: 'Leave a waitlist', access: 'user', params: idParams, v1: waitlist.leaveWaitlist });
|
||||
|
||||
// ─── Sponsorship ─────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/sponsorship', tag: 'Sponsorship', summary: 'Sponsoring places at an event',
|
||||
description: 'Whether it\'s on, and the suggested amounts.', access: 'public', params: idParams, map: asEvent, v1: sponsor.getEventSponsorship,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/sponsorship/pool', tag: 'Sponsorship', summary: 'The sponsorship pool',
|
||||
description: 'What\'s been given and what\'s been used.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: sponsor.getPool,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/sponsorships/checkout', tag: 'Sponsorship', summary: 'Give towards places',
|
||||
description: 'Starts a checkout; answers where to send the person.', access: 'optional', body: z.looseObject({ eventId: id }), v1: sponsor.createSponsorCheckout,
|
||||
});
|
||||
|
||||
// ─── Consent ─────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/consent', tag: 'Consent', summary: 'Parental consent for an event',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: consent.listEventConsent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/consent/medical', tag: 'Consent', summary: 'Medical notes from consent forms',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: consent.medicalList,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/consent/resend-pending', tag: 'Consent', summary: 'Remind everyone who hasn\'t answered',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: z.looseObject({}), map: asEvent, v1: consent.resendPending,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/consent/rerequest', tag: 'Consent', summary: 'Ask for consent again',
|
||||
description: 'After the consent wording changed.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams,
|
||||
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id }, body: ctx.req.body }), v1: consent.rerequestConsent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/consent-requests/:id/resend', tag: 'Consent', summary: 'Send a consent request again',
|
||||
access: 'user', campusScope: 'scopeEvent(consentRequest, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'consentRequest', ownerOk: true })], params: idParams,
|
||||
body: z.looseObject({}), v1: consent.resendRequest,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/consent/:token', tag: 'Consent', summary: 'A consent form',
|
||||
description: 'What the link in a consent request shows (the token is the parent\'s credential).',
|
||||
access: 'public', middleware: [linkLimiter()], params: tokenParams, v1: consent.getConsent,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/consent/:token', tag: 'Consent', summary: 'Answer a consent form',
|
||||
access: 'public', middleware: [linkLimiter()], params: tokenParams, body: z.looseObject({}), v1: consent.answerConsent,
|
||||
});
|
||||
|
||||
// ─── Attendee form answers ───────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/form-responses', tag: 'Registrations', summary: 'Attendee form answers',
|
||||
description: 'For an event (`eventId`) or a registration (`registrationId`), for events you may see.',
|
||||
access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'registrationId', 'userId', 'cursor'), v1: forms.listFormResponses,
|
||||
});
|
||||
|
||||
// ─── Feedback ────────────────────────────────────────────────────────────────
|
||||
|
||||
v1Route({
|
||||
method: 'get', path: '/feedback/summary', tag: 'Feedback', summary: 'Feedback across events', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventIds'), v1: feedback.getSummary,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/feedback-forms/:token', tag: 'Feedback', summary: 'A feedback form',
|
||||
description: 'What the link sent after an event shows (the token is the credential).', access: 'public', params: tokenParams, v1: feedback.getPublicForm,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/feedback-forms/:token', tag: 'Feedback', summary: 'Answer a feedback form',
|
||||
access: 'public', params: tokenParams, body: z.looseObject({}), v1: feedback.submitPublicForm,
|
||||
});
|
||||
v1Route({
|
||||
method: 'get', path: '/events/:id/feedback', tag: 'Feedback', summary: 'An event\'s feedback set-up and answers',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: feedback.getEventFeedback,
|
||||
});
|
||||
v1Route({
|
||||
method: 'put', path: '/events/:id/feedback', tag: 'Feedback', summary: 'Set up an event\'s feedback form',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, v1: feedback.saveEventFeedback,
|
||||
});
|
||||
v1Route({
|
||||
method: 'delete', path: '/events/:id/feedback', tag: 'Feedback', summary: 'Remove an event\'s feedback form',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: feedback.deleteEventFeedback,
|
||||
});
|
||||
v1Route({
|
||||
method: 'post', path: '/events/:id/feedback/send-now', tag: 'Feedback', summary: 'Send the feedback form now',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, v1: feedback.sendNow,
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { z } from 'zod';
|
||||
import type { Request } from 'express';
|
||||
import { defineRoute, type ApiUser } from '../route';
|
||||
import { pageOf, single } from '../paging';
|
||||
import { id, idParams } from '../schemas';
|
||||
import * as admin from '../../services/registrationAdmin';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- registrations are passed through with their includes */
|
||||
const campusScope = require('../../utils/campusScope') as {
|
||||
listWhere(req: unknown, path?: string): Promise<object>;
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown; ownerOk?: boolean }): any;
|
||||
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
|
||||
};
|
||||
const { notOnSeasonPass } = require('../../utils/seasonPass') as { notOnSeasonPass: any };
|
||||
const plain = <T>(v: T): any => JSON.parse(JSON.stringify(v));
|
||||
|
||||
/** The v2 request as the registration service's Call. */
|
||||
function callFrom(req: Request, user: ApiUser | null, parts: { params?: object; body?: object; query?: object }): admin.Call {
|
||||
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
|
||||
return {
|
||||
actor: user ? { id: user.id, role: user.role, email: user.email } : null,
|
||||
params: (parts.params ?? {}) as Record<string, string>,
|
||||
body: parts.body ?? {},
|
||||
query: parts.query ?? {},
|
||||
readOnly: !!(req as any).readOnly,
|
||||
ip: getClientIp(req),
|
||||
listWhere: () => campusScope.listWhere(req, 'event'),
|
||||
};
|
||||
}
|
||||
|
||||
const OWNER_OR_STAFF = 'scopeEvent(registration, owner-ok)';
|
||||
const ownerOrStaff = campusScope.scopeEvent({ resolve: 'registration', ownerOk: true });
|
||||
const STATUSES = ['pending', 'confirmed', 'partial_paid', 'paid', 'cancelled'] as const;
|
||||
|
||||
/** A registration with everything the dashboards show: items and their prices, payments, attendees, coupons, `totalDueComputed`. */
|
||||
const fullRegistration = z.looseObject({ id, status: z.enum(STATUSES), eventId: id, userId: id, totalDueComputed: z.number().optional() });
|
||||
const message = single(z.looseObject({ message: z.string() }));
|
||||
const lineBody = z.object({ eventOptionId: id, variantId: id.nullish(), quantity: z.number().int().min(1).max(1000), sessionIds: z.array(id).max(200).optional() });
|
||||
const attendeeBody = z.looseObject({ id: id.optional().describe('An attendee already on it (left out = removed)'), eventOptionId: id, variantId: id.nullish(), name: z.string().max(200) });
|
||||
const responsesBody = z.object({
|
||||
responses: z.array(z.object({
|
||||
attendeeId: id.nullish().describe('The named attendee this form is for'),
|
||||
answers: z.record(z.string(), z.unknown()).describe('Field id → answer (stored as text)'),
|
||||
})).max(500),
|
||||
});
|
||||
|
||||
// ─── Owner (or staff) ────────────────────────────────────────────────────────
|
||||
|
||||
export const fullOne = defineRoute({
|
||||
method: 'get', path: '/registrations/:id/full', tag: 'Registrations', summary: 'A registration with everything on it',
|
||||
description: [
|
||||
'Items and prices, payments, coupons and bundles, named attendees, form answers and the total due.',
|
||||
'The owner, a named attendee or staff — or anyone with the link when not signed in (registration ids can\'t be guessed). Attendees\' contact details are only shown to the payer and staff.',
|
||||
].join('\n\n'),
|
||||
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams, response: single(fullRegistration),
|
||||
async handler({ params, user, req }) { return { data: plain(await admin.getRegistrationById(callFrom(req, user, { params }))) }; },
|
||||
});
|
||||
|
||||
export const myFull = defineRoute({
|
||||
method: 'get', path: '/me/registrations/full', tag: 'Me', summary: 'My registrations with everything on them',
|
||||
description: 'What the dashboard shows: as `/registrations/{id}/full`, newest first, with the event and its form. Only upcoming ones unless `past=true`.',
|
||||
access: 'user',
|
||||
query: z.object({ past: z.enum(['true', 'false']).default('false') }),
|
||||
response: z.object({ data: z.array(fullRegistration) }),
|
||||
async handler({ query, user, req }) { return { data: plain(await admin.getUserRegistrations(callFrom(req, user, { query: { showPast: query.past } }))) }; },
|
||||
});
|
||||
|
||||
export const changeItems = defineRoute({
|
||||
method: 'put', path: '/registrations/:id/items', tag: 'Registrations', summary: 'Change the tickets on a registration',
|
||||
description: [
|
||||
'The whole list of items it should have. Added places need stock and are priced now; places already paid for keep their price, and nothing goes below tickets already issued or the amount paid.',
|
||||
'Not for registrations made with a season pass, nor once the event has ended or is closed. Staff can add kids and volunteer places; owners can only keep or lower them.',
|
||||
].join('\n\n'),
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff, notOnSeasonPass], params: idParams,
|
||||
body: z.object({ options: z.array(lineBody.omit({ sessionIds: true })).min(1).max(100) }), response: single(fullRegistration),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.updateRegistrationOptions(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const sendInvoice = defineRoute({
|
||||
method: 'post', path: '/registrations/:id/invoice', tag: 'Registrations', summary: 'Send me the invoice again',
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
|
||||
body: z.object({ channel: z.enum(['email', 'whatsapp']).default('email') }), response: message,
|
||||
async handler({ params, body, user, req }) { return { data: await admin.sendInvoice(callFrom(req, user, { params, body })) }; },
|
||||
});
|
||||
|
||||
export const applyCoupon = defineRoute({
|
||||
method: 'post', path: '/registrations/:id/coupon', tag: 'Registrations', summary: 'Apply a coupon code',
|
||||
description: 'By the owner, or by staff on their behalf (logged). Not on season pass registrations.',
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff, notOnSeasonPass], params: idParams,
|
||||
body: z.object({ code: z.string().trim().min(1).max(40) }), response: single(fullRegistration.extend({ message: z.string() })),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.applyCoupon(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const removeCoupon = defineRoute({
|
||||
method: 'delete', path: '/registrations/:id/coupon', tag: 'Registrations', summary: 'Take a coupon code off',
|
||||
description: 'Staff. Its use is freed. Not once a ticket on it has been scanned; unused tickets are withdrawn if it\'s no longer paid.',
|
||||
access: 'staff', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams, response: message,
|
||||
async handler({ params, user, req }) { return { data: await admin.removeCoupon(callFrom(req, user, { params })) }; },
|
||||
});
|
||||
|
||||
export const setAttendees = defineRoute({
|
||||
method: 'put', path: '/registrations/:id/attendees', tag: 'Registrations', summary: 'Name the people on a registration',
|
||||
description: 'The whole list: attendees left out are removed (not once their ticket has been scanned). `ticketDelivery` says who gets the tickets.',
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
|
||||
body: z.object({ attendees: z.array(attendeeBody).max(1000), ticketDelivery: z.enum(['payer', 'each']).optional() }),
|
||||
response: single(z.looseObject({ id, ticketDelivery: z.string().nullable(), attendees: z.array(z.looseObject({ id, name: z.string() })) })),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.updateAttendees(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const sendFormLink = defineRoute({
|
||||
method: 'post', path: '/registrations/:id/attendees/:attendeeId/form-link', tag: 'Registrations', summary: 'Send an attendee their form',
|
||||
description: 'A link to fill in their own attendee form, by email and/or WhatsApp.',
|
||||
access: 'user', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: z.object({ id, attendeeId: id }), response: message,
|
||||
async handler({ params, user, req }) { return { data: await admin.sendAttendeeFormLink(callFrom(req, user, { params })) }; },
|
||||
});
|
||||
|
||||
export const getDraft = defineRoute({
|
||||
method: 'get', path: '/registrations/:id/form-draft', tag: 'Registrations', summary: 'Attendee forms saved part-way',
|
||||
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
|
||||
response: single(z.object({ data: z.record(z.string(), z.unknown()), updatedAt: z.string().nullable() })),
|
||||
async handler({ params, user, req }) { return { data: plain(await admin.getFormDraft(callFrom(req, user, { params }))) }; },
|
||||
});
|
||||
|
||||
export const saveDraft = defineRoute({
|
||||
method: 'put', path: '/registrations/:id/form-draft', tag: 'Registrations', summary: 'Save attendee forms part-way',
|
||||
description: 'Kept for the registration\'s owner (also when staff save it), until the forms are submitted.',
|
||||
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams,
|
||||
body: z.object({ data: z.record(z.string(), z.unknown()) }), response: single(z.object({ message: z.string(), updatedAt: z.string() })),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.saveFormDraft(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const submitForms = defineRoute({
|
||||
method: 'post', path: '/registrations/:id/form-responses', tag: 'Registrations', summary: 'Submit attendee forms',
|
||||
description: 'One response per main ticket at most; each named attendee answers once. Tickets are issued once it\'s paid and the forms are in.',
|
||||
access: 'optional', campusScope: OWNER_OR_STAFF, middleware: [ownerOrStaff], params: idParams, body: responsesBody,
|
||||
response: single(z.looseObject({ message: z.string(), count: z.number().int().optional() })),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.submitFormResponses(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const replaceForms = defineRoute({
|
||||
method: 'put', path: '/registrations/:id/form-responses', tag: 'Registrations', summary: 'Replace the attendee forms',
|
||||
description: 'Supervisors: every response on the registration is replaced by these.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams, body: responsesBody, response: message,
|
||||
async handler({ params, body, user, req }) { return { data: await admin.replaceFormResponses(callFrom(req, user, { params, body })) }; },
|
||||
});
|
||||
|
||||
// ─── Staff ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const list = defineRoute({
|
||||
method: 'get', path: '/registrations', tag: 'Registrations', summary: 'Every registration (staff)',
|
||||
description: 'Newest first, for the events you may see. `unpaid=true` for ones still owing; `userId` for one person\'s.',
|
||||
access: 'staff', campusScope: 'scopeList (listWhere)',
|
||||
query: z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(100).describe('At most 200'),
|
||||
unpaid: z.enum(['true', 'false']).default('false'),
|
||||
userId: id.optional(),
|
||||
}),
|
||||
response: z.object({ data: z.array(fullRegistration), meta: z.object({ page: z.number().int(), limit: z.number().int(), total: z.number().int(), pages: z.number().int() }) }),
|
||||
async handler({ query, user, req }) {
|
||||
const out = await admin.getRegistrations(callFrom(req, user, { query: { page: String(query.page), limit: String(query.limit), unpaid: query.unpaid === 'true' ? '1' : '', userId: query.userId } }));
|
||||
return pageOf(plain(out.data), out.total, { page: out.page, limit: out.limit });
|
||||
},
|
||||
});
|
||||
|
||||
export const forEvent = defineRoute({
|
||||
method: 'get', path: '/events/:id/registrations', tag: 'Registrations', summary: 'An event\'s registrations',
|
||||
description: 'Oldest first, with tickets, payments and attendees. `search` matches the name, email or phone.',
|
||||
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [campusScope.scopeEvent()], params: idParams,
|
||||
query: z.object({ search: z.string().max(200).optional() }),
|
||||
response: z.object({ data: z.array(fullRegistration) }),
|
||||
async handler({ params, query, user, req }) { return { data: plain(await admin.getRegistrationsByEvent(callFrom(req, user, { params: { eventId: params.id }, query }))) }; },
|
||||
});
|
||||
|
||||
export const setStatus = defineRoute({
|
||||
method: 'patch', path: '/registrations/:id/status', tag: 'Registrations', summary: 'Set a registration\'s status',
|
||||
description: 'Staff. `paid` issues and sends the tickets; leaving `paid` withdraws unused tickets (refused once one has been scanned); `cancelled` frees its places for the waitlist.',
|
||||
access: 'staff', campusScope: 'scopeEvent(registration)', middleware: [campusScope.scopeEvent({ resolve: 'registration' })], params: idParams,
|
||||
body: z.object({ status: z.enum(STATUSES) }), response: single(fullRegistration),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await admin.updateRegistrationStatus(callFrom(req, user, { params, body }))) }; },
|
||||
});
|
||||
|
||||
export const manual = defineRoute({
|
||||
method: 'post', path: '/registrations/manual', tag: 'Registrations', summary: 'Register someone (at the door, by phone)',
|
||||
description: [
|
||||
'Supervisors. Finds the person by email or phone (refused when those are two different accounts) or makes an account and sends them an activation link (`skipActivationNotice` to not).',
|
||||
'Added to their existing registration for the event if they have one. Free ones are marked paid and get their tickets; otherwise they\'re sent a link to pay.',
|
||||
].join('\n\n'),
|
||||
access: 'supervisor', campusScope: 'scopeEvent(body.eventId)', middleware: [campusScope.scopeEvent({ from: req => req.body?.eventId })], status: 201,
|
||||
body: z.looseObject({
|
||||
eventId: id,
|
||||
options: z.array(lineBody).min(1).max(100),
|
||||
user: z.looseObject({ name: z.string().trim().min(1).max(200), email: z.string().max(200).nullish(), phoneNumber: z.string().max(40).nullish(), password: z.string().max(200).optional().describe('Self-service kiosk: the visitor sets it, and the account is active at once') }),
|
||||
notificationPreference: z.string().max(20).nullish().describe('email, whatsapp or both (anything else: worked out from the contact details given)'),
|
||||
skipActivationNotice: z.boolean().optional(),
|
||||
couponCode: z.string().max(40).nullish(),
|
||||
attendees: z.array(attendeeBody).max(1000).optional(),
|
||||
ticketDelivery: z.enum(['payer', 'each']).optional(),
|
||||
}),
|
||||
response: single(fullRegistration.extend({ couponError: z.string().optional() })),
|
||||
async handler({ body, user, req }) { return { data: plain(await admin.createManualRegistration(callFrom(req, user, { body }))) }; },
|
||||
});
|
||||
|
||||
export const bulk = defineRoute({
|
||||
method: 'post', path: '/registrations/bulk', tag: 'Registrations', summary: 'Do one thing to many registrations',
|
||||
description: '`markPaid`, `cancel`, `moveOption`, `allocate` or `message`, for up to 500 you may manage. Each is done on its own: the answer lists what worked and what didn\'t.',
|
||||
access: 'supervisor', campusScope: 'scopeBody(registrationIds, deny orphans)', middleware: [campusScope.scopeBody([['registrationIds', 'registration']], { orphan: 'deny' })],
|
||||
body: z.looseObject({ action: z.enum(['markPaid', 'cancel', 'moveOption', 'allocate', 'message']), registrationIds: z.array(id).min(1).max(500) }),
|
||||
response: single(z.looseObject({})),
|
||||
async handler({ body, req }) {
|
||||
const { runBulk } = require('../../utils/bulkRegistrations') as { runBulk(req: unknown, body: object): Promise<object> };
|
||||
return { data: plain(await runBulk(req, body)) };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
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 * as series from '../../services/series';
|
||||
import * as deals from '../../services/deals';
|
||||
import * as campuses from '../../services/campuses';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- stored records are passed through */
|
||||
const campusScope = require('../../utils/campusScope') as {
|
||||
scopeFor(req: unknown): Promise<any>;
|
||||
listWhere(req: unknown, path?: string): Promise<object>;
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
|
||||
};
|
||||
const plain = <T>(v: T): any => JSON.parse(JSON.stringify(v));
|
||||
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, email: user!.email });
|
||||
const deleted = single(z.object({ id, deleted: z.literal(true) }));
|
||||
const SPANS = 'campusIndependent (a series spans campuses; events are checked when they join it)';
|
||||
|
||||
// ─── Series ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const seriesRecord = z.looseObject({ id, name: z.string(), slug: z.string(), description: z.string().nullable(), picture: z.string().nullable() });
|
||||
const seriesBody = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
slug: z.string().trim().max(80).optional().describe('The web address part (/series/<slug>); made from the name when left out'),
|
||||
description: z.string().max(5000).nullable().optional(),
|
||||
picture: z.string().max(500).nullable().optional(),
|
||||
});
|
||||
|
||||
export const listSeries = defineRoute({
|
||||
method: 'get', path: '/series', tag: 'Series', summary: 'Every series',
|
||||
description: 'With its events (the ones you may see — a series spans campuses), followers and event count.',
|
||||
access: 'supervisor', campusScope: 'filters each series\' events by campus (listSeries)',
|
||||
response: z.object({ data: z.array(seriesRecord.extend({ followers: z.number().int(), eventCount: z.number().int(), events: z.array(z.looseObject({ id, title: z.string(), startDate: isoDate })) })) }),
|
||||
async handler({ req }) { return { data: plain(await series.listSeries(await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const createSeries = defineRoute({
|
||||
method: 'post', path: '/series', tag: 'Series', summary: 'Create a series',
|
||||
access: 'supervisor', campusScope: SPANS, status: 201, body: seriesBody, response: single(seriesRecord),
|
||||
async handler({ body, user, req }) { return { data: plain(await series.createSeries(body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const updateSeries = defineRoute({
|
||||
method: 'patch', path: '/series/:id', tag: 'Series', summary: 'Change a series',
|
||||
access: 'supervisor', campusScope: SPANS, params: idParams, body: seriesBody.partial(), response: single(seriesRecord),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await series.updateSeries(params.id, body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const deleteSeries = defineRoute({
|
||||
method: 'delete', path: '/series/:id', tag: 'Series', summary: 'Delete a series',
|
||||
description: 'Admins. Its events stay (without a series) and followers are dropped. Not once season passes have been sold.',
|
||||
access: 'admin', params: idParams, response: deleted,
|
||||
async handler({ params, user, req }) { await series.deleteSeries(params.id, actorOf(user), await ctxOf(req)); return { data: { id: params.id, deleted: true as const } }; },
|
||||
});
|
||||
|
||||
export const seriesPage = defineRoute({
|
||||
method: 'get', path: '/series/by-slug/:slug', tag: 'Series', summary: 'A series\' public page',
|
||||
description: 'Upcoming and past public events, season passes on sale, and whether you follow it.',
|
||||
access: 'optional', params: z.object({ slug: z.string().min(1).max(80) }),
|
||||
response: single(seriesRecord.extend({ upcoming: z.array(z.looseObject({ id })), past: z.array(z.looseObject({ id })), following: z.boolean(), passes: z.array(z.looseObject({ id, name: z.string(), price: z.number() })) })),
|
||||
async handler({ params, user, req }) {
|
||||
const page = plain(await series.seriesPage(params.slug, user?.id ?? null));
|
||||
return { data: { ...page, picture: absoluteUrl(req, page.picture) } };
|
||||
},
|
||||
});
|
||||
|
||||
export const compareSeries = defineRoute({
|
||||
method: 'get', path: '/series/:id/compare', tag: 'Series', summary: 'Year on year',
|
||||
description: 'Registrations, tickets, income and attendance for each event in the series that you may see, oldest first.',
|
||||
access: 'supervisor', campusScope: 'scopeList (eventWhere in compareSeries)', params: idParams, response: single(z.looseObject({})),
|
||||
async handler({ params, req }) { return { data: plain(await series.compareSeries(params.id, await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const follow = defineRoute({
|
||||
method: 'put', path: '/series/:id/follow', tag: 'Series', summary: 'Follow a series',
|
||||
description: 'Hear about new events in it (News & announcements).',
|
||||
access: 'user', params: idParams, response: single(z.object({ following: z.boolean() })),
|
||||
async handler({ params, user }) { return { data: await series.setFollowing(params.id, user!.id, true) }; },
|
||||
});
|
||||
|
||||
export const unfollow = defineRoute({
|
||||
method: 'delete', path: '/series/:id/follow', tag: 'Series', summary: 'Stop following a series',
|
||||
access: 'user', params: idParams, response: single(z.object({ following: z.boolean() })),
|
||||
async handler({ params, user }) { return { data: await series.setFollowing(params.id, user!.id, false) }; },
|
||||
});
|
||||
|
||||
// ─── Season passes ───────────────────────────────────────────────────────────
|
||||
|
||||
const pass = z.looseObject({ id, seriesId: id, name: z.string(), price: z.number(), isActive: z.boolean(), items: z.array(z.looseObject({ eventId: id })) });
|
||||
const passBody = z.looseObject({
|
||||
seriesId: id,
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().max(5000).nullable().optional(),
|
||||
price: z.union([z.number().min(0), z.string().max(20)]),
|
||||
saleEndsAt: z.string().max(40).nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
items: z.array(z.object({ eventId: id, eventOptionId: id, variantId: id.nullable().optional() })).min(1).max(100),
|
||||
});
|
||||
|
||||
export const listPasses = defineRoute({
|
||||
method: 'get', path: '/season-passes', tag: 'Series', summary: 'Season passes',
|
||||
description: 'With their events, how the price is shared between them, and how many were sold or are unpaid.',
|
||||
access: 'supervisor', campusScope: SPANS, query: z.object({ seriesId: id.optional() }),
|
||||
response: z.object({ data: z.array(pass.extend({ sold: z.number().int(), unpaid: z.number().int() })) }),
|
||||
async handler({ query }) { return { data: plain(await series.listPasses(query.seriesId)) }; },
|
||||
});
|
||||
|
||||
export const createPass = defineRoute({
|
||||
method: 'post', path: '/season-passes', tag: 'Series', summary: 'Create a season pass',
|
||||
description: 'One price for several events in a series. Campus-scoped supervisors can only include their own campuses\' events.',
|
||||
access: 'supervisor', campusScope: 'checked in createPass (its events\' campuses)', status: 201, body: passBody, response: single(pass),
|
||||
async handler({ body, user, req }) { return { data: plain(await series.createPass(body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const updatePass = defineRoute({
|
||||
method: 'patch', path: '/season-passes/:id', tag: 'Series', summary: 'Change a season pass',
|
||||
description: 'Its events and price can\'t change once somebody has bought it (switch it off and make a new one).',
|
||||
access: 'supervisor', campusScope: 'checked in updatePass (its events\' campuses)', params: idParams, body: passBody.partial(), response: single(pass),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await series.updatePass(params.id, body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const deletePass = defineRoute({
|
||||
method: 'delete', path: '/season-passes/:id', tag: 'Series', summary: 'Delete a season pass',
|
||||
description: 'Only one nobody has bought — otherwise switch it off.',
|
||||
access: 'supervisor', campusScope: 'checked in deletePass (its events\' campuses)', params: idParams, response: deleted,
|
||||
async handler({ params, user, req }) { await series.deletePass(params.id, actorOf(user), await ctxOf(req)); return { data: { id: params.id, deleted: true as const } }; },
|
||||
});
|
||||
|
||||
export const buyPass = defineRoute({
|
||||
method: 'post', path: '/season-passes/:id/buy', tag: 'Series', summary: 'Buy a season pass',
|
||||
description: 'Registers you at every event on it, then answers with a checkout to pay (or issues the tickets when it\'s free).',
|
||||
access: 'user', status: 201, params: idParams,
|
||||
body: z.object({ quantity: z.number().int().min(1).max(50).default(1), provider: z.string().max(40).optional() }),
|
||||
response: single(z.looseObject({})),
|
||||
async handler({ params, body, user }) { return { data: plain(await series.buyPass(params.id, user!.id, body)) }; },
|
||||
});
|
||||
|
||||
export const payPass = defineRoute({
|
||||
method: 'post', path: '/season-pass-purchases/:id/checkout', tag: 'Series', summary: 'Pay what\'s owed on a pass',
|
||||
access: 'user', params: idParams, body: z.object({ provider: z.string().max(40).optional() }), response: single(z.looseObject({})),
|
||||
async handler({ params, body, user }) { return { data: plain(await series.payPassPurchase(params.id, user!.id, body)) }; },
|
||||
});
|
||||
|
||||
export const myPasses = defineRoute({
|
||||
method: 'get', path: '/me/season-passes', tag: 'Me', summary: 'My season passes',
|
||||
access: 'user',
|
||||
response: z.object({ data: z.array(z.looseObject({ id, quantity: z.number().int(), total: z.number(), status: z.string(), createdAt: isoDate })) }),
|
||||
async handler({ user }) { return { data: plain(await series.myPassPurchases(user!.id)) }; },
|
||||
});
|
||||
|
||||
// ─── Bundle deals ────────────────────────────────────────────────────────────
|
||||
|
||||
const basketBody = z.object({
|
||||
eventId: id,
|
||||
options: z.array(z.object({ eventOptionId: id, variantId: id.nullish(), quantity: z.number().int().min(1).max(1000), sessionIds: z.array(id).max(200).optional() })).max(50),
|
||||
forUserId: id.nullable().optional().describe('Staff: price it for this person (null = a walk-in without an account)'),
|
||||
});
|
||||
const bundle = z.looseObject({ id, eventId: id, name: z.string(), kind: z.enum(['exact', 'mix', 'free', 'group']), price: z.number().nullable(), isActive: z.boolean(), summary: z.string(), headline: z.string(), available: z.boolean() });
|
||||
const bundleBody = z.looseObject({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
kind: z.enum(['exact', 'mix', 'free', 'group']).optional().describe('exact = this set; mix = any N; free = buy N get M free; group = a price for groups'),
|
||||
price: z.union([z.number(), z.string().max(20)]).nullable().optional(),
|
||||
setSize: z.number().int().nullable().optional(),
|
||||
freeCount: z.number().int().nullable().optional(),
|
||||
groupPricing: z.enum(['per_ticket', 'percent']).nullable().optional(),
|
||||
items: z.array(z.object({ eventOptionId: id, variantId: id.nullish(), quantity: z.number().int().min(1).optional() })).max(50).optional(),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
validFrom: z.string().max(40).nullable().optional(),
|
||||
validUntil: z.string().max(40).nullable().optional(),
|
||||
});
|
||||
|
||||
export const listBundles = defineRoute({
|
||||
method: 'get', path: '/events/:id/bundles', tag: 'Deals', summary: 'An event\'s bundle deals',
|
||||
description: 'With how often each has been used and the discount given.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [campusScope.scopeEvent()], params: idParams,
|
||||
response: z.object({ data: z.array(bundle.extend({ uses: z.number(), registrations: z.number().int(), totalDiscount: z.number() })) }),
|
||||
async handler({ params }) { return { data: plain(await deals.listBundles(params.id)) }; },
|
||||
});
|
||||
|
||||
export const createBundle = defineRoute({
|
||||
method: 'post', path: '/events/:id/bundles', tag: 'Deals', summary: 'Add a bundle deal',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [campusScope.scopeEvent({ need: 'manage' })], status: 201,
|
||||
params: idParams, body: bundleBody, response: single(bundle),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await deals.createBundle(params.id, body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const updateBundle = defineRoute({
|
||||
method: 'patch', path: '/bundles/:id', tag: 'Deals', summary: 'Change a bundle deal',
|
||||
description: 'Only what\'s sent changes; when a pricing field is sent the whole deal is checked again.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(bundle, manage)', middleware: [campusScope.scopeEvent({ resolve: 'bundle', need: 'manage' })],
|
||||
params: idParams, body: bundleBody.partial(), response: single(bundle),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await deals.updateBundle(params.id, body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const deleteBundle = defineRoute({
|
||||
method: 'delete', path: '/bundles/:id', tag: 'Deals', summary: 'Remove a bundle deal',
|
||||
description: 'One that\'s been used is switched off instead (`switchedOff`), so registrations that got it keep their price.',
|
||||
access: 'supervisor', campusScope: 'scopeEvent(bundle, manage)', middleware: [campusScope.scopeEvent({ resolve: 'bundle', need: 'manage' })],
|
||||
params: idParams, response: single(z.object({ deleted: z.literal(true).optional(), switchedOff: z.literal(true).optional(), message: z.string() })),
|
||||
async handler({ params, user, req }) { return { data: await deals.deleteBundle(params.id, actorOf(user), await ctxOf(req)) }; },
|
||||
});
|
||||
|
||||
export const quote = defineRoute({
|
||||
method: 'post', path: '/bundles/quote', tag: 'Deals', summary: 'The bundle deals a basket gets',
|
||||
description: 'Before any coupon code — what the register page shows as you choose tickets.',
|
||||
access: 'optional', body: basketBody,
|
||||
response: single(z.object({ subtotal: z.number(), bundleDiscount: z.number(), bundles: z.array(z.looseObject({})), total: z.number() })),
|
||||
async handler({ body, user }) { return { data: plain(await deals.quoteBundles(body, user ? actorOf(user) : null)) }; },
|
||||
});
|
||||
|
||||
// ─── Coupon codes ────────────────────────────────────────────────────────────
|
||||
|
||||
const coupon = z.looseObject({ id, code: z.string(), type: z.enum(['percent', 'fixed', 'comp']), value: z.number(), active: z.boolean(), summary: z.string() });
|
||||
const couponBody = z.looseObject({
|
||||
code: z.string().trim().min(3).max(32),
|
||||
type: z.enum(['percent', 'fixed', 'comp']).describe('comp = free'),
|
||||
value: z.union([z.number(), z.string().max(20)]).optional(),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
active: z.boolean().optional(),
|
||||
stackEarlyBird: z.boolean().optional(),
|
||||
validFrom: z.string().max(40).nullable().optional(),
|
||||
validUntil: z.string().max(40).nullable().optional(),
|
||||
maxUses: z.union([z.number().int(), z.string()]).nullable().optional(),
|
||||
maxUsesPerUser: z.union([z.number().int(), z.string()]).nullable().optional(),
|
||||
minQuantity: z.union([z.number().int(), z.string()]).nullable().optional(),
|
||||
restrictRoles: z.array(z.enum(['user', 'staff', 'supervisor', 'admin'])).optional(),
|
||||
restrictDomain: z.string().max(120).nullable().optional(),
|
||||
eventIds: z.array(id).max(200).optional().describe('Empty = every event (not for campus-scoped supervisors)'),
|
||||
options: z.array(z.object({ eventOptionId: id, variantId: id.nullish() })).max(200).optional(),
|
||||
userIds: z.array(id).max(1000).optional(),
|
||||
});
|
||||
|
||||
export const listCoupons = defineRoute({
|
||||
method: 'get', path: '/coupons', tag: 'Deals', summary: 'Coupon codes',
|
||||
description: 'With how often each has been used and the discount given. Campus-scoped supervisors see codes for their campuses\' events.',
|
||||
access: 'supervisor', campusScope: 'scopeList (couponAllowed in listCoupons)',
|
||||
response: z.object({ data: z.array(coupon.extend({ uses: z.number().int(), totalDiscount: z.number() })) }),
|
||||
async handler({ req }) { return { data: plain(await deals.listCoupons(await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const redemptions = defineRoute({
|
||||
method: 'get', path: '/coupons/redemptions', tag: 'Deals', summary: 'Coupon and bundle uses',
|
||||
description: 'For the Discounts report, newest first.',
|
||||
access: 'supervisor', campusScope: 'scopeList (listWhere)',
|
||||
query: z.object({ eventIds: z.string().max(4000).optional().describe('Comma-separated'), from: z.string().max(40).optional(), to: z.string().max(40).optional() }),
|
||||
response: z.object({ data: z.array(z.looseObject({ id, kind: z.enum(['coupon', 'bundle']), uses: z.number(), discountAmount: z.number(), createdAt: isoDate })) }),
|
||||
async handler({ query, req }) {
|
||||
const date = (v?: string) => { if (!v) return null; const d = new Date(v); return isNaN(d.getTime()) ? null : d; };
|
||||
const eventIds = String(query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
return { data: plain(await deals.listRedemptions({ eventIds, from: date(query.from), to: date(query.to) }, await campusScope.listWhere(req, 'event'))) };
|
||||
},
|
||||
});
|
||||
|
||||
export const getCoupon = defineRoute({
|
||||
method: 'get', path: '/coupons/:id', tag: 'Deals', summary: 'One coupon code with its uses',
|
||||
access: 'supervisor', campusScope: 'scopeList (couponAllowed in getCoupon)', params: idParams,
|
||||
response: single(coupon.extend({ redemptions: z.array(z.looseObject({ id })) })),
|
||||
async handler({ params, req }) { return { data: plain(await deals.getCoupon(params.id, await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const createCoupon = defineRoute({
|
||||
method: 'post', path: '/coupons', tag: 'Deals', summary: 'Create a coupon code',
|
||||
access: 'supervisor', campusScope: 'checked in createCoupon (eventIdsAllowed)', status: 201, body: couponBody, response: single(coupon),
|
||||
async handler({ body, user, req }) { return { data: plain(await deals.createCoupon(body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const updateCoupon = defineRoute({
|
||||
method: 'patch', path: '/coupons/:id', tag: 'Deals', summary: 'Change a coupon code',
|
||||
description: 'Only what\'s sent changes. Discounts already given keep their amount; a used code can\'t be renamed.',
|
||||
access: 'supervisor', campusScope: 'checked in updateCoupon (couponAllowed)', params: idParams, body: couponBody.partial(), response: single(coupon),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await deals.updateCoupon(params.id, body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const checkCode = defineRoute({
|
||||
method: 'post', path: '/coupons/check', tag: 'Deals', summary: 'Check a coupon code against a basket',
|
||||
description: 'Always a 200: `valid` with the discount and total, or `valid: false` with why. Restricted codes need you signed in.',
|
||||
access: 'optional', campusScope: 'scopeEvent(body.eventId)', middleware: [campusScope.scopeEvent({ from: req => req.body?.eventId })],
|
||||
body: basketBody.extend({ code: z.string().trim().min(1).max(40) }),
|
||||
response: single(z.discriminatedUnion('valid', [
|
||||
z.object({ valid: z.literal(true), code: z.string(), type: z.string(), value: z.number(), summary: z.string(), description: z.string().nullable(), discount: z.number(), bundleDiscount: z.number(), bundles: z.array(z.looseObject({})), subtotal: z.number(), total: z.number() }),
|
||||
z.object({ valid: z.literal(false), message: z.string() }),
|
||||
])),
|
||||
async handler({ body, user }) { return { data: plain(await deals.checkCode(body, user ? actorOf(user) : null)) }; },
|
||||
});
|
||||
|
||||
// ─── Campuses ────────────────────────────────────────────────────────────────
|
||||
|
||||
const publicCampus = z.looseObject({ id, name: z.string(), slug: z.string() });
|
||||
const adminCampus = publicCampus.extend({ archivedAt: isoDate.nullable(), order: z.number().int(), _count: z.looseObject({ events: z.number().int() }) });
|
||||
const campusBody = z.looseObject({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
slug: z.string().trim().max(80).optional(),
|
||||
colour: z.string().max(20).nullable().optional(),
|
||||
notifyRecipientIds: z.array(id).max(100).optional().describe('Who gets this campus\'s daily summary (staff only)'),
|
||||
archived: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const listCampuses = defineRoute({
|
||||
method: 'get', path: '/campuses', tag: 'Campuses', summary: 'The active campuses',
|
||||
description: '`enabled` false means campuses are switched off (the list is then empty).',
|
||||
access: 'public', response: single(z.object({ enabled: z.boolean(), campuses: z.array(publicCampus) })),
|
||||
async handler() { return { data: plain(await campuses.publicCampuses()) }; },
|
||||
});
|
||||
|
||||
export const adminList = defineRoute({
|
||||
method: 'get', path: '/campuses/admin', tag: 'Campuses', summary: 'Every campus (Site settings)',
|
||||
description: 'Archived ones last, with event counts and summary recipients.',
|
||||
access: 'admin', response: single(z.object({ enabled: z.boolean(), campuses: z.array(adminCampus) })),
|
||||
async handler() { return { data: plain(await campuses.adminCampuses()) }; },
|
||||
});
|
||||
|
||||
export const campusBySlug = defineRoute({
|
||||
method: 'get', path: '/campuses/by-slug/:slug', tag: 'Campuses', summary: 'A campus landing page',
|
||||
access: 'public', params: z.object({ slug: z.string().min(1).max(80) }), response: single(publicCampus),
|
||||
async handler({ params }) { return { data: plain(await campuses.campusBySlug(params.slug)) }; },
|
||||
});
|
||||
|
||||
export const setEnabled = defineRoute({
|
||||
method: 'put', path: '/campuses/enabled', tag: 'Campuses', summary: 'Switch campuses on or off',
|
||||
description: 'Off hides campus fields and filters; nothing is deleted.',
|
||||
access: 'admin', body: z.object({ enabled: z.boolean() }), response: single(z.object({ enabled: z.boolean() })),
|
||||
async handler({ body, user, req }) { return { data: await campuses.setCampusesEnabled(body.enabled, actorOf(user), await ctxOf(req)) }; },
|
||||
});
|
||||
|
||||
export const reorder = defineRoute({
|
||||
method: 'put', path: '/campuses/order', tag: 'Campuses', summary: 'Put the campuses in order',
|
||||
access: 'admin', body: z.object({ ids: z.array(id).min(1).max(200) }), response: single(z.object({ ids: z.array(id) })),
|
||||
async handler({ body }) { await campuses.reorderCampuses(body.ids); return { data: { ids: body.ids } }; },
|
||||
});
|
||||
|
||||
export const createCampus = defineRoute({
|
||||
method: 'post', path: '/campuses', tag: 'Campuses', summary: 'Add a campus',
|
||||
access: 'admin', status: 201, body: campusBody, response: single(adminCampus),
|
||||
async handler({ body, user, req }) { return { data: plain(await campuses.createCampus(body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const updateCampus = defineRoute({
|
||||
method: 'patch', path: '/campuses/:id', tag: 'Campuses', summary: 'Change a campus',
|
||||
description: 'Details, summary recipients, or `archived`.',
|
||||
access: 'admin', params: idParams, body: campusBody.partial(), response: single(adminCampus),
|
||||
async handler({ params, body, user, req }) { return { data: plain(await campuses.updateCampus(params.id, body, actorOf(user), await ctxOf(req))) }; },
|
||||
});
|
||||
|
||||
export const deleteCampus = defineRoute({
|
||||
method: 'delete', path: '/campuses/:id', tag: 'Campuses', summary: 'Delete a campus',
|
||||
description: 'Only one with no events and no staff limited to it — otherwise archive it (CONFLICT, `reason` CAMPUS_HAS_EVENTS or CAMPUS_HAS_SCOPED_STAFF).',
|
||||
access: 'admin', params: idParams, response: deleted,
|
||||
async handler({ params, user, req }) { await campuses.deleteCampus(params.id, actorOf(user), await ctxOf(req)); return { data: { id: params.id, deleted: true as const } }; },
|
||||
});
|
||||
|
||||
export const audience = defineRoute({
|
||||
method: 'get', path: '/campuses/:id/audience', tag: 'Campuses', summary: 'Everyone at a campus',
|
||||
description: 'For the email and WhatsApp composers: people whose home campus it is, plus (`registrants=true`) people registered for its events in the last 12 months. News consent is checked when sending.',
|
||||
access: 'supervisor', campusScope: 'scopeList (own campuses only, in campusAudience)', params: idParams,
|
||||
query: z.object({ registrants: z.enum(['true', 'false']).default('false').transform(v => v === 'true') }),
|
||||
response: single(z.object({ campus: z.object({ id, name: z.string() }), users: z.array(z.object({ id, name: z.string(), email: z.string(), phoneNumber: z.string().nullable() })) })),
|
||||
async handler({ params, query, req }) { return { data: plain(await campuses.campusAudience(params.id, query.registrants, await ctxOf(req))) }; },
|
||||
});
|
||||
@@ -105,7 +105,7 @@ export function mount(router: Router, list: AnyRoute[] = routes): void {
|
||||
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;
|
||||
const f = (checked.data as { data: { contentType: string; filename: string; body: string | Buffer } }).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);
|
||||
@@ -121,4 +121,4 @@ export function mount(router: Router, list: AnyRoute[] = routes): void {
|
||||
}
|
||||
|
||||
/** 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() }) });
|
||||
export const fileResponse = z.object({ data: z.object({ contentType: z.string(), filename: z.string(), body: z.union([z.string(), z.instanceof(Buffer)]) }) });
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* v2 routes that run a v1 handler in-process (1.51.11 –). The handler is the one v1 runs, so
|
||||
* both versions behave the same; v2 adds its own path, validation, docs and response shape.
|
||||
*
|
||||
* - A v1 answer of 2xx becomes `{ data: <the v1 JSON> }` (or a file download, for `file` routes).
|
||||
* - A v1 error (`{ message, code? }` with 4xx/5xx) becomes the v2 error: the status picks `code`,
|
||||
* v1's `code` becomes `reason`.
|
||||
*
|
||||
* The handlers are still JavaScript; moving each area into a TypeScript service (as 1.51.1 –
|
||||
* 1.51.10 did) can follow later without changing the v2 API.
|
||||
*/
|
||||
import { Writable } from 'stream';
|
||||
import { z } from 'zod';
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import { defineRoute, fileResponse, type Access, type Ctx } from './route';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- requests and v1 answers are passed through */
|
||||
type Parts = { params?: Record<string, unknown>; query?: Record<string, unknown>; body?: unknown };
|
||||
type V1Answer = { status: number; json?: unknown; body?: Buffer; headers: Record<string, string>; redirect?: string };
|
||||
|
||||
/** A response that records what a v1 handler sends (JSON, `send`, a piped stream or a redirect). */
|
||||
class RecordingResponse extends Writable {
|
||||
statusCode = 200;
|
||||
headersSent = false;
|
||||
locals: Record<string, unknown> = {};
|
||||
readonly headers: Record<string, string> = {};
|
||||
private chunks: Buffer[] = [];
|
||||
json?: (v: unknown) => this;
|
||||
answer?: V1Answer;
|
||||
private done!: (a: V1Answer) => void;
|
||||
readonly finished: Promise<V1Answer>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.finished = new Promise(resolve => { this.done = resolve; });
|
||||
this.json = (v: unknown) => this.finishWith({ json: JSON.parse(JSON.stringify(v ?? null)) });
|
||||
}
|
||||
|
||||
override _write(chunk: any, _enc: BufferEncoding, cb: () => void) { this.chunks.push(Buffer.from(chunk)); cb(); }
|
||||
override _final(cb: () => void) { this.finishWith({}); cb(); }
|
||||
|
||||
private finishWith(extra: Partial<V1Answer>): this {
|
||||
if (this.answer) return this;
|
||||
this.headersSent = true;
|
||||
this.answer = { status: this.statusCode, headers: this.headers, body: Buffer.concat(this.chunks), ...extra };
|
||||
this.done(this.answer);
|
||||
return this;
|
||||
}
|
||||
|
||||
status(code: number) { this.statusCode = code; return this; }
|
||||
sendStatus(code: number) { this.statusCode = code; return this.finishWith({}); }
|
||||
setHeader(name: string, value: unknown) { this.headers[name.toLowerCase()] = String(value); return this; }
|
||||
getHeader(name: string) { return this.headers[name.toLowerCase()]; }
|
||||
removeHeader(name: string) { delete this.headers[name.toLowerCase()]; }
|
||||
set(name: string | Record<string, unknown>, value?: unknown) {
|
||||
if (typeof name === 'object') for (const [k, v] of Object.entries(name)) this.setHeader(k, v);
|
||||
else this.setHeader(name, value);
|
||||
return this;
|
||||
}
|
||||
header(name: string, value?: unknown) { return this.set(name, value); }
|
||||
type(t: string) { return this.setHeader('content-type', t.includes('/') ? t : t === 'json' ? 'application/json' : `text/${t}`); }
|
||||
attachment(filename?: string) { return filename ? this.setHeader('content-disposition', `attachment; filename="${filename}"`) : this; }
|
||||
send(body?: unknown) {
|
||||
if (body !== null && typeof body === 'object' && !Buffer.isBuffer(body)) return this.json!(body);
|
||||
if (body !== undefined) this.chunks.push(Buffer.isBuffer(body) ? body : Buffer.from(String(body)));
|
||||
return this.finishWith({});
|
||||
}
|
||||
// A handler that ends without writing anything still answers.
|
||||
override end(...args: any[]) {
|
||||
const [chunk] = args;
|
||||
if (chunk !== undefined && typeof chunk !== 'function') this.chunks.push(Buffer.from(chunk));
|
||||
this.finishWith({});
|
||||
return this;
|
||||
}
|
||||
redirect(a: number | string, b?: string) {
|
||||
const url = typeof a === 'string' ? a : b!;
|
||||
this.statusCode = typeof a === 'number' ? a : 302;
|
||||
return this.finishWith({ redirect: url });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs v1 handlers (and middleware) for a v2 request. `parts` replaces the request's params,
|
||||
* query or body (e.g. v2 `:id` given to v1 as `:eventId`); everything else (the user, headers,
|
||||
* campus scope, read-only state) is the real request's.
|
||||
*/
|
||||
export async function runV1(handlers: RequestHandler | RequestHandler[], req: Request, parts: Parts = {}): Promise<V1Answer> {
|
||||
const fake = Object.create(req) as Request;
|
||||
for (const key of ['params', 'query', 'body'] as const) {
|
||||
if (parts[key] !== undefined) Object.defineProperty(fake, key, { value: parts[key], writable: true, configurable: true, enumerable: true });
|
||||
}
|
||||
const res = new RecordingResponse();
|
||||
const chain = Array.isArray(handlers) ? handlers : [handlers];
|
||||
const step = (i: number): Promise<void> => new Promise((resolve, reject) => {
|
||||
let advanced = false;
|
||||
const next = (err?: unknown) => {
|
||||
advanced = true;
|
||||
if (err) return reject(err);
|
||||
if (i + 1 < chain.length) step(i + 1).then(resolve, reject);
|
||||
else resolve();
|
||||
};
|
||||
Promise.resolve(chain[i](fake, res as any, next)).then(() => { if (!advanced) resolve(); }, reject);
|
||||
});
|
||||
await Promise.race([step(0).then(() => res.finished), res.finished]);
|
||||
return res.answer!;
|
||||
}
|
||||
|
||||
/** A v1 error answer as something v2's error handler understands. */
|
||||
function v1Error(a: V1Answer): Error {
|
||||
const j = (a.json ?? {}) as { message?: unknown; error?: unknown; code?: unknown };
|
||||
const text = typeof j.message === 'string' ? j.message : typeof j.error === 'string' ? j.error : a.body?.length ? a.body.toString('utf8').slice(0, 500) : 'Request failed.';
|
||||
const e = new Error(text) as Error & { statusCode: number; code?: string };
|
||||
e.statusCode = a.status;
|
||||
if (typeof j.code === 'string') e.code = j.code;
|
||||
return e;
|
||||
}
|
||||
|
||||
/** `{ data: <anything> }` — for v1 answers documented by their description. */
|
||||
export const anyData = z.object({ data: z.unknown() });
|
||||
export const listData = z.object({ data: z.array(z.looseObject({})) });
|
||||
export const objectData = z.object({ data: z.looseObject({}) });
|
||||
|
||||
type V1RouteDef = {
|
||||
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
path: string;
|
||||
tag: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
access: Access;
|
||||
campusScope?: string;
|
||||
middleware?: RequestHandler[];
|
||||
params?: z.ZodType;
|
||||
query?: z.ZodType;
|
||||
body?: z.ZodType;
|
||||
response?: z.ZodType;
|
||||
status?: number;
|
||||
file?: { contentType: string };
|
||||
upload?: { field: string; description: string };
|
||||
/** The v1 handler (or middleware chain ending in one). */
|
||||
v1: RequestHandler | RequestHandler[];
|
||||
/**
|
||||
* What v1 gets as params/query/body. By default: the v2 params, the raw query and the raw body
|
||||
* (validated by `query`/`body` when given, but passed as sent so v1 sees what it always saw).
|
||||
*/
|
||||
map?: (ctx: Ctx<any, any, any>) => Parts;
|
||||
};
|
||||
|
||||
/** A v2 route answered by a v1 handler. */
|
||||
export function v1Route(def: V1RouteDef) {
|
||||
const { v1, map, response, ...rest } = def;
|
||||
return defineRoute({
|
||||
...rest,
|
||||
response: def.file ? fileResponse : (response ?? anyData),
|
||||
async handler(ctx) {
|
||||
const parts: Parts = { params: ctx.req.params, query: ctx.req.query as any, body: ctx.req.body, ...(map ? map(ctx) : {}) };
|
||||
const a = await runV1(v1, ctx.req, parts);
|
||||
if (a.status >= 400) throw v1Error(a);
|
||||
if (a.redirect) return { data: { redirectUrl: a.redirect } } as any;
|
||||
if (def.file) {
|
||||
const name = /filename="?([^";]+)"?/.exec(a.headers['content-disposition'] || '')?.[1] || 'download';
|
||||
return { data: { contentType: a.headers['content-type'] || def.file.contentType, filename: name, body: a.body ?? Buffer.alloc(0) } };
|
||||
}
|
||||
return { data: a.json !== undefined ? a.json : (a.body?.length ? a.body.toString('utf8') : null) } as any;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -50,6 +50,8 @@ jest.mock('../src/utils/campusScope', () => ({
|
||||
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 () => ({}),
|
||||
scopeList: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
unscopedOnly: (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
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)])));
|
||||
@@ -75,6 +77,25 @@ jest.mock('../src/controllers/uploadController', () => {
|
||||
return { upload: m, uploadLogo: m, uploadFavicon: m };
|
||||
});
|
||||
|
||||
const mockSeries = { listSeries: jest.fn(), createSeries: jest.fn(), updateSeries: jest.fn(), deleteSeries: jest.fn(), seriesPage: jest.fn(), compareSeries: jest.fn(), setFollowing: jest.fn(), listPasses: jest.fn(), createPass: jest.fn(), updatePass: jest.fn(), deletePass: jest.fn(), buyPass: jest.fn(), payPassPurchase: jest.fn(), myPassPurchases: jest.fn() };
|
||||
jest.mock('../src/services/series', () => Object.fromEntries(Object.keys(mockSeries).map(k => [k, (...a: unknown[]) => (mockSeries as Record<string, jest.Mock>)[k](...a)])));
|
||||
const mockDeals = { listBundles: jest.fn(), createBundle: jest.fn(), updateBundle: jest.fn(), deleteBundle: jest.fn(), quoteBundles: jest.fn(), checkCode: jest.fn(), listCoupons: jest.fn(), getCoupon: jest.fn(), createCoupon: jest.fn(), updateCoupon: jest.fn(), listRedemptions: jest.fn(), publicBundlesForEvent: jest.fn() };
|
||||
jest.mock('../src/services/deals', () => Object.fromEntries(Object.keys(mockDeals).map(k => [k, (...a: unknown[]) => (mockDeals as Record<string, jest.Mock>)[k](...a)])));
|
||||
const mockCampuses = { publicCampuses: jest.fn(), campusBySlug: jest.fn(), adminCampuses: jest.fn(), setCampusesEnabled: jest.fn(), createCampus: jest.fn(), updateCampus: jest.fn(), reorderCampuses: jest.fn(), deleteCampus: jest.fn(), campusAudience: jest.fn() };
|
||||
jest.mock('../src/services/campuses', () => Object.fromEntries(Object.keys(mockCampuses).map(k => [k, (...a: unknown[]) => (mockCampuses as Record<string, jest.Mock>)[k](...a)])));
|
||||
|
||||
const mockAdmin = { getRegistrations: jest.fn(), getUserRegistrations: jest.fn(), getRegistrationById: jest.fn(), updateRegistrationStatus: jest.fn(), getRegistrationsByEvent: jest.fn(), createManualRegistration: jest.fn(), updateRegistrationOptions: jest.fn(), submitFormResponses: jest.fn(), replaceFormResponses: jest.fn(), getFormDraft: jest.fn(), saveFormDraft: jest.fn(), sendInvoice: jest.fn(), applyCoupon: jest.fn(), removeCoupon: jest.fn(), updateAttendees: jest.fn(), sendAttendeeFormLink: jest.fn() };
|
||||
jest.mock('../src/services/registrationAdmin', () => Object.fromEntries(Object.keys(mockAdmin).map(k => [k, (...a: unknown[]) => (mockAdmin as Record<string, jest.Mock>)[k](...a)])));
|
||||
jest.mock('../src/utils/seasonPass', () => ({ notOnSeasonPass: (_req: unknown, _res: unknown, next: () => void) => next() }));
|
||||
|
||||
jest.mock('../src/controllers/cashupController', () => {
|
||||
const echo = (req: { params: object; body: object }, res: { json(b: unknown): void }) => res.json({ params: req.params, body: req.body });
|
||||
return {
|
||||
getEventCashup: echo, getCashByRecordedUser: echo, savePersonCash: echo, saveEventCashupDraft: echo, reopenEvent: echo, resolvePending: echo, getCashupAudit: echo,
|
||||
closeEvent: (_req: unknown, res: { status(n: number): { json(b: unknown): void } }) => res.status(400).json({ message: 'There are pending lines.', code: 'PENDING_LINES' }),
|
||||
};
|
||||
});
|
||||
|
||||
import { v2Router } from '../src/v2';
|
||||
import { ServiceError } from '../src/services/errors';
|
||||
import { routes } from '../src/v2/route';
|
||||
@@ -554,3 +575,119 @@ describe('sections, costs, image uploads', () => {
|
||||
expect((await request(app).post('/api/v2/uploads/logo')).body.data.path).toBe('/uploads/branding/logo.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('series, deals and campuses', () => {
|
||||
test('following a series needs an account; the page works without one', async () => {
|
||||
expect((await request(app).put('/api/v2/series/s1/follow')).status).toBe(401);
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
mockSeries.setFollowing.mockResolvedValue({ following: true });
|
||||
expect((await request(app).put('/api/v2/series/s1/follow')).body.data).toEqual({ following: true });
|
||||
expect(mockSeries.setFollowing).toHaveBeenCalledWith('s1', 'u1', true);
|
||||
});
|
||||
|
||||
test('a coupon check is always a 200 with valid true or false', async () => {
|
||||
mockDeals.checkCode.mockResolvedValueOnce({ valid: false, message: 'This code has expired.' });
|
||||
const res = await request(app).post('/api/v2/coupons/check').send({ code: 'OLD', eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 1 }] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toEqual({ valid: false, message: 'This code has expired.' });
|
||||
});
|
||||
|
||||
test('/coupons/redemptions is not read as a coupon id', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
mockDeals.listRedemptions.mockResolvedValue([]);
|
||||
expect((await request(app).get('/api/v2/coupons/redemptions?eventIds=e1,e2')).body).toEqual({ data: [] });
|
||||
expect(mockDeals.listRedemptions.mock.calls[0][0]).toEqual({ eventIds: ['e1', 'e2'], from: null, to: null });
|
||||
expect(mockDeals.getCoupon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a campus that can't be deleted says why, as a reason", async () => {
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
mockCampuses.deleteCampus.mockRejectedValueOnce(new ServiceError(409, 'North has 3 events, so it can\'t be deleted. Archive it instead.', 'CAMPUS_HAS_EVENTS'));
|
||||
const res = await request(app).delete('/api/v2/campuses/c1');
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toEqual({ code: 'CONFLICT', message: expect.stringContaining('Archive it instead'), reason: 'CAMPUS_HAS_EVENTS' });
|
||||
});
|
||||
|
||||
test('campus admin routes are for admins; the public list is for anyone', async () => {
|
||||
mockCampuses.publicCampuses.mockResolvedValue({ enabled: true, campuses: [{ id: 'c1', name: 'North', slug: 'north', colour: null }] });
|
||||
expect((await request(app).get('/api/v2/campuses')).body.data.campuses[0]).toMatchObject({ slug: 'north' });
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
expect((await request(app).get('/api/v2/campuses/admin')).status).toBe(403);
|
||||
expect((await request(app).put('/api/v2/campuses/enabled').send({ enabled: true })).status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('managing registrations', () => {
|
||||
const reg = { id: 'r1', status: 'pending', eventId: 'e1', userId: 'u1', totalDueComputed: 150, payments: [] };
|
||||
|
||||
test('a guest with the link can read a registration in full', async () => {
|
||||
mockAdmin.getRegistrationById.mockResolvedValue(reg);
|
||||
const res = await request(app).get('/api/v2/registrations/r1/full');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toMatchObject({ id: 'r1', totalDueComputed: 150, payments: [] });
|
||||
expect(mockAdmin.getRegistrationById.mock.calls[0][0]).toMatchObject({ actor: null, params: { id: 'r1' } });
|
||||
});
|
||||
|
||||
test('the staff list is paged and campus-scoped', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
expect((await request(app).get('/api/v2/registrations')).status).toBe(403);
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
mockAdmin.getRegistrations.mockResolvedValue({ data: [reg], total: 1, page: 1, limit: 100, pages: 1 });
|
||||
const res = await request(app).get('/api/v2/registrations?unpaid=true');
|
||||
expect(res.body).toEqual({ data: [reg], meta: { page: 1, limit: 100, total: 1, pages: 1 } });
|
||||
const call = mockAdmin.getRegistrations.mock.calls[0][0];
|
||||
expect(call.query).toMatchObject({ unpaid: '1', page: '1', limit: '100' });
|
||||
expect(typeof call.listWhere).toBe('function');
|
||||
});
|
||||
|
||||
test('a status must be one of the five', async () => {
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
const res = await request(app).patch('/api/v2/registrations/r1/status').send({ status: 'refunded' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error.code).toBe('VALIDATION_FAILED');
|
||||
expect(mockAdmin.updateRegistrationStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('registering someone at the door is for supervisors, and answers 201', async () => {
|
||||
const body = { eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 2 }], user: { name: 'Thandi', phoneNumber: '0821234567' } };
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
expect((await request(app).post('/api/v2/registrations/manual').send(body)).status).toBe(403);
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
mockAdmin.createManualRegistration.mockResolvedValue(reg);
|
||||
const res = await request(app).post('/api/v2/registrations/manual').send(body);
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockAdmin.createManualRegistration.mock.calls[0][0].body).toMatchObject(body);
|
||||
});
|
||||
|
||||
test("a refusal keeps the service's status", async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
mockAdmin.updateRegistrationOptions.mockRejectedValueOnce(new ServiceError(400, 'Cannot reduce items below the amount already paid'));
|
||||
const res = await request(app).put('/api/v2/registrations/r1/items').send({ options: [{ eventOptionId: 'o1', quantity: 1 }] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatchObject({ code: 'VALIDATION_FAILED', message: 'Cannot reduce items below the amount already paid' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('money (v1 handlers behind v2 routes)', () => {
|
||||
test("an event's cash-up reaches v1 with the event as :eventId", async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
const res = await request(app).get('/api/v2/events/e1/cashup');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.params).toMatchObject({ eventId: 'e1' });
|
||||
});
|
||||
|
||||
test('closing is for admins, and a v1 refusal keeps its code as the reason', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
expect((await request(app).post('/api/v2/events/e1/cashup/close').send({})).status).toBe(403);
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
const res = await request(app).post('/api/v2/events/e1/cashup/close').send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toEqual({ code: 'VALIDATION_FAILED', message: 'There are pending lines.', reason: 'PENDING_LINES' });
|
||||
});
|
||||
|
||||
test('a cash-up line is given to v1 as :lineId', async () => {
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
const res = await request(app).post('/api/v2/cashup-lines/l9/pending').send({ resolution: 'paid' });
|
||||
expect(res.body.data).toEqual({ params: { lineId: 'l9' }, body: { resolution: 'paid' } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { PassThrough } from 'stream';
|
||||
import { z } from 'zod';
|
||||
|
||||
jest.mock('../src/config/db', () => ({}));
|
||||
jest.mock('../src/middleware/authMiddleware', () => ({ protect: jest.fn(), optionalAuth: jest.fn(), admin: jest.fn(), staff: jest.fn(), supervisor: jest.fn() }));
|
||||
|
||||
import { mount } from '../src/v2/route';
|
||||
import { errorHandler } from '../src/v2/errors';
|
||||
import { v1Route } from '../src/v2/viaV1';
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
function appWith(...defs: ReturnType<typeof v1Route>[]) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const router = express.Router();
|
||||
mount(router, defs);
|
||||
app.use('/api/v2', router);
|
||||
app.use('/api/v2', errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('v2 routes answered by v1 handlers', () => {
|
||||
test("v1's JSON becomes data; v2's :id is given to v1 by its old name", async () => {
|
||||
const handler: RequestHandler = (req, res) => { res.json({ eventId: req.params.eventId, q: req.query.x }); };
|
||||
const app = appWith(v1Route({ method: 'get', path: '/things/:id', tag: 'T', summary: 's', access: 'public', v1: handler, map: ctx => ({ params: { eventId: ctx.params.id } }), params: z.object({ id: z.string() }) }));
|
||||
const res = await request(app).get('/api/v2/things/e1?x=2');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ data: { eventId: 'e1', q: '2' } });
|
||||
});
|
||||
|
||||
test("a v1 error is a v2 error, with v1's code as the reason", async () => {
|
||||
const handler: RequestHandler = (_req, res) => { res.status(409).json({ message: 'Already closed.', code: 'EVENT_CLOSED' }); };
|
||||
const app = appWith(v1Route({ method: 'post', path: '/close', tag: 'T', summary: 's', access: 'public', v1: handler }));
|
||||
const res = await request(app).post('/api/v2/close').send({});
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body).toEqual({ error: { code: 'CONFLICT', message: 'Already closed.', reason: 'EVENT_CLOSED' } });
|
||||
});
|
||||
|
||||
test('a thrown error and a next(err) both reach the error handler', async () => {
|
||||
const thrower: RequestHandler = async () => { throw Object.assign(new Error('Nope.'), { statusCode: 403 }); };
|
||||
const passer: RequestHandler = (_req, _res, next) => next(Object.assign(new Error('Missing.'), { statusCode: 404 }));
|
||||
const app = appWith(
|
||||
v1Route({ method: 'get', path: '/a', tag: 'T', summary: 's', access: 'public', v1: thrower }),
|
||||
v1Route({ method: 'get', path: '/b', tag: 'T', summary: 's', access: 'public', v1: passer }),
|
||||
);
|
||||
expect((await request(app).get('/api/v2/a')).body.error).toMatchObject({ code: 'FORBIDDEN', message: 'Nope.' });
|
||||
expect((await request(app).get('/api/v2/b')).body.error).toMatchObject({ code: 'NOT_FOUND', message: 'Missing.' });
|
||||
});
|
||||
|
||||
test('middleware chains run in order', async () => {
|
||||
const mark: RequestHandler = (req, _res, next) => { (req as unknown as { mark: string }).mark = 'yes'; next(); };
|
||||
const handler: RequestHandler = (req, res) => { res.json({ mark: (req as unknown as { mark: string }).mark }); };
|
||||
const app = appWith(v1Route({ method: 'get', path: '/m', tag: 'T', summary: 's', access: 'public', v1: [mark, handler] }));
|
||||
expect((await request(app).get('/api/v2/m')).body).toEqual({ data: { mark: 'yes' } });
|
||||
});
|
||||
|
||||
test('a file a v1 handler pipes is sent as a download', async () => {
|
||||
const handler: RequestHandler = (_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="ticket.pdf"');
|
||||
const s = new PassThrough();
|
||||
s.pipe(res);
|
||||
s.write(Buffer.from('%PDF-1.4 '));
|
||||
s.end(Buffer.from('end'));
|
||||
};
|
||||
const app = appWith(v1Route({ method: 'get', path: '/pdf', tag: 'T', summary: 's', access: 'public', file: { contentType: 'application/pdf' }, v1: handler }));
|
||||
const res = await request(app).get('/api/v2/pdf').buffer(true).parse((r, cb) => { const c: Buffer[] = []; r.on('data', (d: Buffer) => c.push(d)); r.on('end', () => cb(null, Buffer.concat(c))); });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/pdf');
|
||||
expect(res.headers['content-disposition']).toContain('ticket.pdf');
|
||||
expect((res.body as Buffer).toString()).toBe('%PDF-1.4 end');
|
||||
});
|
||||
});
|
||||
+44
-4
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-29, after **1.51.8** (and Hub 1.5.0). The plan itself is in
|
||||
Last updated: 2026-10-29, after **1.51.11** (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.11 | **Money on v2** — `src/v2/viaV1.ts` (`v1Route`: a v2 route answered by the v1 handler); `resources/money.ts`; `/docs/v2` collapsed with a filter |
|
||||
| 1.51.10 | **Managing registrations on v2** — `src/services/registrationAdmin.ts` (the rest of `registrationController.js`); frontend `fetchAllRegistrations()`; money split out to 1.51.11 |
|
||||
| 1.51.9 | **Series, season passes, bundles, coupons, campuses on v2** — `src/services/series.ts`, `deals.ts`, `campuses.ts`; `reason` on v2 errors from service codes; frontend `apiV2Data()` |
|
||||
| 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 |
|
||||
@@ -68,8 +71,8 @@ encrypted settings unreadable or break existing installs.
|
||||
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
|
||||
a test server (both done 2026-10-28).
|
||||
|
||||
1. **1.51.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
|
||||
1. **1.51.12 – 1.51.16: every endpoint onto v2**, one area per release (roadmap build order, row
|
||||
11b): services in TS, screens on `apiV2`, v1 routes into `SUCCESSORS`. 1.51.16 gives incoming
|
||||
webhooks and links in sent messages v2 URLs, with the v1 paths still answering (links redirect,
|
||||
webhooks handled by the same code). Then **2.0.0** — AI helpers and v1 removed.
|
||||
2. **Servers run Node 24** (Joshua, 2026-10-29), so the backend may need Node 22+. Still to do:
|
||||
@@ -466,6 +469,43 @@ 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).
|
||||
|
||||
## v1 handlers behind v2 routes (1.51.11 –)
|
||||
|
||||
- **`v1Route({ …defineRoute options, v1: handler, map? })`** (`src/v2/viaV1.ts`) answers a v2 route
|
||||
with the v1 handler, in-process: the request is the real one (user, headers, campus scope) with
|
||||
params/query/body replaced by `map(ctx)` — e.g. `map: ctx => ({ params: { eventId: ctx.params.id } })`
|
||||
where v1 named it `:eventId`. The same campus middleware goes in `middleware`.
|
||||
- v1 JSON → `{ data }`; a 4xx/5xx → the v2 error (v1 `code` → `reason`); a redirect →
|
||||
`{ data: { redirectUrl } }`; with `file`, whatever was sent or piped → a download.
|
||||
- Body and query schemas are loose (`z.looseObject`) and v1 gets the request as sent, so v2 refuses
|
||||
nothing v1 accepted. Give a `query` schema for filters the frontend passes — the typed `apiV2`
|
||||
only allows declared ones.
|
||||
- Moving these handlers into TS services (as 1.51.1 – 1.51.10 did) is a later clean-up; the v2 API
|
||||
doesn't change when it happens.
|
||||
- Frontend conversions are scripted from `SUCCESSORS` and the OpenAPI document (scratch tool, not
|
||||
in the repo); calls with a computed URL are done by hand.
|
||||
|
||||
## Managing registrations on v2 (1.51.10)
|
||||
|
||||
- **`registrationAdmin.ts` takes a `Call`** — `{ actor, params, body, query, readOnly, ip, listWhere }`
|
||||
— and returns what v1 answered, because these handlers were moved nearly as they were (about
|
||||
1,400 lines). v1 builds it with `callOf(req)`, v2 with `callFrom(req, user, parts)` in
|
||||
`resources/registrationsManage.ts`. Lists that are campus-scoped call `call.listWhere()`, which
|
||||
refuses (500) when a caller didn't give one, so nothing is listed unscoped by mistake.
|
||||
- Errors a JS helper throws without a status were 400s on v1; `as400` keeps them 400 on v2
|
||||
(TypeErrors and the like stay 500s).
|
||||
- The staff list is paged on v2 (up to 200 a page); screens that need everything use
|
||||
`fetchAllRegistrations(token, { unpaid?, userId? })`.
|
||||
|
||||
## Series, deals and campuses on v2 (1.51.9)
|
||||
|
||||
- **`apiV2Data(method, path, opts)`** (`lib/apiV2.ts`) returns `data` untyped for screens that keep
|
||||
their own record types: `await (apiV2Data("get", "/coupons", { authToken }) as Promise<Coupon[]>)`.
|
||||
TypeScript can't infer the method and path when a result type is given, so cast instead.
|
||||
- **Codes in service errors** (`fail(409, message, 'CAMPUS_HAS_EVENTS')`) reach v2 clients as
|
||||
`error.reason`, and v1 controllers still send them as `code`.
|
||||
- Answers that aren't failures (a coupon that doesn't apply) are `200` with a flag on v2.
|
||||
|
||||
## 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`) —
|
||||
@@ -476,7 +516,7 @@ a test server (both done 2026-10-28).
|
||||
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).
|
||||
email/WhatsApp senders (messaging, 1.51.14).
|
||||
|
||||
## Setting events up on v2 (1.51.7)
|
||||
|
||||
|
||||
+5
-4
@@ -41,8 +41,9 @@ shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is
|
||||
pass** shipped. 1.50.1 **pagination and search on long lists** and 1.50.2 **event picker and webhook guide** shipped. 1.51.0 **TypeScript backend and `/api/v2`
|
||||
foundations**, 1.51.1 **v2 registrations and payments**, 1.51.2 **v2 accounts and check-in** and 1.51.3 **Express 5,
|
||||
scanner on v2, v1 deprecation headers** shipped. 1.51.4 **security and dependency updates**, 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.**
|
||||
ExcelTS** , 1.51.7 **setting events up on v2, pg-boss 11** and 1.51.8 **the rest of event set-up on v2** and 1.51.9
|
||||
**series, passes, deals and campuses on v2** 1.51.10 **managing registrations on v2** and 1.51.11 **money on v2** shipped.
|
||||
**Next: 1.51.12 – 1.51.16 (every endpoint onto v2), then 2.0.0.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -1011,7 +1012,7 @@ income justify the hardware:
|
||||
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
|
||||
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (Afrikaans removed from scope) | Polish before the big release |
|
||||
| 11 | 1.51.0 ✅, 1.51.1 ✅, 1.51.2 ✅, 1.51.3 ✅ | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 registrations and payments as TS services, typed frontend calls; 1.51.2 check-in and accounts; 1.51.3 Express 5, the scanner on v2, v1 deprecation headers, v2-shaped shared middleware | The breaking-change groundwork |
|
||||
| 11b | 1.51.4 ✅ – 1.51.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 |
|
||||
| 11b | 1.51.4 ✅ – 1.51.11 ✅, 1.51.12 – 1.51.16 | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 managing registrations (the staff lists, status, at the door, bulk actions, items, coupons, attendees, attendee forms); 1.51.11 money, staff side (payments, cashup, provider transactions, waitlist, sponsorship, consent, form builder, feedback); 1.51.12 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.13 kids check-in and volunteer rosters; 1.51.14 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.15 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.16 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each puts its area's endpoints on v2 (1.51.1 – 1.51.10 by moving the logic into TS services; from 1.51.11 by `v1Route`, which runs the v1 handler behind a v2 route — moving those handlers into TS services is a 2.x clean-up that doesn't change the API), its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
|
||||
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 removed (all of it is on v2 by then; the old webhook URLs and links already sent in messages keep forwarding to v2), migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
@@ -1049,7 +1050,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) |
|
||||
| — | v1 in 2.0 | **Every endpoint moves to v2 before 2.0** (1.51.7 – 1.51.16, 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.16); 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) |
|
||||
|
||||
@@ -148,12 +148,106 @@ test.describe("API v2", () => {
|
||||
expect(costs.map((c: { label: string }) => c.label)).toContain("E2E Hall");
|
||||
});
|
||||
|
||||
test("series, coupon codes, bundle quotes and campuses", async ({ request }) => {
|
||||
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
|
||||
const user = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
|
||||
const made = await request.post(`${API}/api/v2/series`, { headers: sup, data: { name: "E2E v2 Series", description: "Made through v2" } });
|
||||
expect(made.status()).toBe(201);
|
||||
const series = (await made.json()).data;
|
||||
const renamed = (await (await request.patch(`${API}/api/v2/series/${series.id}`, { headers: sup, data: { name: "E2E v2 Series (renamed)" } })).json()).data;
|
||||
expect(renamed.name).toBe("E2E v2 Series (renamed)");
|
||||
const page = (await (await request.get(`${API}/api/v2/series/by-slug/${series.slug}`, { headers: user })).json()).data;
|
||||
expect(page).toMatchObject({ id: series.id, following: false, upcoming: [], past: [] });
|
||||
expect((await (await request.put(`${API}/api/v2/series/${series.id}/follow`, { headers: user })).json()).data).toEqual({ following: true });
|
||||
|
||||
const ev = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`)).json()).data;
|
||||
const opt = ev.eventOptions[0];
|
||||
const code = await request.post(`${API}/api/v2/coupons`, { headers: sup, data: { code: "E2EV2TEN", type: "percent", value: 10, eventIds: [ev.id] } });
|
||||
expect(code.status()).toBe(201);
|
||||
const basket = { eventId: ev.id, options: [{ eventOptionId: opt.id, quantity: 1 }] };
|
||||
const ok = (await (await request.post(`${API}/api/v2/coupons/check`, { headers: user, data: { ...basket, code: "E2EV2TEN" } })).json()).data;
|
||||
expect(ok).toMatchObject({ valid: true, code: "E2EV2TEN" });
|
||||
const bad = await request.post(`${API}/api/v2/coupons/check`, { headers: user, data: { ...basket, code: "NO-SUCH-CODE" } });
|
||||
expect(bad.status()).toBe(200);
|
||||
expect((await bad.json()).data.valid).toBe(false);
|
||||
|
||||
const quote = (await (await request.post(`${API}/api/v2/bundles/quote`, { data: basket })).json()).data;
|
||||
expect(quote).toMatchObject({ bundleDiscount: 0 });
|
||||
expect(quote.total).toBe(quote.subtotal);
|
||||
|
||||
const campuses = (await (await request.get(`${API}/api/v2/campuses`)).json()).data;
|
||||
// On or off depending on which tests ran first: when off, the list is empty.
|
||||
if (campuses.enabled) expect(campuses.campuses.map((c: { slug: string }) => c.slug)).toEqual(expect.arrayContaining([seed().campuses.north.slug]));
|
||||
else expect(campuses.campuses).toEqual([]);
|
||||
expect((await request.get(`${API}/api/v2/campuses/admin`, { headers: sup })).status()).toBe(403);
|
||||
});
|
||||
|
||||
test("staff register someone at the door and manage the registration", async ({ request }) => {
|
||||
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
|
||||
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
|
||||
const ev = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`)).json()).data;
|
||||
const opt = ev.eventOptions[0];
|
||||
|
||||
const made = await request.post(`${API}/api/v2/registrations/manual`, {
|
||||
headers: sup,
|
||||
data: { eventId: ev.id, options: [{ eventOptionId: opt.id, quantity: 1 }], user: { name: "E2E Door Guest", email: "e2e-v2-door@example.com" }, skipActivationNotice: true },
|
||||
});
|
||||
expect(made.status()).toBe(201);
|
||||
const reg = (await made.json()).data;
|
||||
expect(reg).toMatchObject({ eventId: ev.id, status: expect.any(String) });
|
||||
|
||||
const list = (await (await request.get(`${API}/api/v2/events/${ev.id}/registrations?search=e2e-v2-door`, { headers: sup })).json()).data;
|
||||
expect(list.map((r: { id: string }) => r.id)).toContain(reg.id);
|
||||
|
||||
const full = (await (await request.get(`${API}/api/v2/registrations/${reg.id}/full`, { headers: sup })).json()).data;
|
||||
expect(full.registrationOptions[0]).toMatchObject({ eventOptionId: opt.id, quantity: 1 });
|
||||
|
||||
const bad = await request.patch(`${API}/api/v2/registrations/${reg.id}/status`, { headers: sup, data: { status: "refunded" } });
|
||||
expect(bad.status()).toBe(400);
|
||||
expect((await bad.json()).error.code).toBe("VALIDATION_FAILED");
|
||||
|
||||
const page = await (await request.get(`${API}/api/v2/registrations?limit=2`, { headers: sup })).json();
|
||||
expect(page.meta).toMatchObject({ page: 1, limit: 2 });
|
||||
expect(page.data.length).toBeLessThanOrEqual(2);
|
||||
|
||||
// Tidy up: cancelled registrations don't count towards anything.
|
||||
const cancelled = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers: admin });
|
||||
expect((await cancelled.json()).data.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
test("money: providers, cash-up, my payments, and links that don't exist", async ({ request }) => {
|
||||
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
|
||||
const user = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
const providers = await request.get(`${API}/api/v2/payments/providers`);
|
||||
expect(providers.status()).toBe(200);
|
||||
expect(await providers.json()).toHaveProperty("data");
|
||||
|
||||
const cash = await request.get(`${API}/api/v2/events/${seed().events.conference}/cashup`, { headers: sup });
|
||||
expect(cash.status()).toBe(200);
|
||||
expect((await cash.json()).data).toBeTruthy();
|
||||
|
||||
const mine = await request.get(`${API}/api/v2/me/payments?page=1&limit=5`, { headers: user });
|
||||
expect(mine.status()).toBe(200);
|
||||
|
||||
const page = await request.get(`${API}/api/v2/payments?page=1&limit=3`, { headers: sup });
|
||||
expect((await page.json()).data).toMatchObject({ page: 1, limit: 3 });
|
||||
|
||||
for (const path of ["/api/v2/waitlist/claims/not-a-real-token", "/api/v2/consent/not-a-real-token", "/api/v2/feedback-forms/not-a-real-token"]) {
|
||||
const res = await request.get(`${API}${path}`);
|
||||
expect(res.status(), path).toBeGreaterThanOrEqual(400);
|
||||
expect((await res.json()).error.code, path).toMatch(/NOT_FOUND|VALIDATION_FAILED/);
|
||||
}
|
||||
});
|
||||
|
||||
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
|
||||
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
expect((await request.get(`${API}/api/v2/openapi.json`, { headers })).status()).toBe(403);
|
||||
|
||||
await page.goto(`${API}/docs/v2?token=${seed().users.admin.token}`);
|
||||
await expect(page.getByRole("heading", { name: /Cross Events API/ })).toBeVisible();
|
||||
// Sections start collapsed (there are hundreds of endpoints): open Events.
|
||||
await page.locator('h3.opblock-tag[data-tag="Events"]').click();
|
||||
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=");
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.8",
|
||||
"version": "1.51.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.8",
|
||||
"version": "1.51.11",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.8",
|
||||
"version": "1.51.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -5,10 +5,11 @@ import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { EventCard } from "@/components/events/EventCard";
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { apiFetch, apiFetchListForPrerender, ApiError } from "@/lib/api";
|
||||
import { apiFetchListForPrerender, ApiError } from "@/lib/api";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
import { CalendarX, MapPin, Mail, Phone, User, ArrowLeft } from "lucide-react";
|
||||
import type { Campus } from "@/hooks/useCampuses";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
@@ -16,7 +17,7 @@ type Event = React.ComponentProps<typeof EventCard>["event"] & { cashupStatus?:
|
||||
|
||||
async function loadCampus(slug: string): Promise<Campus | null> {
|
||||
try {
|
||||
return await apiFetch<Campus>(`/api/campuses/by-slug/${encodeURIComponent(slug)}`, { nextOptions: { next: { revalidate } } });
|
||||
return await (apiV2Data("get", "/campuses/by-slug/{slug}", { params: { slug }, nextOptions: { next: { revalidate } } }) as Promise<Campus>);
|
||||
} catch (e) {
|
||||
// 404: no such campus, archived, or campuses are off.
|
||||
if (e instanceof ApiError && e.status === 404) return null;
|
||||
|
||||
@@ -3,9 +3,10 @@ import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { ShieldCheck, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type ConsentInfo = {
|
||||
status: "pending" | "given" | "declined";
|
||||
@@ -39,7 +40,7 @@ export default function ConsentPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<ConsentInfo>(`/api/consent/${encodeURIComponent(token)}`)
|
||||
(apiV2Data("get", "/consent/{token}", { params: { token: token } }) as Promise<ConsentInfo>)
|
||||
.then(res => { setInfo(res); setSignedName(res.parentName || ""); })
|
||||
.catch(e => setLoadError(e instanceof ApiError ? e.message : "Couldn't load this consent request."));
|
||||
}, [token]);
|
||||
@@ -50,8 +51,8 @@ export default function ConsentPage() {
|
||||
if (decision === "give" && info.collect.photo && photoConsent === null) { setError("Please answer the photo consent question."); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiFetch(`/api/consent/${encodeURIComponent(token)}`, {
|
||||
method: "POST",
|
||||
await apiV2Data("post", "/consent/{token}", {
|
||||
params: { token: token },
|
||||
body: {
|
||||
decision, signedName, isGuardian,
|
||||
...(decision === "give" && info.collect.medical ? { medical } : {}),
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, fetchEvent } from "@/lib/api";
|
||||
import { 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";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
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() {
|
||||
setError(null);
|
||||
try {
|
||||
const [financials, event] = await Promise.all([
|
||||
apiFetch<EventFinancials>(`/api/cashups/event/${eventId}`, { authToken: token }),
|
||||
(apiV2Data("get", "/events/{id}/cashup", { params: { id: eventId }, authToken: token }) as Promise<EventFinancials>),
|
||||
fetchEvent<Event>(eventId, { authToken: token })
|
||||
]);
|
||||
setData(financials);
|
||||
@@ -309,7 +310,7 @@ function CashByUserSection({ eventId, token, onCashSummaryChange }: {
|
||||
const load = useCallback(() => {
|
||||
if (!token || !eventId) return;
|
||||
setLoading(true);
|
||||
apiFetch<AccountabilityResponse>(`/api/cashups/event/${eventId}/cash-by-user`, { authToken: token })
|
||||
(apiV2Data("get", "/events/{id}/cashup/cash-by-user", { params: { id: eventId }, authToken: token }) as Promise<AccountabilityResponse>)
|
||||
.then(r => { setData(r); onCashSummaryChange?.({ actualTotal: r?.cashActualTotal ?? null, denominations: r?.cashDenominations || [] }); })
|
||||
.catch(() => setData({ rows: [], cashActualTotal: null, cashDenominations: [] }))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -458,8 +459,10 @@ function PersonCashCountEditor({ eventId, token, userId, initialDenominations, i
|
||||
const save = async () => {
|
||||
setSaving(true); setErr(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/person-cash/${userId}`, {
|
||||
method: "PUT", authToken: token, body: { denominations: lines, notes: notes || null }
|
||||
await apiV2Data("put", "/events/{id}/cashup/person-cash/{userId}", {
|
||||
params: { id: eventId, userId: userId },
|
||||
authToken: token,
|
||||
body: { denominations: lines, notes: notes || null },
|
||||
});
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
@@ -833,7 +836,7 @@ function PendingSettler({ method, r, token, onChanged }: { method: CashupMethod;
|
||||
if (!r.lineId || !mode) return;
|
||||
setSaving(true); setErr(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/lines/${r.lineId}/pending`, { method: "POST", authToken: token, body: { outcome: mode, amount: parseFloat(amount), category, notes: notes || null } });
|
||||
await apiV2Data("post", "/cashup-lines/{id}/pending", { params: { id: r.lineId }, authToken: token, body: { outcome: mode, amount: parseFloat(amount), category, notes: notes || null } });
|
||||
setMode(null);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
@@ -972,7 +975,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
const saveDraft = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/draft`, { method: "PUT", authToken: token, body: { lines: buildLinesPayload() } });
|
||||
await apiV2Data("put", "/events/{id}/cashup/draft", { params: { id: eventId }, authToken: token, body: { lines: buildLinesPayload() } });
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to save draft");
|
||||
@@ -992,7 +995,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
if (!confirm(`Close this event with this cashup? Unallocated donations will be counted as profit and the event will be locked until it's reopened.${warning}`)) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
|
||||
await apiV2Data("post", "/events/{id}/cashup/close", { params: { id: eventId }, authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
|
||||
onChanged();
|
||||
onClosed();
|
||||
} catch (e) {
|
||||
@@ -1006,7 +1009,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
if (!confirm("Quick close this event without a cashup? System totals will be accepted as-is, unallocated donations will be counted as profit, and the event will be fully locked until it's reopened.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } });
|
||||
await apiV2Data("post", "/events/{id}/cashup/close", { params: { id: eventId }, authToken: token, body: { notes: closeNotes || null } });
|
||||
onChanged();
|
||||
onClosed();
|
||||
} catch (e) {
|
||||
@@ -1020,7 +1023,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
if (!confirm("Reopen this event? Registrations, payments, refunds and donations will be allowed again.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/reopen`, { method: "POST", authToken: token, body: { notes: reopenNotes || null } });
|
||||
await apiV2Data("post", "/events/{id}/cashup/reopen", { params: { id: eventId }, authToken: token, body: { notes: reopenNotes || null } });
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to reopen event");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchManagedEvents } from "@/lib/api";
|
||||
import { fetchManagedEvents, fetchAllRegistrations } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
@@ -12,6 +12,7 @@ import { BackLink } from "@/components/shared/BackLink";
|
||||
import { RegistrationBulkBar } from "@/components/admin/RegistrationBulkBar";
|
||||
import { SavedFilters } from "@/components/shared/SavedFilters";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
|
||||
|
||||
@@ -80,7 +81,7 @@ export default function AdminRegistrationsPage() {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
|
||||
const regs = await fetchAllRegistrations(token);
|
||||
const list = Array.isArray(regs) ? regs.sort((a, b) => {
|
||||
const eventCompare = (a.event?.startDate || a.eventId).localeCompare(b.event?.startDate || b.eventId);
|
||||
if (eventCompare !== 0) return eventCompare;
|
||||
@@ -124,7 +125,7 @@ export default function AdminRegistrationsPage() {
|
||||
if (!formResponses[id] && !loadingForms.has(id)) {
|
||||
setLoadingForms(prev => new Set(prev).add(id));
|
||||
try {
|
||||
const res = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(id)}`, { authToken: token! });
|
||||
const res = await (apiV2Data("get", "/form-responses", { query: { registrationId: id }, authToken: token }) as Promise<{ items?: FormResponse[] } | FormResponse[]>);
|
||||
const items = Array.isArray(res) ? res : (Array.isArray(res?.items) ? res.items : []);
|
||||
setFormResponses(prev => ({ ...prev, [id]: items }));
|
||||
} catch {
|
||||
@@ -140,7 +141,7 @@ export default function AdminRegistrationsPage() {
|
||||
setError(null); setInfo(null);
|
||||
if (!confirm(`Cancel registration #${String(reg.id).slice(0, 8)} for ${reg.user?.name || reg.userId}?`)) return;
|
||||
try {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "DELETE", authToken: token });
|
||||
await apiV2Data("post", "/registrations/{id}/cancel", { params: { id: reg.id }, authToken: token });
|
||||
setInfo("Registration cancelled");
|
||||
await loadRegistrations();
|
||||
} catch (e) {
|
||||
@@ -152,7 +153,7 @@ export default function AdminRegistrationsPage() {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "PUT", authToken: token, body: { status } });
|
||||
await apiV2Data("patch", "/registrations/{id}/status", { params: { id: reg.id }, authToken: token, body: { status: status as "pending" | "confirmed" | "partial_paid" | "paid" | "cancelled" } });
|
||||
setInfo("Status updated");
|
||||
await loadRegistrations();
|
||||
} catch (e) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { bookableSessions, choosesSessions, isPickOption, todaysSession } from "
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2 } from "@/lib/apiV2";
|
||||
import { apiV2, apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
|
||||
|
||||
@@ -265,20 +265,20 @@ export default function AtTheDoorPage() {
|
||||
try {
|
||||
if (pendingEditReg) {
|
||||
// Edit existing registration — replace options via PUT
|
||||
let res = await apiFetch<Registration>(`/api/registrations/${pendingEditReg.id}/options`, {
|
||||
method: "PUT",
|
||||
authToken: token,
|
||||
body: { options: opts }
|
||||
});
|
||||
let res = await (apiV2Data("put", "/registrations/{id}/items", {
|
||||
params: { id: pendingEditReg.id },
|
||||
authToken: token,
|
||||
body: { options: opts },
|
||||
}) as Promise<Registration>);
|
||||
let message = "Registration updated";
|
||||
if (doorCoupon) {
|
||||
// Applied after the items are saved, so it's worked out on the final items.
|
||||
try {
|
||||
res = await apiFetch<Registration>(`/api/registrations/${pendingEditReg.id}/coupon`, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { code: doorCoupon.code },
|
||||
});
|
||||
res = await (apiV2Data("post", "/registrations/{id}/coupon", {
|
||||
params: { id: pendingEditReg.id },
|
||||
authToken: token,
|
||||
body: { code: doorCoupon.code },
|
||||
}) as Promise<Registration>);
|
||||
message = `Registration updated — code ${doorCoupon.code} applied`;
|
||||
} catch (ce) {
|
||||
message = `Registration updated, but the code wasn't applied: ${errorMessage(ce) || "unknown error"}`;
|
||||
@@ -290,10 +290,9 @@ export default function AtTheDoorPage() {
|
||||
handleRegistrationSelected(res);
|
||||
} else if (pendingUser) {
|
||||
// New attendee
|
||||
const res = await apiFetch<Registration & { couponError?: string }>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
const res = await (apiV2Data("post", "/registrations/manual", {
|
||||
authToken: token,
|
||||
body: {
|
||||
eventId,
|
||||
user: {
|
||||
name: pendingUser.name,
|
||||
@@ -306,8 +305,8 @@ export default function AtTheDoorPage() {
|
||||
...(doorAllowsNames && (doorNaming || doorYouthStart) && attendeesPayload(nameOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries).length > 0
|
||||
? { attendees: attendeesPayload(nameOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries), ticketDelivery: doorDelivery }
|
||||
: {}),
|
||||
}
|
||||
});
|
||||
},
|
||||
}) as Promise<Registration & { couponError?: string }>);
|
||||
setShowOptionsModal(false);
|
||||
if (res?.couponError) setError(`Registered, but the code wasn't applied: ${res.couponError}`);
|
||||
handleRegistrationCreated(res);
|
||||
@@ -360,7 +359,7 @@ export default function AtTheDoorPage() {
|
||||
const handleEditRegistration = async (reg: Registration) => {
|
||||
let freshReg = reg;
|
||||
try {
|
||||
freshReg = await apiFetch<Registration>(`/api/registrations/${reg.id}`, { authToken: token });
|
||||
freshReg = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: reg.id }, authToken: token }) as Promise<Registration>);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load latest registration data");
|
||||
return;
|
||||
@@ -575,7 +574,7 @@ function DoorRegistrationPanel({ token, eventId, onSelected, onEditRegistration,
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>);
|
||||
if (!cancelled) setAllRegs(regs || []);
|
||||
} catch {
|
||||
if (!cancelled) setAllRegs([]);
|
||||
@@ -601,7 +600,7 @@ function DoorRegistrationPanel({ token, eventId, onSelected, onEditRegistration,
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>);
|
||||
setAllRegs(regs || []);
|
||||
} catch {} finally { setLoading(false); }
|
||||
}}
|
||||
@@ -705,19 +704,19 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
await apiFetch("/api/payments", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
await apiV2Data("post", "/payments", {
|
||||
authToken: token,
|
||||
body: {
|
||||
registrationId: registration.id,
|
||||
amount: parseFloat(amount),
|
||||
method
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const updated = await apiFetch<Registration>(`/api/registrations/${registration.id}`, {
|
||||
authToken: token
|
||||
});
|
||||
const updated = await (apiV2Data("get", "/registrations/{id}/full", {
|
||||
params: { id: registration.id },
|
||||
authToken: token,
|
||||
}) as Promise<Registration>);
|
||||
|
||||
const updatedBalance = Math.max(0, registrationTotal(updated) - totalPaidOn(updated));
|
||||
|
||||
@@ -1426,16 +1425,15 @@ function DonationModal({ open, onClose, user, token, eventId, onSuccess, setErro
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
await apiFetch("/api/payments", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
await apiV2Data("post", "/payments", {
|
||||
authToken: token,
|
||||
body: {
|
||||
amount: amt,
|
||||
method,
|
||||
userId: user?.id || undefined,
|
||||
eventId,
|
||||
isDonation: true
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
onSuccess?.();
|
||||
@@ -1665,19 +1663,18 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
|
||||
setSaving(true);
|
||||
setLocalError("");
|
||||
try {
|
||||
await apiFetch("/api/payments/refund", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
await apiV2Data("post", "/payments/refunds", {
|
||||
authToken: token,
|
||||
body: {
|
||||
userId: registration.userId,
|
||||
registrationId: registration.id,
|
||||
amount: amt,
|
||||
method,
|
||||
reason: reason || undefined,
|
||||
}
|
||||
},
|
||||
});
|
||||
// Re-fetch registration to get updated status & payments
|
||||
const updated = await apiFetch<Registration>(`/api/registrations/${registration.id}`, { authToken: token });
|
||||
const updated = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registration.id }, authToken: token }) as Promise<Registration>);
|
||||
onRefunded(updated);
|
||||
} catch (e) {
|
||||
setLocalError(errorMessage(e) || "Refund failed.");
|
||||
@@ -1778,7 +1775,7 @@ function DoorRefundPanel({ token, eventId, setInfo }: {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>);
|
||||
setAllRegs(regs || []);
|
||||
} catch {
|
||||
setAllRegs([]);
|
||||
@@ -1800,7 +1797,7 @@ function DoorRefundPanel({ token, eventId, setInfo }: {
|
||||
setSelectedReg(updated);
|
||||
setInfo("Refund recorded");
|
||||
// Refresh list
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token }).catch(() => null);
|
||||
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>).catch(() => null);
|
||||
if (regs) setAllRegs(regs);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchManagedEvents } from "@/lib/api";
|
||||
import { 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";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import type { BundleGroupPricing, BundleKind, EventBundle } from "@/types";
|
||||
import { EventPicker, defaultEventId } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type OptionLite = { id: string; name: string; price: number; variants?: { id: string; name: string; price?: number | null }[] };
|
||||
type EventLite = { id: string; title: string; startDate: string; endDate?: string; eventOptions: OptionLite[] };
|
||||
@@ -118,7 +119,7 @@ export default function BundlesPage() {
|
||||
if (!token || !eventId) { setBundles([]); return; }
|
||||
setFetching(true);
|
||||
try {
|
||||
setBundles(await apiFetch<EventBundle[]>(`/api/bundles/event/${eventId}`, { authToken: token }) || []);
|
||||
setBundles(await (apiV2Data("get", "/events/{id}/bundles", { params: { id: eventId }, authToken: token }) as Promise<EventBundle[]>));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load bundles");
|
||||
} finally {
|
||||
@@ -130,7 +131,7 @@ export default function BundlesPage() {
|
||||
|
||||
const toggleActive = async (b: EventBundle) => {
|
||||
try {
|
||||
await apiFetch(`/api/bundles/${b.id}`, { method: "PUT", authToken: token!, body: { isActive: !b.isActive } });
|
||||
await apiV2Data("patch", "/bundles/{id}", { params: { id: b.id }, authToken: token, body: { isActive: !b.isActive } });
|
||||
setInfo(`${b.name} turned ${b.isActive ? "off" : "on"}.`);
|
||||
load();
|
||||
} catch (e) {
|
||||
@@ -141,7 +142,7 @@ export default function BundlesPage() {
|
||||
const remove = async (b: EventBundle) => {
|
||||
if (!window.confirm(`Delete "${b.name}"?`)) return;
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/bundles/${b.id}`, { method: "DELETE", authToken: token! });
|
||||
const res = await (apiV2Data("delete", "/bundles/{id}", { params: { id: b.id }, authToken: token }) as Promise<{ message: string }>);
|
||||
setInfo(res?.message || "Bundle deleted");
|
||||
load();
|
||||
} catch (e) {
|
||||
@@ -357,10 +358,10 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
|
||||
isActive: d.isActive,
|
||||
};
|
||||
if (bundleId) {
|
||||
await apiFetch(`/api/bundles/${bundleId}`, { method: "PUT", authToken: token, body });
|
||||
await apiV2Data("patch", "/bundles/{id}", { params: { id: bundleId }, authToken: token, body: body as never });
|
||||
onSaved(`${body.name} saved.`);
|
||||
} else {
|
||||
await apiFetch(`/api/bundles/event/${event.id}`, { method: "POST", authToken: token, body });
|
||||
await apiV2Data("post", "/events/{id}/bundles", { params: { id: event.id }, authToken: token, body: body as never });
|
||||
onSaved(`${body.name} created.`);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -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, fetchManagedEvents } from "@/lib/api";
|
||||
import { fetchManagedEvents } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ShieldCheck, Send, RefreshCw, HeartPulse, Printer } from "lucide-react";
|
||||
@@ -12,6 +12,7 @@ import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, ConsentRow, MedicalRow } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker, defaultEventId } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
|
||||
const STATUS_STYLE: Record<ConsentRow["status"], { label: string; cls: string }> = {
|
||||
@@ -38,7 +39,8 @@ export default function ConsentTrackerPage() {
|
||||
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState<string>(search.get("eventId") || "");
|
||||
const [data, setData] = useState<{ event: Pick<Event, "id" | "title">; counts: Record<string, number>; rows: ConsentRow[] } | null>(null);
|
||||
type ConsentData = { event: Pick<Event, "id" | "title">; counts: Record<string, number>; rows: ConsentRow[] };
|
||||
const [data, setData] = useState<ConsentData | null>(null);
|
||||
const [filter, setFilter] = useState<"minors" | "all" | "waiting">("minors");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
@@ -60,7 +62,7 @@ export default function ConsentTrackerPage() {
|
||||
if (!token || !eventId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
setData(await apiFetch(`/api/consent/events/${encodeURIComponent(eventId)}`, { authToken: token }));
|
||||
setData(await (apiV2Data("get", "/events/{id}/consent", { params: { id: eventId }, authToken: token }) as Promise<ConsentData>));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load consent status");
|
||||
} finally {
|
||||
@@ -79,10 +81,10 @@ export default function ConsentTrackerPage() {
|
||||
|
||||
const outdatedCount = (data?.rows || []).filter(r => r.request?.outdated || (r.minor && r.status === "missing")).length;
|
||||
|
||||
const act = async (fn: () => Promise<{ message: string }>) => {
|
||||
const act = async (fn: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fn();
|
||||
const res = (await fn()) as { message: string };
|
||||
setInfo(res.message);
|
||||
await load();
|
||||
} catch (e) {
|
||||
@@ -92,14 +94,14 @@ export default function ConsentTrackerPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resendOne = (r: ConsentRow) => act(() => apiFetch(`/api/consent/requests/${r.request!.id}/resend`, { method: "POST", authToken: token! }));
|
||||
const resendAll = () => act(() => apiFetch(`/api/consent/events/${eventId}/resend-pending`, { method: "POST", authToken: token! }));
|
||||
const rerequest = () => act(() => apiFetch(`/api/consent/events/${eventId}/rerequest`, { method: "POST", authToken: token! }));
|
||||
const resendOne = (r: ConsentRow) => act(() => apiV2Data("post", "/consent-requests/{id}/resend", { params: { id: r.request!.id }, authToken: token, body: {} }));
|
||||
const resendAll = () => act(() => apiV2Data("post", "/events/{id}/consent/resend-pending", { params: { id: eventId }, authToken: token, body: {} }));
|
||||
const rerequest = () => act(() => apiV2Data("post", "/events/{id}/consent/rerequest", { params: { id: eventId }, authToken: token, body: {} }));
|
||||
|
||||
// Printable list for camp leaders. Opening it is recorded in the audit log.
|
||||
const printMedical = async () => {
|
||||
try {
|
||||
const res = await apiFetch<{ event: Pick<Event, "id" | "title">; rows: MedicalRow[] }>(`/api/consent/events/${eventId}/medical`, { authToken: token! });
|
||||
const res = await (apiV2Data("get", "/events/{id}/consent/medical", { params: { id: eventId }, authToken: token }) as Promise<{ event: Pick<Event, "id" | "title">; rows: MedicalRow[] }>);
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) { setError("Allow pop-ups to print the medical list."); return; }
|
||||
const rowsHtml = res.rows.map(r => `
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { TicketPercent, Plus, X, Search, Pencil, Power, Users, CalendarClock, ListChecks } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type CouponType = "percent" | "fixed" | "comp";
|
||||
type Role = "user" | "staff" | "supervisor" | "admin";
|
||||
@@ -163,7 +164,7 @@ export default function CouponsPage() {
|
||||
setFetching(true);
|
||||
try {
|
||||
const [c, ev] = await Promise.all([
|
||||
apiFetch<Coupon[]>("/api/coupons", { authToken: token }),
|
||||
(apiV2Data("get", "/coupons", { authToken: token }) as Promise<Coupon[]>),
|
||||
fetchManagedEvents<EventLite>(token, { past: true }),
|
||||
]);
|
||||
setCoupons(c || []);
|
||||
@@ -191,7 +192,7 @@ export default function CouponsPage() {
|
||||
|
||||
const toggleActive = async (c: Coupon) => {
|
||||
try {
|
||||
await apiFetch(`/api/coupons/${c.id}`, { method: "PUT", authToken: token!, body: { active: !c.active } });
|
||||
await apiV2Data("patch", "/coupons/{id}", { params: { id: c.id }, authToken: token, body: { active: !c.active } });
|
||||
setInfo(`${c.code} turned ${c.active ? "off" : "on"}.`);
|
||||
load();
|
||||
} catch (e) {
|
||||
@@ -201,7 +202,7 @@ export default function CouponsPage() {
|
||||
|
||||
const openUses = async (c: Coupon) => {
|
||||
try {
|
||||
const full = await apiFetch<Coupon & { redemptions: Redemption[] }>(`/api/coupons/${c.id}`, { authToken: token! });
|
||||
const full = await (apiV2Data("get", "/coupons/{id}", { params: { id: c.id }, authToken: token }) as Promise<Coupon & { redemptions: Redemption[] }>);
|
||||
setViewing({ coupon: c, redemptions: full.redemptions || [] });
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load uses");
|
||||
@@ -473,10 +474,10 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
|
||||
try {
|
||||
const body = bodyFromDraft(d);
|
||||
if (couponId) {
|
||||
await apiFetch(`/api/coupons/${couponId}`, { method: "PUT", authToken: token, body });
|
||||
await apiV2Data("patch", "/coupons/{id}", { params: { id: couponId }, authToken: token, body: body as never });
|
||||
onSaved(`${body.code.toUpperCase()} saved.`);
|
||||
} else {
|
||||
await apiFetch(`/api/coupons`, { method: "POST", authToken: token, body });
|
||||
await apiV2Data("post", "/coupons", { authToken: token, body: body as never });
|
||||
onSaved(`${body.code.toUpperCase()} created.`);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { BackLink } from "@/components/shared/BackLink";
|
||||
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
|
||||
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Attendee = { id: string; name: string; email: string; pref: string };
|
||||
type TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets';
|
||||
@@ -390,7 +391,7 @@ function EmailAttendeesPageInner() {
|
||||
setAttendees([]);
|
||||
setSelectedAttendeeIds([]);
|
||||
if (!eventId || !token) return;
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>);
|
||||
const uniq = new Map<string, Attendee>();
|
||||
(regs || []).forEach((r) => {
|
||||
const u: User | undefined = r?.user;
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "@/components/events/ScheduleEditor";
|
||||
import { toSessionDraft, scheduleSummary } from "@/lib/sessions";
|
||||
import { fetchApiFile, openBlob } from "@/lib/export";
|
||||
import { apiV2 } from "@/lib/apiV2";
|
||||
import { apiV2, apiV2Data } from "@/lib/apiV2";
|
||||
import { createTicketOption, updateTicketOption, listSections, createSection, updateSection, deleteSection, listAttachments, uploadAttachment, deleteAttachment, uploadImage } from "@/lib/eventSetup";
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -959,7 +959,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
const [seriesList, setSeriesList] = useState<EventSeries[]>([]);
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<EventSeries[]>("/api/series", { authToken: token }).then(l => setSeriesList(Array.isArray(l) ? l : [])).catch(() => {});
|
||||
(apiV2Data("get", "/series", { authToken: token }) as Promise<EventSeries[]>).then(l => setSeriesList(l)).catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
// ── multi-day schedule + attendance settings ──
|
||||
@@ -1077,7 +1077,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
const feedbackFrom = mode === "edit" ? ev?.id : cloning ? source?.id : undefined;
|
||||
useEffect(() => {
|
||||
if (!feedbackFrom || !token) return;
|
||||
apiFetch<FeedbackFormResponse | null>(`/api/feedback/event/${feedbackFrom}`, { authToken: token }).then(f => {
|
||||
(apiV2Data("get", "/events/{id}/feedback", { params: { id: feedbackFrom }, authToken: token }) as Promise<FeedbackFormResponse | null>).then(f => {
|
||||
if (!f) return;
|
||||
setFbEnabled(true);
|
||||
if (cloning) {
|
||||
@@ -1111,8 +1111,9 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
};
|
||||
const saveFeedback = async (eventId: string) => {
|
||||
if (fbEnabled) {
|
||||
await apiFetch(`/api/feedback/event/${eventId}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
await apiV2Data("put", "/events/{id}/feedback", {
|
||||
params: { id: eventId },
|
||||
authToken: token,
|
||||
body: {
|
||||
sendAt: fbSettings.sendAt ? new Date(fbSettings.sendAt).toISOString() : null,
|
||||
audience: fbSettings.audience, anonymous: fbSettings.anonymous, intro: fbSettings.intro || null,
|
||||
@@ -1120,7 +1121,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
},
|
||||
});
|
||||
} else if (fbMeta) {
|
||||
await apiFetch(`/api/feedback/event/${eventId}`, { method: "DELETE", authToken: token || undefined });
|
||||
await apiV2Data("delete", "/events/{id}/feedback", { params: { id: eventId }, authToken: token });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,14 +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, fetchManagedEvents, fetchEvent } from "@/lib/api";
|
||||
import { 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";
|
||||
import { apiV2, apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph' | 'rating' | 'multiple_choice';
|
||||
|
||||
@@ -221,8 +221,7 @@ export default function FormsBrowserPage() {
|
||||
if (userId.trim()) qs.set("userId", userId.trim());
|
||||
if (registrationId.trim()) qs.set("registrationId", registrationId.trim());
|
||||
if (append && nextCursor) qs.set("cursor", nextCursor);
|
||||
const url = "/api/forms/responses" + (qs.toString() ? `?${qs.toString()}` : "");
|
||||
const res = await apiFetch<{ items: FormResponse[]; nextCursor?: string }>(url, { authToken: token });
|
||||
const res = await (apiV2Data("get", "/form-responses", { query: Object.fromEntries(qs), authToken: token }) as Promise<{ items: FormResponse[]; nextCursor?: string }>);
|
||||
const newItems = Array.isArray(res?.items) ? res!.items : [];
|
||||
setItems(prev => append ? [...prev, ...newItems] : newItems);
|
||||
setNextCursor(res?.nextCursor || null);
|
||||
@@ -350,7 +349,7 @@ export default function FormsBrowserPage() {
|
||||
if (!token || !evId) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(evId)}`, { authToken: token });
|
||||
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: evId }, authToken: token }) as Promise<Registration[]>);
|
||||
const sorted = (Array.isArray(regs) ? regs : []).sort((a, b) => (a.user?.name || '').toLowerCase().localeCompare((b.user?.name || '').toLowerCase()));
|
||||
setFillRegistrations(sorted);
|
||||
} catch {
|
||||
@@ -374,13 +373,13 @@ export default function FormsBrowserPage() {
|
||||
(async () => {
|
||||
if (mode !== 'fill' || !fillRegistrationId) return;
|
||||
try {
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
|
||||
const reg = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: fillRegistrationId }, authToken: token }) as Promise<Registration>);
|
||||
setFillRegistration(reg);
|
||||
if (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! });
|
||||
const resList = await (apiV2Data("get", "/form-responses", { query: { registrationId: fillRegistrationId }, authToken: token }) as Promise<{ items?: FormResponse[] } | FormResponse[]>);
|
||||
setExistingResponses(responseItems(resList));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to load registration/form');
|
||||
@@ -435,11 +434,13 @@ export default function FormsBrowserPage() {
|
||||
if (v == null || String(v).trim() === '') throw new Error(`Response #${i+1}: Missing answer for a required field`);
|
||||
}
|
||||
}
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(fillRegistrationId)}/forms/responses`, {
|
||||
method: 'PUT', authToken: token, body: { responses: editableResponses }
|
||||
await apiV2Data("put", "/registrations/{id}/form-responses", {
|
||||
params: { id: fillRegistrationId },
|
||||
authToken: token,
|
||||
body: { responses: editableResponses },
|
||||
});
|
||||
setInfo('Responses saved');
|
||||
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
|
||||
const resList = await (apiV2Data("get", "/form-responses", { query: { registrationId: fillRegistrationId }, authToken: token }) as Promise<{ items?: FormResponse[] } | FormResponse[]>);
|
||||
setExistingResponses(responseItems(resList));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to save responses');
|
||||
|
||||
@@ -4,12 +4,13 @@ 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, fetchEvent } from "@/lib/api";
|
||||
import { fetchEvent } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
|
||||
@@ -39,15 +40,14 @@ export default function ManualRegistrationPage() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<Registration>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/registrations/manual", {
|
||||
authToken: token,
|
||||
body: {
|
||||
eventId,
|
||||
options: [{ eventOptionId: optionId, quantity }],
|
||||
user: { name, ...(email ? { email } : {}), ...(phoneNumber ? { phoneNumber } : {}) },
|
||||
},
|
||||
});
|
||||
}) as Promise<Registration>);
|
||||
setCreatedReg(res);
|
||||
// Load form definition for this event (if any)
|
||||
try {
|
||||
@@ -154,8 +154,10 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
|
||||
setError(null); setInfo(null);
|
||||
const payload: { answers: Record<string, string> }[] = [];
|
||||
for (let i = 0; i < count; i++) payload.push({ answers: formsData[i] || {} });
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registration.id)}/forms/responses`, {
|
||||
method: 'POST', authToken: token, body: { responses: payload }
|
||||
await apiV2Data("post", "/registrations/{id}/form-responses", {
|
||||
params: { id: registration.id },
|
||||
authToken: token,
|
||||
body: { responses: payload },
|
||||
});
|
||||
setInfo('Attendee forms submitted successfully.');
|
||||
} catch (e) {
|
||||
|
||||
@@ -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, fetchManagedEvents, fetchEvent } from "@/lib/api";
|
||||
import { fetchAllUsers, fetchManagedEvents, fetchEvent, fetchAllRegistrations } from "@/lib/api";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
@@ -17,6 +17,7 @@ import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -24,7 +25,7 @@ function fmtPrice(n: number) {
|
||||
return n === 0 ? "Free" : `R ${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Effective unit price for an existing registration option (as returned by /api/registrations).
|
||||
// Effective unit price for an existing registration option (as returned by /api/v2/registrations).
|
||||
// Prefers priceSnapshot (authoritative price captured at registration time) and only falls
|
||||
// back to a live early-bird calculation for legacy rows without a snapshot.
|
||||
function optionUnitPrice(opt: RegistrationOption): number {
|
||||
@@ -103,7 +104,7 @@ export default function ManualRegistrationPage() {
|
||||
// balances can be computed without a per-registration fetch loop).
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<Registration[]>("/api/registrations", { authToken: token })
|
||||
fetchAllRegistrations(token)
|
||||
.then(regs => setAllRegistrations(Array.isArray(regs) ? regs : []))
|
||||
.catch(() => {});
|
||||
}, [token]);
|
||||
@@ -163,8 +164,7 @@ export default function ManualRegistrationPage() {
|
||||
if (!payRegistrationId) { setPayError("Please select a registration"); return; }
|
||||
try {
|
||||
setPaySubmitting(true);
|
||||
await apiFetch("/api/payments", {
|
||||
method: "POST",
|
||||
await apiV2Data("post", "/payments", {
|
||||
authToken: token,
|
||||
body: {
|
||||
amount: amt,
|
||||
@@ -173,14 +173,14 @@ export default function ManualRegistrationPage() {
|
||||
registrationId: payRegistrationId,
|
||||
isDonation: false,
|
||||
paidAt: payPaidAtLocal ? new Date(payPaidAtLocal).toISOString() : undefined,
|
||||
}
|
||||
},
|
||||
});
|
||||
setPayMessage(`Payment recorded (R ${amt.toFixed(2)}).`);
|
||||
setPayAmount("");
|
||||
setPayPaidAtLocal("");
|
||||
// Refresh registrations so the displayed outstanding balance updates
|
||||
try {
|
||||
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
|
||||
const regs = await fetchAllRegistrations(token);
|
||||
setAllRegistrations(Array.isArray(regs) ? regs : []);
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
@@ -346,8 +346,7 @@ export default function ManualRegistrationPage() {
|
||||
const hasEmail = !!guest.email.trim();
|
||||
const hasPhone = !!guest.phoneNumber.trim();
|
||||
const resolvedPref = hasEmail && hasPhone ? notifPref : hasPhone ? "whatsapp" : "email";
|
||||
const res = await apiFetch<Registration & { couponError?: string }>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/registrations/manual", {
|
||||
authToken: token,
|
||||
body: {
|
||||
eventId: selectedEventId,
|
||||
@@ -358,8 +357,8 @@ export default function ManualRegistrationPage() {
|
||||
...(selectedEventAllowsNames && (namingOthers || selectedIsYouth) && attendeesPayload(options, manualQtyFor, attendeeEntries).length > 0
|
||||
? { attendees: attendeesPayload(options, manualQtyFor, attendeeEntries), ticketDelivery: delivery }
|
||||
: {}),
|
||||
}
|
||||
});
|
||||
},
|
||||
}) as Promise<Registration & { couponError?: string }>);
|
||||
setMessage(res?.couponError
|
||||
? `Manual registration created, but the code wasn't applied: ${res.couponError}`
|
||||
: coupon ? `Manual registration created with code ${coupon.code}.` : "Manual registration created successfully.");
|
||||
|
||||
@@ -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, fetchManagedEvents } from "@/lib/api";
|
||||
import { fetchAllUsers, fetchAllPayments, fetchManagedEvents, fetchAllRegistrations } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { Wallet, RotateCcw, HandHeart, ScanLine, Link2 } from "lucide-react";
|
||||
@@ -14,6 +14,7 @@ import { BackLink } from "@/components/shared/BackLink";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { WaitingForPayment } from "@/components/payments/WaitingForPayment";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
// A donation is never mutated once created — assigning it to a registration creates a separate
|
||||
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
|
||||
@@ -193,7 +194,7 @@ function PaymentsContent() {
|
||||
setLoadingList(true);
|
||||
const start = new Date(); start.setHours(0, 0, 0, 0);
|
||||
const [recent, today, donations] = await Promise.all([
|
||||
apiFetch<{ data: Payment[] }>("/api/payments?page=1&limit=40", { authToken: token }),
|
||||
(apiV2Data("get", "/payments", { query: { page: "1", limit: "40" }, authToken: token }) as Promise<{ data: Payment[] }>),
|
||||
fetchAllPayments(token, { from: start.toISOString() }),
|
||||
fetchAllPayments(token, { donations: "1" }),
|
||||
]);
|
||||
@@ -209,14 +210,14 @@ function PaymentsContent() {
|
||||
|
||||
useEffect(() => { loadPayments(); }, [loadPayments]);
|
||||
|
||||
// Registrations still owing (not paid or cancelled), for the dropdowns. GET /api/registrations
|
||||
// Registrations still owing (not paid or cancelled), for the dropdowns. GET /api/v2/registrations
|
||||
// embeds each registration's `payments`, so balances come from that in one pass. Refunds load
|
||||
// the chosen person's own registrations (including paid ones) when needed.
|
||||
const loadRegistrations = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<Registration[]>("/api/registrations?unpaid=1", { authToken: token });
|
||||
const regs = await fetchAllRegistrations(token, { unpaid: true });
|
||||
const list = Array.isArray(regs) ? regs : [];
|
||||
setRegOutstanding(computeOutstanding(list));
|
||||
// Sort by createdAt desc
|
||||
@@ -301,17 +302,16 @@ function PaymentsContent() {
|
||||
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
|
||||
try {
|
||||
setLinkGenerating(true);
|
||||
const res = await apiFetch<{ redirectUrl?: string; checkoutId?: string; amount?: number; priceUpdated?: boolean; message?: string }>("/api/payments/checkout", {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/registrations/{id}/payment-link", {
|
||||
params: { id: linkRegistrationId },
|
||||
authToken: token,
|
||||
body: {
|
||||
registrationId: linkRegistrationId,
|
||||
amount: amt,
|
||||
successUrl: window.location.origin + "/payment/success",
|
||||
cancelUrl: window.location.origin + "/payment/cancel",
|
||||
failureUrl: window.location.origin + "/payment/failure",
|
||||
},
|
||||
});
|
||||
}) as Promise<{ redirectUrl?: string; checkoutId?: string; amount?: number; priceUpdated?: boolean; message?: string }>);
|
||||
if (res.priceUpdated) {
|
||||
setError(res.message || "Pricing has changed for this registration; please re-check the amount.");
|
||||
return;
|
||||
@@ -342,10 +342,10 @@ function PaymentsContent() {
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
setLinkSending(channel);
|
||||
await apiFetch("/api/payments/checkout/send", {
|
||||
method: "POST",
|
||||
await apiV2Data("post", "/registrations/{id}/payment-link/send", {
|
||||
params: { id: linkResult.registrationId },
|
||||
authToken: token,
|
||||
body: { registrationId: linkResult.registrationId, redirectUrl: linkResult.redirectUrl, channel },
|
||||
body: { redirectUrl: linkResult.redirectUrl, channel },
|
||||
});
|
||||
setInfo(`Payment link sent via ${channel === "email" ? "email" : "WhatsApp"}`);
|
||||
} catch (e) {
|
||||
@@ -405,8 +405,7 @@ function PaymentsContent() {
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await apiFetch("/api/payments", {
|
||||
method: "POST",
|
||||
await apiV2Data("post", "/payments", {
|
||||
authToken: token,
|
||||
body: {
|
||||
amount: amt,
|
||||
@@ -416,7 +415,7 @@ function PaymentsContent() {
|
||||
eventId: eventId || undefined,
|
||||
isDonation: !!isDonation,
|
||||
paidAt: paidAtLocal ? new Date(paidAtLocal).toISOString() : undefined,
|
||||
}
|
||||
},
|
||||
});
|
||||
setInfo(`Payment created (R ${amt.toFixed(2)})`);
|
||||
setAmount(""); setRegistrationId(""); setIsDonation(false); setEventId(""); setPaidAtLocal("");
|
||||
@@ -447,7 +446,7 @@ function PaymentsContent() {
|
||||
if (!token) return;
|
||||
try {
|
||||
setTxLoading(true);
|
||||
const res = await apiFetch<{ data?: ProviderTx[] } | ProviderTx[]>("/api/provider-transactions/unreconciled", { authToken: token });
|
||||
const res = await (apiV2Data("get", "/provider-transactions/unreconciled", { authToken: token }) as Promise<{ data?: ProviderTx[] } | ProviderTx[]>);
|
||||
const items = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : []);
|
||||
setProviderTxs(items);
|
||||
} catch {
|
||||
@@ -486,18 +485,18 @@ function PaymentsContent() {
|
||||
setReconcileForm(prev => ({ ...prev, submitting: true }));
|
||||
if (type === 'reg') {
|
||||
if (!registrationId) { setError('Please select a registration'); return; }
|
||||
await apiFetch(`/api/provider-transactions/${encodeURIComponent(txId)}/reconcile`, {
|
||||
method: 'POST',
|
||||
await apiV2Data("post", "/provider-transactions/{id}/reconcile", {
|
||||
params: { id: txId },
|
||||
authToken: token,
|
||||
body: { registrationId }
|
||||
body: { registrationId },
|
||||
});
|
||||
setInfo('Reconciled to registration');
|
||||
} else {
|
||||
if (!eventId) { setError('Please select an event'); return; }
|
||||
await apiFetch(`/api/provider-transactions/${encodeURIComponent(txId)}/reconcile`, {
|
||||
method: 'POST',
|
||||
await apiV2Data("post", "/provider-transactions/{id}/reconcile", {
|
||||
params: { id: txId },
|
||||
authToken: token,
|
||||
body: { eventId, userId: userId || undefined }
|
||||
body: { eventId, userId: userId || undefined },
|
||||
});
|
||||
setInfo('Reconciled as donation');
|
||||
}
|
||||
@@ -617,7 +616,7 @@ function PaymentsContent() {
|
||||
const ok = window.confirm('Ignore this transaction? It will be marked as ignored and reconciled.');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiFetch(`/api/provider-transactions/${encodeURIComponent(tx.id)}/ignore`, { method: 'POST', authToken: token });
|
||||
await apiV2Data("post", "/provider-transactions/{id}/ignore", { params: { id: tx.id }, authToken: token, body: {} });
|
||||
setInfo('Transaction ignored');
|
||||
await loadUnreconciled();
|
||||
} catch (e) {
|
||||
@@ -891,7 +890,7 @@ function RefundSection({ allUsers, onDone }: RefundSectionProps) {
|
||||
setLoadingUser(true);
|
||||
Promise.all([
|
||||
fetchAllPayments(token, { userId }),
|
||||
apiFetch<Registration[]>(`/api/registrations?userId=${encodeURIComponent(userId)}`, { authToken: token }),
|
||||
fetchAllRegistrations(token, { userId }),
|
||||
]).then(([p, r]) => {
|
||||
if (cancelled) return;
|
||||
setPayments(p);
|
||||
@@ -935,9 +934,8 @@ function RefundSection({ allUsers, onDone }: RefundSectionProps) {
|
||||
if (!method) { setErr('Select a refund method'); return; }
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await apiFetch('/api/payments/refund', {
|
||||
method: 'POST',
|
||||
authToken: token!,
|
||||
await apiV2Data("post", "/payments/refunds", {
|
||||
authToken: token,
|
||||
body: {
|
||||
userId,
|
||||
amount: amt,
|
||||
@@ -945,7 +943,7 @@ function RefundSection({ allUsers, onDone }: RefundSectionProps) {
|
||||
paymentId: target === 'payment' ? paymentId : undefined,
|
||||
registrationId: target === 'registration' ? registrationId : undefined,
|
||||
reason: reason || undefined
|
||||
}
|
||||
},
|
||||
});
|
||||
setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason(""); setMethod("");
|
||||
await onDone();
|
||||
@@ -1083,10 +1081,9 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
|
||||
if (!ok) return;
|
||||
try {
|
||||
setUnassigningId(leg.id);
|
||||
await apiFetch('/api/payments/unassign-donation', {
|
||||
method: 'POST',
|
||||
await apiV2Data("post", "/payments/donations/unassign", {
|
||||
authToken: token,
|
||||
body: { legId: leg.id }
|
||||
body: { legId: leg.id },
|
||||
});
|
||||
await onDone();
|
||||
} catch (e) {
|
||||
@@ -1266,8 +1263,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
if (amountNum > maxAllocatable + 0.000001) { setErr(`Cannot allocate more than R ${maxAllocatable.toFixed(2)}`); return; }
|
||||
try {
|
||||
setAssigning(true);
|
||||
await apiFetch('/api/payments/assign-donation', {
|
||||
method: 'PUT',
|
||||
await apiV2Data("post", "/payments/donations/assign", {
|
||||
authToken: token,
|
||||
body: { paymentId, registrationId, amount: amountNum }
|
||||
});
|
||||
|
||||
@@ -5,12 +5,12 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Layers, ExternalLink, Users, BarChart3 } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { CampusBadge } from "@/components/shared/CampusFilter";
|
||||
import { SeasonPassesPanel } from "@/components/series/SeasonPassesPanel";
|
||||
import type { EventSeries } from "@/types";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Draft = { id?: string; name: string; slug: string; description: string; picture: string };
|
||||
const blank: Draft = { name: "", slug: "", description: "", picture: "" };
|
||||
@@ -69,7 +69,7 @@ export default function SeriesPage() {
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setList(await apiFetch<EventSeries[]>("/api/series", { authToken: token }));
|
||||
setList(await (apiV2Data("get", "/series", { authToken: token }) as Promise<EventSeries[]>));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't load the series");
|
||||
}
|
||||
@@ -81,7 +81,8 @@ export default function SeriesPage() {
|
||||
setSaving(true); setError(null); setNotice(null);
|
||||
try {
|
||||
const body = { name: draft.name, slug: draft.slug || undefined, description: draft.description, picture: draft.picture };
|
||||
await apiFetch(draft.id ? `/api/series/${draft.id}` : "/api/series", { method: draft.id ? "PUT" : "POST", authToken: token || undefined, body });
|
||||
if (draft.id) await apiV2Data("patch", "/series/{id}", { params: { id: draft.id }, authToken: token, body: body as never });
|
||||
else await apiV2Data("post", "/series", { authToken: token, body: body as never });
|
||||
setNotice(draft.id ? "Series saved." : `"${draft.name.trim()}" created — choose it in the event editor to add events.`);
|
||||
setDraft(null);
|
||||
await load();
|
||||
@@ -96,7 +97,7 @@ export default function SeriesPage() {
|
||||
if (!window.confirm(`Delete "${s.name}"? Its ${s.eventCount} event${s.eventCount === 1 ? "" : "s"} stay, just without a series, and ${s.followers} follower${s.followers === 1 ? "" : "s"} won't hear about new ones.`)) return;
|
||||
setError(null); setNotice(null);
|
||||
try {
|
||||
await apiFetch(`/api/series/${s.id}`, { method: "DELETE", authToken: token || undefined });
|
||||
await apiV2Data("delete", "/series/{id}", { params: { id: s.id }, authToken: token });
|
||||
setNotice(`"${s.name}" deleted.`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
|
||||
@@ -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, fetchManagedEvents } from "@/lib/api";
|
||||
import { fetchManagedEvents } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ListChecks, ArrowUp, ArrowDown } from "lucide-react";
|
||||
@@ -13,6 +13,7 @@ import { entryLineName } from "@/lib/waitlist";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
@@ -72,7 +73,7 @@ export default function WaitlistPage() {
|
||||
if (!token || !eventId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
setEntries(await apiFetch<Entry[]>(`/api/waitlist/events/${encodeURIComponent(eventId)}`, { authToken: token }));
|
||||
setEntries(await (apiV2Data("get", "/events/{id}/waitlist", { params: { id: eventId }, authToken: token }) as Promise<Entry[]>));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load the waitlist");
|
||||
} finally {
|
||||
@@ -81,10 +82,11 @@ export default function WaitlistPage() {
|
||||
}, [token, eventId, setError]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const act = async (path: string, body?: { direction: "up" | "down" }) => {
|
||||
const act = async (id: string, action: "offer" | "move" | "remove", body: { direction?: "up" | "down" } = {}) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetch<{ message?: string }>(path, { method: "POST", authToken: token!, body });
|
||||
const path = action === "offer" ? "/waitlist/{id}/offer" : action === "move" ? "/waitlist/{id}/move" : "/waitlist/{id}/remove";
|
||||
const res = await (apiV2Data("post", path, { params: { id }, authToken: token, body }) as Promise<{ message?: string }>);
|
||||
if (res?.message) setInfo(res.message);
|
||||
await load();
|
||||
} catch (e) {
|
||||
@@ -164,15 +166,15 @@ export default function WaitlistPage() {
|
||||
<TableCell className="text-right whitespace-nowrap">
|
||||
{e.status === "waiting" && (
|
||||
<>
|
||||
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/move`, { direction: "up" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move up"><ArrowUp className="w-3.5 h-3.5" /></button>
|
||||
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/move`, { direction: "down" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move down"><ArrowDown className="w-3.5 h-3.5" /></button>
|
||||
<button disabled={busy} onClick={() => act(e.id, "move", { direction: "up" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move up"><ArrowUp className="w-3.5 h-3.5" /></button>
|
||||
<button disabled={busy} onClick={() => act(e.id, "move", { direction: "down" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move down"><ArrowDown className="w-3.5 h-3.5" /></button>
|
||||
</>
|
||||
)}
|
||||
{(e.status === "waiting" || e.status === "expired") && (
|
||||
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/offer`)} className="px-2 py-1 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 mr-1" title="Offer now, even if no place has freed up">Offer place</button>
|
||||
<button disabled={busy} onClick={() => act(e.id, "offer")} className="px-2 py-1 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 mr-1" title="Offer now, even if no place has freed up">Offer place</button>
|
||||
)}
|
||||
{active && (
|
||||
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/remove`)} className="px-2 py-1 text-xs rounded border hover:bg-gray-50">Remove</button>
|
||||
<button disabled={busy} onClick={() => act(e.id, "remove")} className="px-2 py-1 text-xs rounded border hover:bg-gray-50">Remove</button>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { BackLink } from "@/components/shared/BackLink";
|
||||
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
|
||||
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
// Attendee with preference info
|
||||
type Attendee = { id: string; name: string; phone: string; pref: string };
|
||||
@@ -426,7 +427,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
try {
|
||||
setLoadingAttendees(true); setAttendees([]); setSelectedAttendeeIds([]);
|
||||
if (!eventId || !token) return;
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
const regs = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: eventId }, authToken: token }) as Promise<Registration[]>);
|
||||
const uniq = new Map<string, Attendee>();
|
||||
(regs || []).forEach((r) => {
|
||||
const u: User | undefined = r?.user;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { HandHeart } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
@@ -41,8 +42,7 @@ export default function DonatePage() {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setLoading(true);
|
||||
const res = await apiFetch<{ redirectUrl: string }>("/api/payments/checkout", {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/donations/checkout", {
|
||||
body: {
|
||||
eventId,
|
||||
amount: amt,
|
||||
@@ -51,7 +51,7 @@ export default function DonatePage() {
|
||||
failureUrl: window.location.origin + "/payment/failure",
|
||||
},
|
||||
authToken: token,
|
||||
});
|
||||
}) as Promise<{ redirectUrl: string }>);
|
||||
setInfo("Redirecting to payment...");
|
||||
window.location.href = res.redirectUrl;
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,12 +3,13 @@ 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, fetchEvent } from "@/lib/api";
|
||||
import { fetchEvent } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
// Types for form fields
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
@@ -35,13 +36,13 @@ function FormsContent() {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
try {
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const reg = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registrationId }, authToken: token }) as Promise<Registration>);
|
||||
setRegistration(reg);
|
||||
const ev = await fetchEvent<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(reg.eventId);
|
||||
if (ev?.form) setEventForm(ev.form);
|
||||
// Load any saved draft
|
||||
try {
|
||||
const draft = await apiFetch<{ data?: Record<number, Record<string, string>> } | null>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token });
|
||||
const draft = await (apiV2Data("get", "/registrations/{id}/form-draft", { params: { id: registrationId }, authToken: token }) as Promise<{ data?: Record<number, Record<string, string>> } | null>);
|
||||
if (draft && draft.data && typeof draft.data === 'object') setFormsData(draft.data);
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
@@ -99,14 +100,14 @@ function FormsContent() {
|
||||
setInfo('No remaining attendee forms to submit.');
|
||||
return;
|
||||
}
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/responses`, {
|
||||
method: 'POST',
|
||||
await apiV2Data("post", "/registrations/{id}/form-responses", {
|
||||
params: { id: registrationId },
|
||||
authToken: token,
|
||||
body: { responses: payload }
|
||||
body: { responses: payload },
|
||||
});
|
||||
setInfo('Attendee forms submitted successfully.');
|
||||
// reload registration to reflect new responses
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const reg = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registrationId }, authToken: token }) as Promise<Registration>);
|
||||
setRegistration(reg);
|
||||
setFormsData({});
|
||||
// On success, redirect to the user dashbaord
|
||||
@@ -120,10 +121,10 @@ function FormsContent() {
|
||||
if (!token || !registrationId) return;
|
||||
try {
|
||||
setError(null); setInfo(null);
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, {
|
||||
method: 'PUT',
|
||||
await apiV2Data("put", "/registrations/{id}/form-draft", {
|
||||
params: { id: registrationId },
|
||||
authToken: token,
|
||||
body: { data: formsData }
|
||||
body: { data: formsData },
|
||||
});
|
||||
setInfo('Draft saved. You can return later to finish.');
|
||||
} catch (e) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, createFullPaymentCheckout, fetchEvent } from "@/lib/api";
|
||||
import { apiV2 } from "@/lib/apiV2";
|
||||
import { apiV2, apiV2Data } from "@/lib/apiV2";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatDate } from "@/lib/date";
|
||||
@@ -124,7 +124,7 @@ export default function UserDashboardPage() {
|
||||
// waterfalling — this was previously fetched only after registrations + all
|
||||
// their payments had already resolved.
|
||||
const [myRegs, myTicks] = await Promise.all([
|
||||
apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token }),
|
||||
(apiV2Data("get", "/me/registrations/full", { query: { past: showPast ? "true" : "false" }, authToken: token }) as Promise<Registration[]>),
|
||||
apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token }),
|
||||
]);
|
||||
setRegistrations(myRegs);
|
||||
@@ -143,7 +143,7 @@ export default function UserDashboardPage() {
|
||||
await Promise.all(
|
||||
myRegs.map(async (r) => {
|
||||
try {
|
||||
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
|
||||
const pays = await (apiV2Data("get", "/registrations/{id}/payments", { params: { id: r.id }, authToken: token }) as Promise<Payment[]>);
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
// totalDueComputed is not time-dependent, no need to recompute per payment time
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
@@ -189,7 +189,7 @@ export default function UserDashboardPage() {
|
||||
return;
|
||||
}
|
||||
// Load registration to get number of submitted responses
|
||||
const regFull = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(r.id)}`, { authToken: token });
|
||||
const regFull = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: r.id }, authToken: token }) as Promise<Registration>);
|
||||
const submittedCount = (regFull?.formResponses || []).length;
|
||||
// Required count equals the number of main tickets purchased
|
||||
const requiredCount = (r.registrationOptions || [])
|
||||
@@ -273,11 +273,11 @@ export default function UserDashboardPage() {
|
||||
loadingSubtitle: "Please wait while we prepare your invoice.",
|
||||
});
|
||||
try {
|
||||
const res = await apiFetch<{ message?: string }>(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/registrations/{id}/invoice", {
|
||||
params: { id: registrationId },
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
}) as Promise<{ message?: string }>);
|
||||
setDialog({ open: true, loading: false, title: "Invoice sent", message: (res && res.message) || "Invoice sent." });
|
||||
} catch (e) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
@@ -295,11 +295,11 @@ export default function UserDashboardPage() {
|
||||
loadingSubtitle: "Please wait while we prepare your receipt.",
|
||||
});
|
||||
try {
|
||||
const res = await apiFetch<{ message?: string }>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/payments/{id}/receipt", {
|
||||
params: { id: paymentId },
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
}) as Promise<{ message?: string }>);
|
||||
setDialog({ open: true, loading: false, title: "Receipt sent", message: (res && res.message) || "Receipt sent." });
|
||||
} catch (e) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
@@ -332,8 +332,8 @@ export default function UserDashboardPage() {
|
||||
// Refresh this registration's billing so the displayed outstanding reflects the new price
|
||||
try {
|
||||
const now = new Date();
|
||||
const r = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const r = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registrationId }, authToken: token }) as Promise<Registration>);
|
||||
const pays = await (apiV2Data("get", "/registrations/{id}/payments", { params: { id: registrationId }, authToken: token }) as Promise<Payment[]>);
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
setBilling(prev => ({ ...prev, [registrationId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
|
||||
@@ -409,15 +409,15 @@ export default function UserDashboardPage() {
|
||||
setCodeBusy(true);
|
||||
setCodeError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ message?: string }>(`/api/registrations/${encodeURIComponent(activeRegId)}/coupon`, {
|
||||
method: 'POST',
|
||||
const res = await (apiV2Data("post", "/registrations/{id}/coupon", {
|
||||
params: { id: activeRegId },
|
||||
authToken: token,
|
||||
body: { code: codeInput.trim() },
|
||||
});
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
}) as Promise<{ message?: string }>);
|
||||
const myRegs = await (apiV2Data("get", "/me/registrations/full", { query: { past: showPast ? "true" : "false" }, authToken: token }) as Promise<Registration[]>);
|
||||
setRegistrations(myRegs);
|
||||
const updated = myRegs.find(r => r.id === activeRegId);
|
||||
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(activeRegId)}`, { authToken: token });
|
||||
const pays = await (apiV2Data("get", "/registrations/{id}/payments", { params: { id: activeRegId }, authToken: token }) as Promise<Payment[]>);
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const totalDue = updated?.totalDueComputed ?? 0;
|
||||
setBilling(prev => ({ ...prev, [activeRegId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
|
||||
@@ -440,7 +440,7 @@ export default function UserDashboardPage() {
|
||||
const reloadAfterAttendees = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
const myRegs = await (apiV2Data("get", "/me/registrations/full", { query: { past: showPast ? "true" : "false" }, authToken: token }) as Promise<Registration[]>);
|
||||
setRegistrations(myRegs);
|
||||
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
|
||||
setTickets(myTicks);
|
||||
@@ -456,7 +456,7 @@ export default function UserDashboardPage() {
|
||||
setCancelLoading(true);
|
||||
try {
|
||||
await apiV2("post", "/registrations/{id}/cancel", { params: { id: activeRegId }, authToken: token });
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
const myRegs = await (apiV2Data("get", "/me/registrations/full", { query: { past: showPast ? "true" : "false" }, authToken: token }) as Promise<Registration[]>);
|
||||
setRegistrations(myRegs);
|
||||
setActiveRegId(null);
|
||||
setShowCancelConfirm(false);
|
||||
@@ -593,13 +593,13 @@ export default function UserDashboardPage() {
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(activeReg.id)}/options`, {
|
||||
method: 'PUT',
|
||||
await apiV2Data("put", "/registrations/{id}/items", {
|
||||
params: { id: activeReg.id },
|
||||
authToken: token,
|
||||
body: { options: payloadOptions },
|
||||
});
|
||||
// Reload registrations to reflect changes
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
const myRegs = await (apiV2Data("get", "/me/registrations/full", { query: { past: showPast ? "true" : "false" }, authToken: token }) as Promise<Registration[]>);
|
||||
setRegistrations(myRegs);
|
||||
try {
|
||||
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
|
||||
@@ -614,7 +614,7 @@ export default function UserDashboardPage() {
|
||||
setBilling(totals);
|
||||
// Payments will be refreshed lazily or on next open; fetch for this one immediately
|
||||
try {
|
||||
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(activeReg.id)}`, { authToken: token });
|
||||
const pays = await (apiV2Data("get", "/registrations/{id}/payments", { params: { id: activeReg.id }, authToken: token }) as Promise<Payment[]>);
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const totalDue = totals[activeReg.id]?.totalDue ?? 0;
|
||||
setBilling(prev => ({
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { formatDateTime } from "@/lib/date";
|
||||
import { formatPaymentMethod } from "@/lib/paymentMethod";
|
||||
@@ -10,6 +9,7 @@ import { Receipt } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Paginated } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
interface PaymentItem {
|
||||
id: string;
|
||||
@@ -52,11 +52,11 @@ export default function UserPaymentsPage() {
|
||||
setInfo(null);
|
||||
setSendingReceipt(true);
|
||||
try {
|
||||
const res = await apiFetch<{ message?: string }>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/payments/{id}/receipt", {
|
||||
params: { id: paymentId },
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
}) as Promise<{ message?: string }>);
|
||||
setInfo((res && res.message) || "Receipt sent.");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to send receipt");
|
||||
@@ -77,7 +77,7 @@ export default function UserPaymentsPage() {
|
||||
if (endDate) qs.set("endDate", endDate);
|
||||
if (method) qs.set("method", method);
|
||||
if (kind) qs.set("kind", kind);
|
||||
return `/api/payments/mypayments?${qs.toString()}`;
|
||||
return Object.fromEntries(qs);
|
||||
}, [startDate, endDate, method, kind]);
|
||||
|
||||
const loadPayments = useCallback(async (p = 1) => {
|
||||
@@ -85,7 +85,7 @@ export default function UserPaymentsPage() {
|
||||
setError(null);
|
||||
setFetching(true);
|
||||
try {
|
||||
const res = await apiFetch<Paginated<PaymentItem>>(buildQuery(p), { authToken: token });
|
||||
const res = await (apiV2Data("get", "/me/payments", { query: buildQuery(p), authToken: token }) as Promise<Paginated<PaymentItem>>);
|
||||
setPayments(Array.isArray(res?.data) ? res.data : []);
|
||||
setTotal(res?.total ?? 0);
|
||||
setTotalPages(res?.pages ?? 1);
|
||||
|
||||
@@ -3,11 +3,11 @@ import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { MessageSquareHeart, CheckCircle2 } from "lucide-react";
|
||||
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Field = { id: string; type: string; label: string; isRequired: boolean; helpText: string | null; options: unknown };
|
||||
type FeedbackInfo = {
|
||||
@@ -34,7 +34,7 @@ export default function FeedbackPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<FeedbackInfo>(`/api/feedback/public/${encodeURIComponent(token)}`)
|
||||
(apiV2Data("get", "/feedback-forms/{token}", { params: { token: token } }) as Promise<FeedbackInfo>)
|
||||
.then(setInfo)
|
||||
.catch(e => setLoadError(e?.message || "This feedback link isn't valid."));
|
||||
}, [token]);
|
||||
@@ -45,7 +45,7 @@ export default function FeedbackPage() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/feedback/public/${encodeURIComponent(token!)}`, { method: "POST", body: { answers } });
|
||||
await apiV2Data("post", "/feedback-forms/{token}", { params: { token: token }, body: { answers } });
|
||||
setDone(true);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Couldn't send your feedback");
|
||||
|
||||
@@ -3,12 +3,13 @@ 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, fetchEvent } from "@/lib/api";
|
||||
import { fetchEvent } from "@/lib/api";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
// Types for form fields
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
@@ -40,13 +41,13 @@ function FormsContent() {
|
||||
setInfo(null);
|
||||
try {
|
||||
// authToken optional — guests access via registrationId alone
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token || undefined });
|
||||
const reg = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registrationId }, authToken: token }) as Promise<Registration>);
|
||||
setRegistration(reg);
|
||||
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 {
|
||||
const draft = await apiFetch<{ data?: Record<string, Record<string, string>> } | null>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token || undefined });
|
||||
const draft = await (apiV2Data("get", "/registrations/{id}/form-draft", { params: { id: registrationId }, authToken: token }) as Promise<{ data?: Record<string, Record<string, string>> } | null>);
|
||||
if (draft && draft.data && typeof draft.data === 'object') setFormsData(draft.data);
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
@@ -124,10 +125,10 @@ function FormsContent() {
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`);
|
||||
return;
|
||||
}
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/responses`, {
|
||||
method: 'POST',
|
||||
authToken: token || undefined,
|
||||
body: { responses: payload }
|
||||
await apiV2Data("post", "/registrations/{id}/form-responses", {
|
||||
params: { id: registrationId },
|
||||
authToken: token,
|
||||
body: { responses: payload },
|
||||
});
|
||||
// On success, redirect to the success page
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
@@ -141,10 +142,10 @@ function FormsContent() {
|
||||
if (!registrationId) return;
|
||||
try {
|
||||
setError(null); setInfo(null);
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, {
|
||||
method: 'PUT',
|
||||
authToken: token || undefined,
|
||||
body: { data: formsData }
|
||||
await apiV2Data("put", "/registrations/{id}/form-draft", {
|
||||
params: { id: registrationId },
|
||||
authToken: token,
|
||||
body: { data: formsData },
|
||||
});
|
||||
setInfo('Draft saved. You can come back and finish later.');
|
||||
if (!token || !registrationId) return;
|
||||
|
||||
@@ -3,8 +3,9 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type ProviderForm = { action: string; fields: Record<string, string> };
|
||||
type PayPage = {
|
||||
@@ -40,7 +41,7 @@ export default function PayPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
apiFetch<PayPage>(`/api/payments/pay/${encodeURIComponent(id)}`)
|
||||
(apiV2Data("get", "/payment-links/{id}", { params: { id: id } }) as Promise<PayPage>)
|
||||
.then(p => {
|
||||
setPage(p);
|
||||
if (p.status === "open") go(p);
|
||||
@@ -56,7 +57,7 @@ export default function PayPage() {
|
||||
setBusy(provider);
|
||||
setError(null);
|
||||
try {
|
||||
go(await apiFetch<{ redirectUrl: string | null; form: ProviderForm | null }>(`/api/payments/pay/${encodeURIComponent(id)}`, { method: "POST", body: { provider } }));
|
||||
go(await (apiV2Data("post", "/payment-links/{id}", { params: { id: id }, body: { provider } }) as Promise<{ redirectUrl: string | null; form: ProviderForm | null }>));
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : "Couldn't start the payment. Please try again.");
|
||||
setBusy(null);
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { apiV2 } from "@/lib/apiV2";
|
||||
import { apiV2, apiV2Data } from "@/lib/apiV2";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
||||
@@ -394,7 +393,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
useEffect(() => {
|
||||
const t = new URLSearchParams(window.location.search).get("waitlist");
|
||||
if (!t) return;
|
||||
apiFetch<WaitlistOffer & { status: string }>(`/api/waitlist/claim/${encodeURIComponent(t)}`)
|
||||
(apiV2Data("get", "/waitlist/claims/{token}", { params: { token: t } }) as Promise<WaitlistOffer & { status: string }>)
|
||||
.then(res => {
|
||||
if (res.status !== "offered") { setOfferError("This waitlist offer has expired."); return; }
|
||||
setWaitlistToken(t);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CheckCircle2 } from "lucide-react";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
|
||||
@@ -40,7 +41,7 @@ function RegistrationSuccessContent() {
|
||||
if (!registrationId) return;
|
||||
try {
|
||||
// Try to fetch registration and event form
|
||||
const r = token ? await (await import('@/lib/api')).apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null;
|
||||
const r = token ? await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registrationId }, authToken: token }) as Promise<Registration>) : null;
|
||||
if (r) {
|
||||
setReg(r);
|
||||
const ev = await (await import('@/lib/api')).fetchEvent<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(r.eventId);
|
||||
@@ -95,7 +96,7 @@ function RegistrationSuccessContent() {
|
||||
// reload the registration so the displayed totalDue reflects it, instead of redirecting.
|
||||
setError(`${res.message || 'Pricing has changed.'} New total: R ${(res.newTotal ?? 0).toFixed(2)}. Please try again.`);
|
||||
try {
|
||||
const r = await (await import('@/lib/api')).apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const r = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registrationId }, authToken: token }) as Promise<Registration>);
|
||||
if (r) setReg(r);
|
||||
} catch {}
|
||||
return;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { choosesSessions, isPickOption } from "@/lib/sessions";
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import { ModalOverlay } from "@/components/shared/ModalOverlay";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
||||
@@ -478,11 +479,10 @@ export default function SelfServicePage() {
|
||||
payload.attendees = kioskNames;
|
||||
payload.ticketDelivery = delivery;
|
||||
}
|
||||
const result = await apiFetch<Registration>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
const result = await (apiV2Data("post", "/registrations/manual", {
|
||||
body: payload as never,
|
||||
authToken: supervisorToken,
|
||||
});
|
||||
}) as Promise<Registration>);
|
||||
|
||||
setCurrentRegistrationId(result?.id || null);
|
||||
setRegistrationIsFree(result?.status === "paid" || (result?.totalDueComputed ?? total) === 0);
|
||||
@@ -539,8 +539,8 @@ export default function SelfServicePage() {
|
||||
try {
|
||||
const responses = collectedAnswers.map((answers) => ({ answers }));
|
||||
|
||||
await apiFetch(`/api/registrations/${currentRegistrationId}/forms/responses`, {
|
||||
method: "POST",
|
||||
await apiV2Data("post", "/registrations/{id}/form-responses", {
|
||||
params: { id: currentRegistrationId },
|
||||
body: { responses },
|
||||
authToken: supervisorToken,
|
||||
});
|
||||
|
||||
@@ -9,10 +9,11 @@ import { CampusBadge } from "@/components/shared/CampusFilter";
|
||||
import { FollowSeriesButton } from "@/components/events/FollowSeriesButton";
|
||||
import { SeasonPassOffers } from "@/components/series/SeasonPassOffers";
|
||||
import { Suspense } from "react";
|
||||
import { apiFetch, apiFetchListForPrerender, ApiError } from "@/lib/api";
|
||||
import { apiFetchListForPrerender, ApiError } from "@/lib/api";
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
import { ArrowLeft, CalendarX, Layers } from "lucide-react";
|
||||
import type { Event as FullEvent, SeasonPassView } from "@/types";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
@@ -22,7 +23,7 @@ type Series = { id: string; name: string; slug: string; description: string | nu
|
||||
|
||||
async function loadSeries(slug: string): Promise<Series | null> {
|
||||
try {
|
||||
return await apiFetch<Series>(`/api/series/by-slug/${encodeURIComponent(slug)}`, { nextOptions: { next: { revalidate } } });
|
||||
return await (apiV2Data("get", "/series/by-slug/{slug}", { params: { slug }, nextOptions: { next: { revalidate } } }) as Promise<Series>);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) return null;
|
||||
throw e;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { appUrl } from "@/lib/siteConfig";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Event = { id: string; updatedAt?: string; startDate: string };
|
||||
|
||||
@@ -26,7 +27,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// Campus pages, while campuses are on (the list is empty otherwise).
|
||||
let campuses: { slug: string }[] = [];
|
||||
try {
|
||||
campuses = (await apiFetch<{ campuses: { slug: string }[] }>("/api/campuses", { nextOptions: { next: { revalidate } } })).campuses || [];
|
||||
campuses = (await (apiV2Data("get", "/campuses", { nextOptions: { next: { revalidate } } }) as Promise<{ campuses: { slug: string }[] }>)).campuses || [];
|
||||
} catch {
|
||||
// Same as events above.
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { ListChecks } from "lucide-react";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Claim = {
|
||||
status: "offered" | "expired";
|
||||
@@ -30,7 +31,7 @@ export default function WaitlistClaimPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<Claim>(`/api/waitlist/claim/${encodeURIComponent(token)}`)
|
||||
(apiV2Data("get", "/waitlist/claims/{token}", { params: { token: token } }) as Promise<Claim>)
|
||||
.then(setClaim)
|
||||
.catch(e => setError(e instanceof ApiError ? e.message : "Couldn't load this offer."));
|
||||
}, [token]);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { reloadCampuses, type Campus } from "@/hooks/useCampuses";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Person = { id: string; name: string; email: string };
|
||||
type AdminCampus = Campus & {
|
||||
@@ -129,7 +130,7 @@ export function CampusesTab({ active }: { active: boolean }) {
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await apiFetch<{ enabled: boolean; campuses: AdminCampus[] }>("/api/campuses/admin", { authToken: token });
|
||||
const res = await (apiV2Data("get", "/campuses/admin", { authToken: token }) as Promise<{ enabled: boolean; campuses: AdminCampus[] }>);
|
||||
setEnabled(res.enabled);
|
||||
setCampuses(res.campuses);
|
||||
} catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Could not load campuses." }); }
|
||||
@@ -154,28 +155,28 @@ export function CampusesTab({ active }: { active: boolean }) {
|
||||
// Limits are ignored while campuses are off, so limited staff would see every campus.
|
||||
const limited = (campuses || []).reduce((n, c) => n + (c._count.scopedStaff || 0), 0);
|
||||
if (!on && limited > 0 && !window.confirm(`Some staff are limited to campuses (${limited} limit${limited === 1 ? "" : "s"} set). While campuses are off those limits are ignored and they'll see every campus. Turn campuses off?`)) return;
|
||||
return run(() => apiFetch("/api/campuses/settings", { method: "PUT", authToken: token, body: { enabled: on } }), on ? "Campuses are on." : "Campuses are off — campus fields are hidden, nothing is deleted.");
|
||||
return run(() => apiV2Data("put", "/campuses/enabled", { authToken: token, body: { enabled: on } }), on ? "Campuses are on." : "Campuses are off — campus fields are hidden, nothing is deleted.");
|
||||
};
|
||||
const save = async (d: Draft, id?: string) => {
|
||||
const body = { ...d, notifyRecipients: undefined, notifyRecipientIds: d.notifyRecipients.map(p => p.id) };
|
||||
const ok = await run(() => id
|
||||
? apiFetch(`/api/campuses/${id}`, { method: "PUT", authToken: token, body })
|
||||
: apiFetch("/api/campuses", { method: "POST", authToken: token, body }).then((c) => {
|
||||
? apiV2Data("patch", "/campuses/{id}", { params: { id }, authToken: token, body: body as never })
|
||||
: apiV2Data("post", "/campuses", { authToken: token, body: body as never }).then((c) => {
|
||||
const created = c as AdminCampus;
|
||||
// Recipients are set with an update, so a new campus gets them too.
|
||||
return d.notifyRecipients.length ? apiFetch(`/api/campuses/${created.id}`, { method: "PUT", authToken: token, body: { notifyRecipientIds: body.notifyRecipientIds } }) : created;
|
||||
return d.notifyRecipients.length ? apiV2Data("patch", "/campuses/{id}", { params: { id: created.id }, authToken: token, body: { notifyRecipientIds: body.notifyRecipientIds } }) : created;
|
||||
}), id ? "Campus saved." : "Campus added.");
|
||||
if (ok) setEditing(null);
|
||||
};
|
||||
const move = (list: AdminCampus[], i: number, dir: -1 | 1) => {
|
||||
const ids = list.map(c => c.id);
|
||||
[ids[i], ids[i + dir]] = [ids[i + dir], ids[i]];
|
||||
return run(() => apiFetch("/api/campuses/order", { method: "PUT", authToken: token, body: { ids } }));
|
||||
return run(() => apiV2Data("put", "/campuses/order", { authToken: token, body: { ids } }));
|
||||
};
|
||||
const setArchived = (c: AdminCampus, archived: boolean) => run(() => apiFetch(`/api/campuses/${c.id}`, { method: "PUT", authToken: token, body: { archived } }), archived ? `${c.name} archived.` : `${c.name} restored.`);
|
||||
const setArchived = (c: AdminCampus, archived: boolean) => run(() => apiV2Data("patch", "/campuses/{id}", { params: { id: c.id }, authToken: token, body: { archived } }), archived ? `${c.name} archived.` : `${c.name} restored.`);
|
||||
const remove = (c: AdminCampus) => {
|
||||
if (!window.confirm(`Delete ${c.name}? This can't be undone.`)) return;
|
||||
run(() => apiFetch(`/api/campuses/${c.id}`, { method: "DELETE", authToken: token }), `${c.name} deleted.`);
|
||||
run(() => apiV2Data("delete", "/campuses/{id}", { params: { id: c.id }, authToken: token }), `${c.name} deleted.`);
|
||||
};
|
||||
|
||||
if (!campuses) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, Wallet } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { YocoTab } from "@/components/admin/YocoTab";
|
||||
import { PayFastSettings } from "@/components/admin/PayFastSettings";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type ProviderSetting = { id: string; label: string; enabled: boolean; configured: boolean };
|
||||
|
||||
@@ -25,7 +25,7 @@ export function PaymentsTab({ active }: { active: boolean }) {
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await apiFetch<{ providers: ProviderSetting[]; minimumAmount: number }>("/api/payments/providers/settings", { authToken: token });
|
||||
const res = await (apiV2Data("get", "/payments/providers/settings", { authToken: token }) as Promise<{ providers: ProviderSetting[]; minimumAmount: number }>);
|
||||
setProviders(res.providers);
|
||||
setMinimum(res.minimumAmount);
|
||||
} catch (e) {
|
||||
@@ -40,9 +40,10 @@ export function PaymentsTab({ active }: { active: boolean }) {
|
||||
setProviders(next);
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiFetch<{ message: string; providers: ProviderSetting[] }>("/api/payments/providers/settings", {
|
||||
method: "PUT", authToken: token, body: { providers: next.map(p => ({ id: p.id, enabled: p.enabled })) },
|
||||
});
|
||||
const res = await (apiV2Data("put", "/payments/providers/settings", {
|
||||
authToken: token,
|
||||
body: { providers: next.map(p => ({ id: p.id, enabled: p.enabled })) },
|
||||
}) as Promise<{ message: string; providers: ProviderSetting[] }>);
|
||||
setProviders(res.providers);
|
||||
setMessage({ type: "ok", text: res.message });
|
||||
} catch (e) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { downloadReportExcel } from "@/lib/export";
|
||||
import type { Event, Registration } from "@/types";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Action = "" | "message" | "markPaid" | "moveOption" | "allocate" | "cancel" | "export";
|
||||
type Result = { id: string; ok: boolean; skipped?: boolean; message: string };
|
||||
@@ -102,7 +103,7 @@ export function RegistrationBulkBar({ selected, events, token, onClear, onDone }
|
||||
: {};
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetch<BulkResponse>("/api/registrations/bulk", { method: "POST", authToken: token, body: { action, registrationIds: selected.map(r => r.id), ...params } });
|
||||
const res = await (apiV2Data("post", "/registrations/bulk", { authToken: token, body: { action: action as Exclude<typeof action, "">, registrationIds: selected.map(r => r.id), ...params } }) as Promise<BulkResponse>);
|
||||
setResult(res);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bell, BellOff } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
/** Follow / unfollow a series. Signed out, it links to the login page and back. */
|
||||
export function FollowSeriesButton({ slug }: { slug: string }) {
|
||||
@@ -18,7 +18,7 @@ export function FollowSeriesButton({ slug }: { slug: string }) {
|
||||
// is now) is asked separately.
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<{ id: string; following: boolean }>(`/api/series/by-slug/${encodeURIComponent(slug)}`, { authToken: token })
|
||||
(apiV2Data("get", "/series/by-slug/{slug}", { params: { slug }, authToken: token }) as Promise<{ id: string; following: boolean }>)
|
||||
.then(s => { setSeriesId(s.id); setFollowing(!!s.following); })
|
||||
.catch(() => setFollowing(null));
|
||||
}, [slug, token]);
|
||||
@@ -35,7 +35,7 @@ export function FollowSeriesButton({ slug }: { slug: string }) {
|
||||
const toggle = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ following: boolean }>(`/api/series/${seriesId}/follow`, { method: following ? "DELETE" : "POST", authToken: token || undefined });
|
||||
const res = await (apiV2Data(following ? "delete" : "put", "/series/{id}/follow", { params: { id: seriesId! }, authToken: token }) as Promise<{ following: boolean }>);
|
||||
setFollowing(res.following);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't change that");
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ListPlus, X } from "lucide-react";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import type { WaitlistLine } from "@/lib/waitlist";
|
||||
import { ModalOverlay } from "@/components/shared/ModalOverlay";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
export type { WaitlistLine };
|
||||
|
||||
@@ -40,9 +41,8 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>("/api/waitlist", {
|
||||
method: "POST",
|
||||
authToken: token || undefined,
|
||||
const res = await (apiV2Data("post", "/waitlist", {
|
||||
authToken: token,
|
||||
body: {
|
||||
eventId,
|
||||
eventOptionId: line.eventOptionId,
|
||||
@@ -51,7 +51,7 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
|
||||
quantity: qty,
|
||||
...(!loggedIn ? { guestName: guest.name.trim(), guestEmail: guest.email.trim(), guestPhone: guest.phone.trim() || undefined } : {}),
|
||||
},
|
||||
});
|
||||
}) as Promise<{ message: string }>);
|
||||
setDone(res.message);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Couldn't join the waitlist. Please try again.");
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { HeartHandshake, X } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { ModalOverlay } from "@/components/shared/ModalOverlay";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Sponsorship = { enabled: boolean; open?: boolean; spotPrice?: number | null; showProgress?: boolean; goalSpots?: number | null; spotsFunded?: number | null; sponsors?: number | null };
|
||||
|
||||
@@ -35,7 +35,7 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
|
||||
const online = useOnlinePayments();
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<Sponsorship>(`/api/sponsorships/event/${encodeURIComponent(eventId)}`).then(setData).catch(() => setData(null));
|
||||
(apiV2Data("get", "/events/{id}/sponsorship", { params: { id: eventId } }) as Promise<Sponsorship>).then(setData).catch(() => setData(null));
|
||||
try {
|
||||
const s = new URLSearchParams(window.location.search).get("sponsored");
|
||||
if (s) setReturned(s);
|
||||
@@ -55,8 +55,7 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ redirectUrl: string }>("/api/sponsorships/checkout", {
|
||||
method: "POST",
|
||||
const res = await (apiV2Data("post", "/sponsorships/checkout", {
|
||||
...(loggedIn ? { authToken: token! } : {}),
|
||||
body: {
|
||||
eventId,
|
||||
@@ -66,7 +65,7 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
|
||||
...(message.trim() ? { message: message.trim() } : {}),
|
||||
...(!loggedIn ? { name: guest.name, email: guest.email } : {}),
|
||||
},
|
||||
});
|
||||
}) as Promise<{ redirectUrl: string }>);
|
||||
window.location.href = res.redirectUrl;
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Couldn't start the payment");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type OpenCheckout = {
|
||||
id: string;
|
||||
@@ -40,7 +40,7 @@ export function WaitingForPayment({ token, onRecorded }: { token: string | null;
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await apiFetch<{ checkouts: OpenCheckout[] }>("/api/payments/checkouts/open", { authToken: token });
|
||||
const res = await (apiV2Data("get", "/payments/open-checkouts", { authToken: token }) as Promise<{ checkouts: OpenCheckout[] }>);
|
||||
setList(res.checkouts);
|
||||
} catch (e) {
|
||||
setMessage({ ok: false, text: errorMessage(e) || "Couldn't load waiting payments" });
|
||||
@@ -53,9 +53,10 @@ export function WaitingForPayment({ token, onRecorded }: { token: string | null;
|
||||
setBusy(ids?.[0] || "all");
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await apiFetch<{ results: Record<string, CheckResult>; recorded: number; message: string }>("/api/payments/checkouts/check", {
|
||||
method: "POST", authToken: token, body: ids ? { ids } : {},
|
||||
});
|
||||
const res = await (apiV2Data("post", "/payments/open-checkouts/check", {
|
||||
authToken: token,
|
||||
body: ids ? { ids } : {},
|
||||
}) as Promise<{ results: Record<string, CheckResult>; recorded: number; message: string }>);
|
||||
setResults(r => ({ ...r, ...res.results }));
|
||||
setMessage({ ok: true, text: res.message });
|
||||
if (res.recorded > 0) onRecorded();
|
||||
|
||||
@@ -17,6 +17,7 @@ import { registrationOptionUnitPrice } from "@/lib/pricing";
|
||||
import { CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
|
||||
import { SavedFilters } from "@/components/shared/SavedFilters";
|
||||
import type { ConsentRow, Event, EventCashup, EventFinancials, EventSeries, MedicalRow, Payment, Registration, SeriesComparison, Ticket, UserSummary } from "@/types";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const METHOD_KEYS = ["cash", "card", "eft", "other"] as const;
|
||||
const METHOD_LABEL: Record<string, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
|
||||
@@ -97,7 +98,7 @@ type AuditRow = EventCashup & {
|
||||
_previousCloseAt?: string;
|
||||
};
|
||||
|
||||
// GET /api/coupons/redemptions — active coupon uses and bundle deals, with a readable summary.
|
||||
// GET /api/v2/coupons/redemptions — active coupon uses and bundle deals, with a readable summary.
|
||||
// For a bundle, coupon.code is the bundle name and uses is how many times it applied.
|
||||
type DiscountRow = {
|
||||
id: string;
|
||||
@@ -251,7 +252,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
const [comparison, setComparison] = useState<SeriesComparison | null>(null);
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<EventSeries[]>("/api/series", { authToken: token }).then(l => {
|
||||
(apiV2Data("get", "/series", { authToken: token }) as Promise<EventSeries[]>).then(l => {
|
||||
const list = Array.isArray(l) ? l : [];
|
||||
setSeriesList(list);
|
||||
setCompareSeriesId(id => id || list[0]?.id || "");
|
||||
@@ -317,7 +318,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
const byEv: Record<string, Payment[]> = {};
|
||||
for (const id of eventIds) {
|
||||
try {
|
||||
const list = await apiFetch<Payment[]>(`/api/payments/event/${encodeURIComponent(id)}`, { authToken: token });
|
||||
const list = await (apiV2Data("get", "/events/{id}/payments", { params: { id: id }, authToken: token }) as Promise<Payment[]>);
|
||||
byEv[id] = Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
byEv[id] = [];
|
||||
@@ -331,7 +332,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
const byEv: Record<string, Registration[]> = {};
|
||||
for (const id of eventIds) {
|
||||
try {
|
||||
const list = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(id)}`, { authToken: token });
|
||||
const list = await (apiV2Data("get", "/events/{id}/registrations", { params: { id: id }, authToken: token }) as Promise<Registration[]>);
|
||||
byEv[id] = Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
byEv[id] = [];
|
||||
@@ -359,7 +360,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
const byEv: Record<string, EventFinancials | null> = {};
|
||||
for (const id of eventIds) {
|
||||
try {
|
||||
byEv[id] = await apiFetch<EventFinancials>(`/api/cashups/event/${encodeURIComponent(id)}`, { authToken: token });
|
||||
byEv[id] = await (apiV2Data("get", "/events/{id}/cashup", { params: { id: id }, authToken: token }) as Promise<EventFinancials>);
|
||||
} catch {
|
||||
byEv[id] = null;
|
||||
}
|
||||
@@ -382,7 +383,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
const loadFeedback = async (eventIds: string[]) => {
|
||||
if (!token || !eventIds.length) return [];
|
||||
try {
|
||||
return await apiFetch<FeedbackEventSummary[]>(`/api/feedback/summary?eventIds=${encodeURIComponent(eventIds.join(","))}`, { authToken: token });
|
||||
return await (apiV2Data("get", "/feedback/summary", { query: { eventIds: eventIds.join(",") }, authToken: token }) as Promise<FeedbackEventSummary[]>);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -395,7 +396,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
if (dateFrom) query.set("from", dateFrom);
|
||||
if (dateTo) query.set("to", dateTo);
|
||||
try {
|
||||
return await apiFetch<DiscountRow[]>(`/api/coupons/redemptions?${query.toString()}`, { authToken: token });
|
||||
return await (apiV2Data("get", "/coupons/redemptions", { query: Object.fromEntries(query.entries()), authToken: token }) as Promise<DiscountRow[]>);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -411,7 +412,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
const ev = filteredEvents.find((e) => e.id === id);
|
||||
if (!ev?.requiresParentalConsent) continue;
|
||||
try {
|
||||
const res = await apiFetch<{ rows?: (ConsentRow | MedicalRow)[] }>(`/api/consent/events/${encodeURIComponent(id)}${kind === "medical" ? "/medical" : ""}`, { authToken: token });
|
||||
const res = await ((kind === "medical" ? apiV2Data("get", "/events/{id}/consent/medical", { params: { id }, authToken: token }) : apiV2Data("get", "/events/{id}/consent", { params: { id }, authToken: token })) as Promise<{ rows?: (ConsentRow | MedicalRow)[] }>);
|
||||
(res?.rows || []).forEach((r) => out.push({ ...r, eventTitle: ev.title }));
|
||||
} catch { /* not allowed or not found — skip */ }
|
||||
}
|
||||
@@ -425,7 +426,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
if (auditTo) query.set("to", auditTo);
|
||||
if (eventIds.length === 1) query.set("eventId", eventIds[0]);
|
||||
try {
|
||||
const rows = await apiFetch<AuditRow[]>(`/api/cashups/audit?${query.toString()}`, { authToken: token });
|
||||
const rows = await (apiV2Data("get", "/cashups/audit", { query: Object.fromEntries(query), authToken: token }) as Promise<AuditRow[]>);
|
||||
const idSet = new Set(eventIds);
|
||||
const filtered = eventIds.length > 1 ? (rows || []).filter(r => idSet.has(r.eventId)) : (rows || []);
|
||||
return computeAuditDeltas(filtered).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
@@ -547,7 +548,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
setMedicalRows(await loadConsent(selectedEventIds, "medical"));
|
||||
} else if (report === "seriesComparison") {
|
||||
setComparison(compareSeriesId && token
|
||||
? await apiFetch<SeriesComparison>(`/api/series/${encodeURIComponent(compareSeriesId)}/compare`, { authToken: token }).catch(() => null)
|
||||
? await (apiV2Data("get", "/series/{id}/compare", { params: { id: compareSeriesId }, authToken: token }) as Promise<SeriesComparison>).catch(() => null)
|
||||
: null);
|
||||
}
|
||||
setReady(true);
|
||||
|
||||
@@ -4,10 +4,10 @@ import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Ticket, CheckCircle2 } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
import type { SeasonPassPurchase, SeasonPassView } from "@/types";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const rand = (n: number) => `R ${n.toFixed(2)}`;
|
||||
type BuyResult = { purchaseId: string; total: number; paid: boolean; redirectUrl: string | null; message: string | null };
|
||||
@@ -27,12 +27,12 @@ export function SeasonPassOffers({ slug, passes: initial }: { slug: string; pass
|
||||
// The page is cached for everyone; ask for the passes as they are now.
|
||||
const [passes, setPasses] = useState<SeasonPassView[]>(initial);
|
||||
useEffect(() => {
|
||||
apiFetch<{ passes?: SeasonPassView[] }>(`/api/series/by-slug/${encodeURIComponent(slug)}`).then(s => setPasses(s.passes || [])).catch(() => {});
|
||||
(apiV2Data("get", "/series/by-slug/{slug}", { params: { slug } }) as Promise<{ passes?: SeasonPassView[] }>).then(s => setPasses(s.passes || [])).catch(() => {});
|
||||
}, [slug]);
|
||||
|
||||
const loadMine = useCallback(async () => {
|
||||
if (!token) return;
|
||||
const list = await apiFetch<SeasonPassPurchase[]>("/api/season-passes/mine", { authToken: token }).catch(() => []);
|
||||
const list = await (apiV2Data("get", "/me/season-passes", { authToken: token }) as Promise<SeasonPassPurchase[]>).catch(() => [] as SeasonPassPurchase[]);
|
||||
setMine(list.filter(p => p.pass.series.slug === slug && p.status !== "cancelled"));
|
||||
}, [token, slug]);
|
||||
useEffect(() => { loadMine(); }, [loadMine]);
|
||||
@@ -48,7 +48,7 @@ export function SeasonPassOffers({ slug, passes: initial }: { slug: string; pass
|
||||
const buy = async (p: SeasonPassView) => {
|
||||
setBusy(p.id); setError(null); setNotice(null);
|
||||
try {
|
||||
go(await apiFetch<BuyResult>(`/api/season-passes/${p.id}/buy`, { method: "POST", authToken: token || undefined, body: { quantity: qty[p.id] || 1 } }));
|
||||
go(await (apiV2Data("post", "/season-passes/{id}/buy", { params: { id: p.id }, authToken: token, body: { quantity: qty[p.id] || 1 } }) as Promise<BuyResult>));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't buy the pass");
|
||||
} finally {
|
||||
@@ -59,7 +59,7 @@ export function SeasonPassOffers({ slug, passes: initial }: { slug: string; pass
|
||||
const pay = async (purchaseId: string) => {
|
||||
setBusy(purchaseId); setError(null); setNotice(null);
|
||||
try {
|
||||
go(await apiFetch<{ redirectUrl: string | null; message: string | null }>(`/api/season-passes/purchases/${purchaseId}/checkout`, { method: "POST", authToken: token || undefined }));
|
||||
go(await (apiV2Data("post", "/season-pass-purchases/{id}/checkout", { params: { id: purchaseId }, authToken: token, body: {} }) as Promise<{ redirectUrl: string | null; message: string | null }>));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't start the payment");
|
||||
} finally {
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Ticket } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, fetchManagedEvents } from "@/lib/api";
|
||||
import { fetchManagedEvents } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, SeasonPassView } from "@/types";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type ItemDraft = { eventId: string; on: boolean; eventOptionId: string; variantId: string };
|
||||
type Draft = { id?: string; name: string; description: string; price: string; saleEndsAt: string; isActive: boolean; items: ItemDraft[] };
|
||||
@@ -32,7 +33,7 @@ export function SeasonPassesPanel({ seriesId, seriesSlug }: { seriesId: string;
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
const [p, all] = await Promise.all([
|
||||
apiFetch<SeasonPassView[]>(`/api/season-passes?seriesId=${encodeURIComponent(seriesId)}`, { authToken: token }).catch(() => []),
|
||||
(apiV2Data("get", "/season-passes", { query: { seriesId }, authToken: token }) as Promise<SeasonPassView[]>).catch(() => [] as SeasonPassView[]),
|
||||
fetchManagedEvents<Event>(token, { inactive: true }).catch(() => []),
|
||||
]);
|
||||
setPasses(p);
|
||||
@@ -76,7 +77,8 @@ export function SeasonPassesPanel({ seriesId, seriesSlug }: { seriesId: string;
|
||||
items: events.map(ev => itemFor(draft, ev)).filter(i => i.on).map(i => ({ eventId: i.eventId, eventOptionId: i.eventOptionId, variantId: i.variantId || null })),
|
||||
}),
|
||||
};
|
||||
await apiFetch(draft.id ? `/api/season-passes/${draft.id}` : "/api/season-passes", { method: draft.id ? "PUT" : "POST", authToken: token || undefined, body });
|
||||
if (draft.id) await apiV2Data("patch", "/season-passes/{id}", { params: { id: draft.id }, authToken: token, body: body as never });
|
||||
else await apiV2Data("post", "/season-passes", { authToken: token, body: body as never });
|
||||
setDraft(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
@@ -89,7 +91,7 @@ export function SeasonPassesPanel({ seriesId, seriesSlug }: { seriesId: string;
|
||||
const remove = async (p: SeasonPassView) => {
|
||||
if (!window.confirm(`Delete "${p.name}"?`)) return;
|
||||
try {
|
||||
await apiFetch(`/api/season-passes/${p.id}`, { method: "DELETE", authToken: token || undefined });
|
||||
await apiV2Data("delete", "/season-passes/{id}", { params: { id: p.id }, authToken: token });
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't delete the pass");
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Users, Send } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Attendee } from "@/types";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
pending: { text: "consent pending", cls: "text-amber-700" },
|
||||
@@ -95,8 +95,8 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registration.id)}/attendees`, {
|
||||
method: "PUT",
|
||||
await apiV2Data("put", "/registrations/{id}/attendees", {
|
||||
params: { id: registration.id },
|
||||
authToken: token,
|
||||
body: {
|
||||
ticketDelivery: delivery,
|
||||
@@ -127,7 +127,7 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
|
||||
const sendLink = async (a: Attendee) => {
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/registrations/${encodeURIComponent(registration.id)}/attendees/${encodeURIComponent(a.id)}/send-form-link`, { method: "POST", authToken: token });
|
||||
const res = await (apiV2Data("post", "/registrations/{id}/attendees/{attendeeId}/form-link", { params: { id: registration.id, attendeeId: a.id }, authToken: token }) as Promise<{ message: string }>);
|
||||
onMessage(res.message);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't send the form link");
|
||||
@@ -138,7 +138,7 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
|
||||
const resendConsent = async (requestId: string) => {
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/consent/requests/${encodeURIComponent(requestId)}/resend`, { method: "POST", authToken: token });
|
||||
const res = await (apiV2Data("post", "/consent-requests/{id}/resend", { params: { id: requestId }, authToken: token, body: {} }) as Promise<{ message: string }>);
|
||||
onMessage(res.message);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't re-send the consent request");
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Gift } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { BundleItem, BundleQuote, PublicBundle } from "@/types";
|
||||
import type { BasketItem } from "@/components/shared/CouponCodeField";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
const fmt = (n: number) => `R${n.toFixed(2)}`;
|
||||
|
||||
/**
|
||||
* The bundle deals a basket gets, from POST /api/bundles/quote (the server prices the
|
||||
* The bundle deals a basket gets, from POST /api/v2/bundles/quote (the server prices the
|
||||
* basket the same way registration will). Null while there's nothing to show. Pass
|
||||
* `enabled={false}` when the event has no bundles to skip the request.
|
||||
*
|
||||
@@ -35,7 +35,7 @@ export function useBundleQuote({ eventId, basket, token, forUserId, enabled = tr
|
||||
try {
|
||||
const body: Record<string, unknown> = { eventId, options };
|
||||
if (forUserId !== undefined) body.forUserId = forUserId;
|
||||
const res = await apiFetch<BundleQuote>("/api/bundles/quote", { method: "POST", authToken: token || undefined, body });
|
||||
const res = await (apiV2Data("post", "/bundles/quote", { authToken: token, body: body as never }) as Promise<BundleQuote>);
|
||||
if (!cancelled) setQuote(res.bundleDiscount > 0 ? res : null);
|
||||
} catch {
|
||||
if (!cancelled) setQuote(null);
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Users } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useMyCampuses } from "@/hooks/useCampuses";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
/**
|
||||
* "Everyone at campus X" for the email/WhatsApp broadcast composers: adds the people whose
|
||||
@@ -27,7 +27,7 @@ export function CampusAudiencePicker({ selectedIds, onChange }: { selectedIds: s
|
||||
setBusy(true);
|
||||
setNote(null);
|
||||
try {
|
||||
const res = await apiFetch<{ campus: { name: string }; users: { id: string }[] }>(`/api/campuses/${campusId}/audience${registrants ? "?registrants=true" : ""}`, { authToken: token });
|
||||
const res = await (apiV2Data("get", "/campuses/{id}/audience", { params: { id: campusId }, query: { registrants: registrants ? "true" : "false" }, authToken: token }) as Promise<{ campus: { name: string }; users: { id: string }[] }>);
|
||||
const ids = res.users.map(u => u.id);
|
||||
const merged = Array.from(new Set([...selectedIds, ...ids]));
|
||||
onChange(merged);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { TicketPercent, X } from "lucide-react";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
export type BasketItem = { eventOptionId: string; variantId?: string | null; quantity: number };
|
||||
|
||||
@@ -16,7 +17,7 @@ export type AppliedCoupon = {
|
||||
|
||||
/**
|
||||
* "Have a code?" field. Checks the code against the current basket with
|
||||
* POST /api/coupons/validate and reports the result through `onChange` — the parent
|
||||
* POST /api/v2/coupons/check and reports the result through `onChange` — the parent
|
||||
* sends `applied.code` as `couponCode` when it registers. Re-checks automatically when
|
||||
* the basket changes, since the discount (or whether the code applies at all) can change.
|
||||
*
|
||||
@@ -58,7 +59,9 @@ export function CouponCodeField({
|
||||
try {
|
||||
const body: Record<string, unknown> = { code: trimmed, eventId, options: items };
|
||||
if (forUserId !== undefined) body.forUserId = forUserId;
|
||||
const res = await apiFetch<AppliedCoupon & { valid: boolean }>("/api/coupons/validate", { method: "POST", authToken: token || undefined, body });
|
||||
const res = await (apiV2Data("post", "/coupons/check", { authToken: token, body: body as never }) as Promise<(AppliedCoupon & { valid: true }) | { valid: false; message: string }>);
|
||||
// A code that doesn't apply is an answer, not an error.
|
||||
if (!res.valid) { onChange(null); setError(res.message); return; }
|
||||
onChange({ code: res.code, summary: res.summary, discount: res.discount, subtotal: res.subtotal, total: res.total });
|
||||
} catch (e) {
|
||||
onChange(null);
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { ListChecks } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { entryLineName } from "@/lib/waitlist";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
@@ -22,7 +22,7 @@ type Entry = {
|
||||
export function MyWaitlists({ token, onMessage }: { token: string; onMessage: (m: string) => void }) {
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const load = useCallback(() => {
|
||||
apiFetch<Entry[]>("/api/waitlist/mine", { authToken: token }).then(setEntries).catch(() => setEntries([]));
|
||||
(apiV2Data("get", "/me/waitlist", { authToken: token }) as Promise<Entry[]>).then(setEntries).catch(() => setEntries([]));
|
||||
}, [token]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
@@ -30,7 +30,7 @@ export function MyWaitlists({ token, onMessage }: { token: string; onMessage: (m
|
||||
|
||||
const leave = async (id: string) => {
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/waitlist/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token });
|
||||
const res = await (apiV2Data("delete", "/waitlist/{id}", { params: { id: id }, authToken: token }) as Promise<{ message: string }>);
|
||||
onMessage(res.message);
|
||||
load();
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
export type Campus = {
|
||||
id: string;
|
||||
@@ -28,7 +28,7 @@ let cache: Promise<CampusState> | null = null;
|
||||
|
||||
function load(): Promise<CampusState> {
|
||||
if (!cache) {
|
||||
cache = apiFetch<CampusState>("/api/campuses").catch(() => {
|
||||
cache = (apiV2Data("get", "/campuses") as Promise<CampusState>).catch(() => {
|
||||
cache = null; // try again next time
|
||||
return { enabled: false, campuses: [] };
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+8767
-1
File diff suppressed because it is too large
Load Diff
+27
-3
@@ -1,4 +1,4 @@
|
||||
import type { Event, Paginated, Payment, User } from "@/types";
|
||||
import type { Event, Paginated, Payment, Registration, User } from "@/types";
|
||||
|
||||
export type ApiOptions = {
|
||||
method?: string;
|
||||
@@ -216,6 +216,23 @@ export async function fetchManagedEvents<T = Event>(token: string | null | undef
|
||||
return data as unknown as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every registration staff may see (API v2 GET /registrations, all pages), newest first, in full.
|
||||
* `unpaid`: only ones still owing; `userId`: one person's.
|
||||
*/
|
||||
export async function fetchAllRegistrations<T = Registration>(token: string | null | undefined, filter: { unpaid?: boolean; userId?: string } = {}): Promise<T[]> {
|
||||
const { apiV2 } = await import("@/lib/apiV2");
|
||||
const out: T[] = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const res = await apiV2("get", "/registrations", {
|
||||
authToken: token,
|
||||
query: { page, limit: 200, unpaid: filter.unpaid ? "true" : "false", ...(filter.userId ? { userId: filter.userId } : {}) },
|
||||
});
|
||||
out.push(...(res.data as unknown as T[]));
|
||||
if (page >= res.meta.pages) return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -231,8 +248,15 @@ export function fetchAllUsers(token: string, params: Record<string, string> = {}
|
||||
return fetchAllPages<User>("/api/users", token, params);
|
||||
}
|
||||
|
||||
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<Payment[]> {
|
||||
return fetchAllPages<Payment>("/api/payments", token, params);
|
||||
/** Every payment staff may see (API v2 GET /payments, all pages at once). */
|
||||
export async function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<Payment[]> {
|
||||
const { apiV2Data } = await import("@/lib/apiV2");
|
||||
const page = (n: number) => apiV2Data("get", "/payments", { query: { ...params, limit: "200", page: String(n) }, authToken: token }) as Promise<Paginated<Payment>>;
|
||||
const first = await page(1);
|
||||
const items: Payment[] = Array.isArray(first?.data) ? [...first.data] : [];
|
||||
const rest = await Promise.all(Array.from({ length: Math.max(0, (first?.pages || 1) - 1) }, (_, i) => page(i + 2)));
|
||||
for (const res of rest) if (Array.isArray(res?.data)) items.push(...res.data);
|
||||
return items;
|
||||
}
|
||||
|
||||
// Starts an online payment for everything still owed on one of my registrations (API v2), with
|
||||
|
||||
@@ -45,3 +45,13 @@ export function apiV2<M extends Method, P extends PathsWith<M>>(method: M, path:
|
||||
|
||||
/** The JSON body a v2 endpoint takes. */
|
||||
export type V2Body<P extends keyof paths, M extends Method> = Body<Op<P, M>>;
|
||||
|
||||
/**
|
||||
* `apiV2` for screens that keep their own record types: the path, parameters and body are still
|
||||
* checked against the API, and the answer's `data` comes back untyped — cast it:
|
||||
* `await (apiV2Data("get", "/coupons", { authToken }) as Promise<Coupon[]>)`.
|
||||
*/
|
||||
export async function apiV2Data<M extends Method, P extends PathsWith<M>>(method: M, path: P, ...rest: object extends V2Options<P, M> ? [V2Options<P, M>?] : [V2Options<P, M>]): Promise<unknown> {
|
||||
const res = (await apiV2(method, path, ...(rest as [V2Options<P, M>]))) as { data?: unknown };
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { apiV2Data } from "@/lib/apiV2";
|
||||
|
||||
type ProvidersResponse = { providers: { id: string; label: string }[]; minimumAmount: number };
|
||||
|
||||
@@ -21,7 +21,7 @@ export type OnlinePayments = {
|
||||
// One request per page load, shared by every component that asks.
|
||||
let pending: Promise<ProvidersResponse | null> | null = null;
|
||||
function load() {
|
||||
if (!pending) pending = apiFetch<ProvidersResponse>("/api/payments/providers").catch(() => null);
|
||||
if (!pending) pending = (apiV2Data("get", "/payments/providers") as Promise<ProvidersResponse>).catch(() => null);
|
||||
return pending;
|
||||
}
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ export type Event = {
|
||||
export type EventSeriesRef = { id: string; name: string; slug: string };
|
||||
export type NextInSeries = { id: string; title: string; startDate: string; endDate: string; location?: string | null };
|
||||
|
||||
/** GET /api/series (supervisors): a series with its events and number of followers. */
|
||||
/** GET /api/v2/series (supervisors): a series with its events and number of followers. */
|
||||
export type EventSeries = EventSeriesRef & {
|
||||
description: string | null;
|
||||
picture: string | null;
|
||||
@@ -225,7 +225,7 @@ export type SeasonPassPurchase = {
|
||||
registrations: { id: string; status: string; event: { id: string; title: string; startDate: string } }[];
|
||||
};
|
||||
|
||||
/** GET /api/series/:id/compare — one row per event in the series, oldest first. */
|
||||
/** GET /api/v2/series/{id}/compare — one row per event in the series, oldest first. */
|
||||
export type SeriesComparisonRow = {
|
||||
eventId: string; title: string; startDate: string; endDate: string; campus?: Event["campus"]; isActive: boolean;
|
||||
sessions: number; registrations: number; tickets: number; income: number; checkedIn: number; attendanceRate: number | null;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.8",
|
||||
"version": "1.51.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.51.8",
|
||||
"version": "1.51.11",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.8",
|
||||
"version": "1.51.11",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user