Compare commits

...
4 Commits
Author SHA1 Message Date
joshua 0fc4f83c34 Merge branch 'feat/api-v2-series-deals' 2026-10-29 12:39:28 +02:00
joshuaandClaude Opus 5.5 59fde801f7 Series, season passes, deals and campuses on API v2 (1.51.9)
- v2: series (+ page, compare, follow), season passes (+ buy, pay, mine), bundle deals
  (+ quote), coupon codes (+ check, redemptions), campuses (+ admin, enabled, order, audience).
- src/services/series.ts, deals.ts, campuses.ts; the five controllers are thin v1 wrappers.
- v2 errors carry a service's own code as `reason`.
- Frontend: apiV2Data() for screens with their own record types; these screens on v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:39:27 +02:00
joshuaandClaude Opus 5.5 733b23955b WIP 1.51.9: frontend on v2 for series, passes, bundles, coupons, campuses
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:34:49 +02:00
joshuaandClaude Opus 5.5 d6082693c2 WIP 1.51.9: series, season passes, bundles, coupons and campuses on v2 (backend)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 12:28:00 +02:00
45 changed files with 9914 additions and 1224 deletions
+25
View File
@@ -7,6 +7,31 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [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
+5
View File
@@ -12,6 +12,11 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 1.51.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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.51.8",
"version": "1.51.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.51.8",
"version": "1.51.9",
"hasInstallScript": true,
"dependencies": {
"@cj-tech-master/excelts": "^10.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.51.8",
"version": "1.51.9",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+17 -319
View File
@@ -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 };
+23 -188
View File
@@ -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 };
+24 -379
View File
@@ -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 };
+19 -141
View File
@@ -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 };
+20 -121
View File
@@ -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 };
+156
View File
@@ -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 };
}
+544
View File
@@ -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 },
})),
];
}
+1 -1
View File
@@ -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,
+247
View File
@@ -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 } } } },
},
});
}
+36
View File
@@ -46,6 +46,42 @@ 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.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' },
+9 -2
View File
@@ -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.'));
+1
View File
@@ -9,6 +9,7 @@ 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';
+365
View File
@@ -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))) }; },
});
+48
View File
@@ -75,6 +75,13 @@ 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)])));
import { v2Router } from '../src/v2';
import { ServiceError } from '../src/services/errors';
import { routes } from '../src/v2/route';
@@ -554,3 +561,44 @@ 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);
});
});
+12 -2
View File
@@ -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.9** (and Hub 1.5.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -17,6 +17,7 @@ encrypted settings unreadable or break existing installs.
| 1.51.3 | **Express 5** (qs query parser and `req.body = {}` kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on `/api/v2` (`legacyErrorsToV2`, `reason`); v1 `Deprecation` + `successor-version` headers (`src/v2/deprecation.ts`) |
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
| 1.51.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,7 +69,7 @@ encrypted settings unreadable or break existing installs.
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. **1.51.9 – 1.51.15: every endpoint onto v2**, one area per release (roadmap build order, row
1. **1.51.10 – 1.51.15: every endpoint onto v2**, one area per release (roadmap build order, row
11b): services in TS, screens on `apiV2`, v1 routes into `SUCCESSORS`. 1.51.15 gives incoming
webhooks and links in sent messages v2 URLs, with the v1 paths still answering (links redirect,
webhooks handled by the same code). Then **2.0.0** — AI helpers and v1 removed.
@@ -466,6 +467,15 @@ 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).
## 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`) —
+4 -3
View File
@@ -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** shipped. **Next: 1.51.10 – 1.51.15 (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.9 ✅, 1.51.10 – 1.51.15 | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 registrations and money, staff side (manage registrations, payments, cashup, provider transactions, waitlist, sponsorship, consent, forms, feedback); 1.51.11 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.12 kids check-in and volunteer rosters; 1.51.13 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.14 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.15 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each moves its area's logic into TS services, its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 removed (all of it is on v2 by then; the old webhook URLs and links already sent in messages keep forwarding to v2), migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
Each phase should add matching help content (`frontend/src/content/help/`),
+35
View File
@@ -148,6 +148,41 @@ 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("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);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.51.8",
"version": "1.51.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.51.8",
"version": "1.51.9",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.51.8",
"version": "1.51.9",
"private": true,
"scripts": {
"dev": "next dev",
+3 -2
View File
@@ -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,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) {
@@ -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) {
@@ -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 ──
@@ -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 -2
View File
@@ -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;
+2 -1
View File
@@ -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.
}
@@ -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,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");
@@ -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 || "");
@@ -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 [];
}
@@ -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,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 -2
View File
@@ -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
File diff suppressed because it is too large Load Diff
+10
View File
@@ -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;
}
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.51.8",
"version": "1.51.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.51.8",
"version": "1.51.9",
"license": "ISC",
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.51.8",
"version": "1.51.9",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",