Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16357e6006 | ||
|
|
9547793f25 | ||
|
|
99efe7a7d0 | ||
|
|
3c983070a1 | ||
|
|
c63c3a3604 |
@@ -7,6 +7,34 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.51.8] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 — the rest of setting events up:** `GET /events/{id}/full` (the event in full — what's
|
||||
left per ticket, variant and session, form, attachments, bundles, next in series; staff also see
|
||||
inactive events, creator and email recipients), `GET /events/{id}/calendar.ics`,
|
||||
`GET /events/by-alias/{alias}`; ticket options (`POST /events/{id}/ticket-options`,
|
||||
`PATCH`/`DELETE /ticket-options/{id}`) and variants (`POST /ticket-options/{id}/variants`,
|
||||
`PATCH`/`DELETE /variants/{id}`); attachments (`GET`/`POST /events/{id}/attachments` — multipart —
|
||||
and `DELETE …/{attachmentId}`, admin `/attachments/status` and `/attachments/sync`); scanning
|
||||
sections (`/sections`); planned costs (`/events/{id}/costs`, `/costs/{id}`); image uploads
|
||||
(`/uploads/event-image`, `/uploads/logo`, `/uploads/favicon` — returning `path` and `url`).
|
||||
- **Services:** `src/services/eventDetails.ts` (from eventController.js, now 1,080 lines, from 2,217
|
||||
at the start of 1.51.7) and `src/services/setup.ts` (sections and costs). v1 uses them too.
|
||||
- **v2 route features:** `file` routes answer with a download (`fileResponse`), and `upload` routes
|
||||
are described as multipart in the OpenAPI document.
|
||||
- Frontend: `fetchEvent()` for every event page (the public event and registration pages too),
|
||||
and `lib/eventSetup.ts` for ticket options, sections, attachments, costs and image uploads.
|
||||
|
||||
### Changed
|
||||
|
||||
- The pre-migration fallbacks for attachments (JSON manifests on disk) are gone from the normal
|
||||
path; old manifests can still be imported with `/attachments/sync`.
|
||||
- v1's `GET /api/events/:id` now points at `/api/v2/events/{id}/full` as its successor.
|
||||
- Roadmap: series, season passes, bundles, coupons and campuses are 1.51.9; later areas move up one
|
||||
(to 1.51.15).
|
||||
|
||||
## [1.51.7] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -12,6 +12,12 @@ Write for church admins, not developers: no environment variables, endpoints or
|
||||
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
|
||||
-->
|
||||
|
||||
## 1.51.8
|
||||
|
||||
- **More groundwork for 2.0.** Event pages, ticket types, scanning sections, attachments, planned
|
||||
costs and picture uploads now run on the new version of the server's connection to the app. They
|
||||
work just as before.
|
||||
|
||||
## 1.51.7
|
||||
|
||||
- **More groundwork for 2.0.** Creating and editing events, and every list of events staff choose
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.7",
|
||||
"version": "1.51.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.7",
|
||||
"version": "1.51.8",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@cj-tech-master/excelts": "^10.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.7",
|
||||
"version": "1.51.8",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,177 +1,44 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
// Planned event costs (cashup and the profit report). Shared with /api/v2 (services/setup.ts, 1.51.8).
|
||||
const setup = require('../services/setup');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { assertEventOpen, ALL_METHODS } = require('../utils/cashupUtils');
|
||||
|
||||
function normalizePaidFromMethod(value) {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null || value === '') return null;
|
||||
if (!ALL_METHODS.includes(value)) {
|
||||
throw new Error("paidFromMethod must be one of 'cash', 'card', 'eft', 'other', or null");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
|
||||
|
||||
// @desc List costs for an event
|
||||
// @route GET /api/events/:eventId/costs
|
||||
// @access Private/Supervisor
|
||||
const getEventCosts = async (req, res) => {
|
||||
try {
|
||||
const costs = await prisma.eventCost.findMany({
|
||||
where: { eventId: req.params.eventId },
|
||||
include: { eventOption: { select: { id: true, name: true } } },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
res.json(costs);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
res.json(await setup.listCosts(req.params.eventId));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Create a cost for an event
|
||||
// @route POST /api/events/:eventId/costs
|
||||
// @access Private/Supervisor
|
||||
// @access Private/Admin
|
||||
const createEventCost = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { label, costType, amount, eventOptionId, notes, paidFromMethod } = req.body;
|
||||
|
||||
await assertEventOpen(eventId, res);
|
||||
|
||||
if (!label || !String(label).trim()) {
|
||||
res.status(400);
|
||||
throw new Error('Label is required');
|
||||
}
|
||||
if (costType !== 'once_off' && costType !== 'per_item') {
|
||||
res.status(400);
|
||||
throw new Error("costType must be 'once_off' or 'per_item'");
|
||||
}
|
||||
const amt = parseFloat(amount);
|
||||
if (!(amt >= 0)) {
|
||||
res.status(400);
|
||||
throw new Error('Amount must be a non-negative number');
|
||||
}
|
||||
if (costType === 'per_item' && !eventOptionId) {
|
||||
res.status(400);
|
||||
throw new Error('eventOptionId is required for per-item costs');
|
||||
}
|
||||
if (costType === 'per_item') {
|
||||
const option = await prisma.eventOption.findUnique({ where: { id: eventOptionId } });
|
||||
if (!option || option.eventId !== eventId) {
|
||||
res.status(404);
|
||||
throw new Error('Ticket type not found for this event');
|
||||
}
|
||||
}
|
||||
|
||||
const cost = await prisma.eventCost.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
label: String(label).trim(),
|
||||
costType,
|
||||
amount: amt,
|
||||
eventOptionId: costType === 'per_item' ? eventOptionId : null,
|
||||
paidFromMethod: normalizePaidFromMethod(paidFromMethod) ?? null,
|
||||
notes: notes || null
|
||||
},
|
||||
include: { eventOption: { select: { id: true, name: true } } }
|
||||
});
|
||||
|
||||
res.status(201).json(cost);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
res.status(201).json(await setup.createCost(req.params.eventId, req.body || {}));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Update a cost
|
||||
// @route PUT /api/costs/:id
|
||||
// @access Private/Supervisor
|
||||
// @access Private/Admin
|
||||
const updateEventCost = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.eventCost.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) {
|
||||
res.status(404);
|
||||
throw new Error('Cost not found');
|
||||
}
|
||||
|
||||
await assertEventOpen(existing.eventId, res);
|
||||
|
||||
const { label, costType, amount, eventOptionId, notes, paidFromMethod } = req.body;
|
||||
const nextCostType = costType !== undefined ? costType : existing.costType;
|
||||
if (nextCostType !== 'once_off' && nextCostType !== 'per_item') {
|
||||
res.status(400);
|
||||
throw new Error("costType must be 'once_off' or 'per_item'");
|
||||
}
|
||||
|
||||
const nextEventOptionId = nextCostType === 'per_item'
|
||||
? (eventOptionId !== undefined ? eventOptionId : existing.eventOptionId)
|
||||
: null;
|
||||
if (nextCostType === 'per_item') {
|
||||
if (!nextEventOptionId) {
|
||||
res.status(400);
|
||||
throw new Error('eventOptionId is required for per-item costs');
|
||||
}
|
||||
const option = await prisma.eventOption.findUnique({ where: { id: nextEventOptionId } });
|
||||
if (!option || option.eventId !== existing.eventId) {
|
||||
res.status(404);
|
||||
throw new Error('Ticket type not found for this event');
|
||||
}
|
||||
}
|
||||
|
||||
let nextAmount = existing.amount;
|
||||
if (amount !== undefined) {
|
||||
const amt = parseFloat(amount);
|
||||
if (!(amt >= 0)) {
|
||||
res.status(400);
|
||||
throw new Error('Amount must be a non-negative number');
|
||||
}
|
||||
nextAmount = amt;
|
||||
}
|
||||
|
||||
const nextPaidFromMethod = normalizePaidFromMethod(paidFromMethod);
|
||||
|
||||
const cost = await prisma.eventCost.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
label: label !== undefined ? String(label).trim() : existing.label,
|
||||
costType: nextCostType,
|
||||
amount: nextAmount,
|
||||
eventOptionId: nextEventOptionId,
|
||||
paidFromMethod: nextPaidFromMethod !== undefined ? nextPaidFromMethod : existing.paidFromMethod,
|
||||
notes: notes !== undefined ? notes : existing.notes
|
||||
},
|
||||
include: { eventOption: { select: { id: true, name: true } } }
|
||||
});
|
||||
|
||||
res.json(cost);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
res.json(await setup.updateCost(req.params.id, req.body || {}));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Delete a cost
|
||||
// @route DELETE /api/costs/:id
|
||||
// @access Private/Supervisor
|
||||
// @access Private/Admin
|
||||
const deleteEventCost = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.eventCost.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) {
|
||||
res.status(404);
|
||||
throw new Error('Cost not found');
|
||||
}
|
||||
|
||||
await assertEventOpen(existing.eventId, res);
|
||||
|
||||
await prisma.eventCost.delete({ where: { id: req.params.id } });
|
||||
await setup.deleteCost(req.params.id);
|
||||
res.json({ message: 'Cost deleted' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getEventCosts,
|
||||
createEventCost,
|
||||
updateEventCost,
|
||||
deleteEventCost
|
||||
};
|
||||
module.exports = { getEventCosts, createEventCost, updateEventCost, deleteEventCost };
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const sessionsUtil = require('../utils/sessions');
|
||||
const { publicOptions } = require('../utils/volunteers');
|
||||
const { campusesEnabled, campusWhere, resolveCampusFilter, EVENT_CAMPUS_SELECT } = require('../utils/campuses');
|
||||
const { scopeFor, canView } = require('../utils/campusScope');
|
||||
const seriesUtil = require('../utils/series');
|
||||
const { scopeFor } = require('../utils/campusScope');
|
||||
|
||||
// Setting events up is shared with /api/v2/events (TypeScript, 1.51.7).
|
||||
const eventService = require('../services/events');
|
||||
const { SERIES_SELECT, SESSIONS_INCLUDE, withSchedule } = eventService;
|
||||
const details = require('../services/eventDetails');
|
||||
|
||||
// @desc Copy allocation groups (and, for admins, planned costs) from another event into a new copy
|
||||
// @route POST /api/events/:id/copy-from
|
||||
@@ -222,173 +220,9 @@ const getEventsAll = async (req, res) => {
|
||||
// @access Public
|
||||
const getEventById = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.params.id;
|
||||
// Determine if Prisma Client supports EarlyBirdTier / OptionVariant
|
||||
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||
const optionInclude = canIncludeTiers
|
||||
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
|
||||
: undefined;
|
||||
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE, campus: { select: EVENT_CAMPUS_SELECT }, series: SERIES_SELECT };
|
||||
|
||||
// Staff/supervisor/admin need the creator + notify-recipient list to populate the
|
||||
// event edit screen's Notifications step; public callers don't need this.
|
||||
const isStaffOrHigherForNotify = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
|
||||
if (isStaffOrHigherForNotify) {
|
||||
includeObj.createdBy = { select: { id: true, name: true, email: true } };
|
||||
includeObj.notifyRecipients = { select: { id: true, name: true, email: true, role: true } };
|
||||
}
|
||||
|
||||
let event;
|
||||
|
||||
// Try to include attachments via Prisma if the client has the model
|
||||
const canUsePrismaAttachments = (prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function');
|
||||
if (canUsePrismaAttachments) {
|
||||
try {
|
||||
// @ts-ignore - runtime guard ensures this exists
|
||||
includeObj.attachments = true;
|
||||
event = await prisma.event.findUnique({ where: { id: eventId }, include: includeObj });
|
||||
} catch (e) {
|
||||
// If relation/table does not exist (migration not deployed), fall back to fetching without attachments
|
||||
try {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true, ...(isStaffOrHigherForNotify ? { createdBy: includeObj.createdBy, notifyRecipients: includeObj.notifyRecipients } : {}) } });
|
||||
} catch (e2) {
|
||||
// Notify-recipients relation not migrated yet either — fall back further
|
||||
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true } });
|
||||
}
|
||||
// And we will source attachments from the filesystem manifest below
|
||||
}
|
||||
} else {
|
||||
// No model available on the Prisma client; fetch without attachments
|
||||
try {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true, ...(isStaffOrHigherForNotify ? { createdBy: includeObj.createdBy, notifyRecipients: includeObj.notifyRecipients } : {}) } });
|
||||
} catch (e) {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: true } });
|
||||
}
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
// Public gating: hide inactive or not-yet-live events from everyone except staff/supervisor/admin,
|
||||
// who need to load inactive events for cashup, editing, etc. Campus-scoped staff get the
|
||||
// public view of other campuses' events (and not their notification recipients).
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)) && canView(await scopeFor(req), event.campusId);
|
||||
if (!isStaffOrHigher) {
|
||||
delete event.createdBy;
|
||||
delete event.notifyRecipients;
|
||||
// The kids check-in and volunteer tickets are handed out by staff, never sold.
|
||||
event.eventOptions = publicOptions(event);
|
||||
if (event.isActive === false) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
// goLiveAt may not exist on older schemas — tolerate that read failing without
|
||||
// affecting the isActive gate above.
|
||||
let goLiveAt = null;
|
||||
try { goLiveAt = event.goLiveAt ? new Date(event.goLiveAt) : null; } catch (e) {}
|
||||
if (goLiveAt && new Date() < goLiveAt) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve attachments
|
||||
let attachments = [];
|
||||
if (event.attachments && Array.isArray(event.attachments)) {
|
||||
attachments = event.attachments;
|
||||
} else {
|
||||
// Fall back to manifest on disk if DB attachments are not available
|
||||
try {
|
||||
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
||||
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
||||
const list = JSON.parse(raw) || [];
|
||||
attachments = list;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Try include form definition if available
|
||||
let form = null;
|
||||
try {
|
||||
if (prisma && prisma.eventForm && typeof prisma.eventForm.findUnique === 'function') {
|
||||
const f = await prisma.eventForm.findUnique({
|
||||
where: { eventId: eventId },
|
||||
include: { fields: true },
|
||||
});
|
||||
if (f) {
|
||||
form = {
|
||||
id: f.id,
|
||||
isRequired: !!f.isRequired,
|
||||
fields: (f.fields || []).sort((a,b) => (a.order||0)-(b.order||0)).map(fl => ({
|
||||
id: fl.id,
|
||||
type: fl.type,
|
||||
label: fl.label,
|
||||
isRequired: !!fl.isRequired,
|
||||
order: fl.order || 0,
|
||||
helpText: fl.helpText || null,
|
||||
options: fl.options || null,
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Compute availableCount per option and variant
|
||||
let eventOptionsWithStock = event.eventOptions || [];
|
||||
try {
|
||||
if (canIncludeVariants) {
|
||||
eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => {
|
||||
// Taken = non-cancelled registrations + places held for live waitlist offers
|
||||
const { takenCount } = require('../utils/stock');
|
||||
// Picked-session options: the stock limit is places per session.
|
||||
if (sessionsUtil.isPickOption(event, opt)) {
|
||||
const sessionAvailability = {};
|
||||
if (opt.stockLimit > 0) {
|
||||
for (const s of sessionsUtil.activeSessions(event.sessions)) {
|
||||
sessionAvailability[s.id] = Math.max(0, opt.stockLimit - await takenCount({ eventOptionId: opt.id, sessionId: s.id }));
|
||||
}
|
||||
}
|
||||
return { ...opt, soldCount: 0, availableCount: null, sessionAvailability };
|
||||
}
|
||||
const soldCount = await takenCount({ eventOptionId: opt.id });
|
||||
const availableCount = opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null;
|
||||
|
||||
// Per-variant stock
|
||||
const variantsWithStock = await Promise.all((opt.variants || []).map(async v => {
|
||||
if (!v.stockLimit) return { ...v, soldCount: 0, availableCount: null };
|
||||
const vSold = await takenCount({ eventOptionId: opt.id, variantId: v.id });
|
||||
return { ...v, soldCount: vSold, availableCount: Math.max(0, v.stockLimit - vSold) };
|
||||
}));
|
||||
|
||||
return { ...opt, soldCount, availableCount, variants: variantsWithStock };
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-fatal: fall back to options without stock info
|
||||
}
|
||||
|
||||
if (!event.sessions) event.sessions = await prisma.eventSession.findMany({ where: { eventId }, ...SESSIONS_INCLUDE }).catch(() => []);
|
||||
const bookings = event.scheduleType === 'sessions' ? await sessionsUtil.sessionBookings(eventId).catch(() => null) : null;
|
||||
const mapped = withSchedule({
|
||||
...event,
|
||||
eventOptions: eventOptionsWithStock,
|
||||
picture: toAbsoluteUrl(req, event.picture),
|
||||
attachments: (attachments || []).map(att => ({
|
||||
...att,
|
||||
url: toAbsoluteUrl(req, att.url)
|
||||
})),
|
||||
form,
|
||||
bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []),
|
||||
nextInSeries: await seriesUtil.nextInSeries(event).catch(() => null),
|
||||
}, bookings);
|
||||
res.json(mapped);
|
||||
res.json(await details.getEventDetail(req.params.id, { staff: !!req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role), scope: await scopeFor(req) }, (u) => toAbsoluteUrl(req, u)));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -397,47 +231,12 @@ const getEventById = async (req, res) => {
|
||||
// @access Public (same visibility gating as getEventById)
|
||||
const getEventIcs = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.params.id;
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true, scheduleType: true, sessions: { orderBy: { startsAt: 'asc' } } },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
// Same public-visibility gate as getEventById — a hidden/not-yet-live event's
|
||||
// details shouldn't be enumerable via this route either.
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role)) && canView(await scopeFor(req), event.campusId);
|
||||
if (!isStaffOrHigher) {
|
||||
if (event.isActive === false) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
let goLiveAt = null;
|
||||
try { goLiveAt = event.goLiveAt ? new Date(event.goLiveAt) : null; } catch (e) {}
|
||||
if (goLiveAt && new Date() < goLiveAt) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
}
|
||||
|
||||
const { buildEventIcs } = require('../utils/icsUtils');
|
||||
let frontendUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
// The ics library's url validator requires a scheme; guard against FRONTEND_URL/APP_BASE_URL
|
||||
// being configured without one (e.g. "example.com" instead of "https://example.com").
|
||||
if (!/^[a-z0-9+.-]+:\/\//i.test(frontendUrl)) {
|
||||
frontendUrl = `https://${frontendUrl}`;
|
||||
}
|
||||
const ics = buildEventIcs(event, `${frontendUrl}/events/${event.id}`);
|
||||
|
||||
const { filename, ics } = await details.eventCalendar(req.params.id, { staff: !!req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role), scope: await scopeFor(req) });
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${event.title.replace(/[^a-z0-9 -]/gi, '').slice(0, 60) || 'event'}.ics"`);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(ics);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -491,49 +290,14 @@ const deleteEvent = async (req, res) => {
|
||||
};
|
||||
|
||||
// Optional staff price from a request body: '' / null clears it, undefined leaves it.
|
||||
function parseStaffPrice(value, current = null) {
|
||||
if (value === undefined) return current;
|
||||
if (value === null || value === '') return null;
|
||||
const n = parseFloat(value);
|
||||
if (!(n >= 0)) throw new Error('Staff price must be zero or more');
|
||||
return n;
|
||||
}
|
||||
|
||||
// @desc Create event option
|
||||
// @route POST /api/events/:id/options
|
||||
// @access Private/Admin
|
||||
const createEventOption = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
await assertEventOpen(req.params.id, res);
|
||||
|
||||
const { name, price, isMainTicket, stockLimit, staffPrice, sessionMode } = req.body;
|
||||
|
||||
const eventOption = await prisma.eventOption.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventId: req.params.id,
|
||||
name,
|
||||
price: parseFloat(price),
|
||||
isMainTicket: isMainTicket || false,
|
||||
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
|
||||
staffPrice: parseStaffPrice(staffPrice),
|
||||
sessionMode: sessionMode === 'pick' ? 'pick' : 'all',
|
||||
}
|
||||
});
|
||||
sessionsUtil.forgetSchedule(req.params.id);
|
||||
|
||||
res.status(201).json(eventOption);
|
||||
res.status(201).json(await details.createOption(req.params.id, req.body || {}));
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -542,131 +306,9 @@ const createEventOption = async (req, res) => {
|
||||
// @access Private/Admin
|
||||
const updateEventOption = async (req, res) => {
|
||||
try {
|
||||
const eventOption = await prisma.eventOption.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { earlyBirdTiers: true }
|
||||
});
|
||||
|
||||
if (!eventOption) {
|
||||
res.status(404);
|
||||
throw new Error('Event option not found');
|
||||
}
|
||||
|
||||
await assertEventOpen(eventOption.eventId, res);
|
||||
|
||||
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice, sessionMode } = req.body;
|
||||
|
||||
const updatedEventOption = await prisma.eventOption.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
name: name !== undefined ? name : eventOption.name,
|
||||
price: price !== undefined ? parseFloat(price) : eventOption.price,
|
||||
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
|
||||
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
|
||||
staffPrice: parseStaffPrice(staffPrice, eventOption.staffPrice),
|
||||
sessionMode: sessionMode !== undefined ? (sessionMode === 'pick' ? 'pick' : 'all') : eventOption.sessionMode,
|
||||
}
|
||||
});
|
||||
|
||||
// If earlyBirdTiers provided, upsert tiers for this option (including per-variant ones) by
|
||||
// id — mirrors the variants upsert just below. Editing tiers used to delete-and-recreate
|
||||
// every tier for the option on every save, which silently reassigned each one a brand-new
|
||||
// id; RegistrationOption/RegistrationOptionTranche.appliedTierId (onDelete: SetNull) then
|
||||
// pointed at nothing, so every past purchase under that tier lost its "early bird"
|
||||
// attribution — even for tiers the admin didn't touch — and any stock-limit count for the
|
||||
// tier reset to zero (allowing it to be oversold). Upserting by id keeps existing tiers'
|
||||
// ids stable across edits so that history stays linked.
|
||||
if (Array.isArray(earlyBirdTiers)) {
|
||||
const incomingIds = earlyBirdTiers.filter(t => t && t.id).map(t => t.id);
|
||||
const existingTiers = await prisma.earlyBirdTier.findMany({ where: { eventOptionId: updatedEventOption.id } });
|
||||
for (const et of existingTiers) {
|
||||
if (incomingIds.includes(et.id)) continue;
|
||||
// Never delete a tier that's still attributed on past purchases — just leave it
|
||||
// orphaned from the option's active tier list rather than nulling out history.
|
||||
const usageCount = await prisma.registrationOptionTranche.count({ where: { appliedTierId: et.id } });
|
||||
if (usageCount === 0) {
|
||||
try { await prisma.earlyBirdTier.delete({ where: { id: et.id } }); } catch {}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < earlyBirdTiers.length; i++) {
|
||||
const t = earlyBirdTiers[i];
|
||||
if (!t || !t.deadline || (t.price === undefined || t.price === null)) continue;
|
||||
const deadline = new Date(t.deadline);
|
||||
const p = parseFloat(t.price);
|
||||
if (!(deadline instanceof Date) || isNaN(deadline.getTime()) || !(p >= 0)) continue;
|
||||
const data = {
|
||||
eventOptionId: updatedEventOption.id,
|
||||
variantId: t.variantId || null,
|
||||
deadline,
|
||||
price: p,
|
||||
order: typeof t.order === 'number' ? t.order : i,
|
||||
stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0,
|
||||
};
|
||||
if (t.id) {
|
||||
await prisma.earlyBirdTier.upsert({
|
||||
where: { id: t.id },
|
||||
update: data,
|
||||
create: { id: t.id, ...data },
|
||||
});
|
||||
} else {
|
||||
await prisma.earlyBirdTier.create({ data: { id: require('uuid').v4(), ...data } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If variants array provided, upsert variants
|
||||
if (Array.isArray(variants)) {
|
||||
const incomingIds = variants.filter(v => v.id).map(v => v.id);
|
||||
// Delete variants not in the new list (only if they have no registrations)
|
||||
const existingVariants = await prisma.optionVariant.findMany({ where: { eventOptionId: updatedEventOption.id } });
|
||||
for (const ev of existingVariants) {
|
||||
if (!incomingIds.includes(ev.id)) {
|
||||
const usageCount = await prisma.registrationOption.count({ where: { variantId: ev.id } });
|
||||
if (usageCount === 0) {
|
||||
await prisma.optionVariant.delete({ where: { id: ev.id } });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Upsert incoming variants
|
||||
for (let i = 0; i < variants.length; i++) {
|
||||
const v = variants[i];
|
||||
if (!v.name) continue;
|
||||
const variantPrice = v.price !== undefined && v.price !== null && v.price !== '' ? parseFloat(v.price) : null;
|
||||
const variantStockLimit = v.stockLimit !== undefined ? parseInt(v.stockLimit, 10) : 0;
|
||||
const variantStaffPrice = parseStaffPrice(v.staffPrice);
|
||||
if (v.id) {
|
||||
await prisma.optionVariant.update({
|
||||
where: { id: v.id },
|
||||
data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, staffPrice: variantStaffPrice, order: i }
|
||||
});
|
||||
} else {
|
||||
await prisma.optionVariant.create({
|
||||
data: {
|
||||
id: require('uuid').v4(),
|
||||
eventOptionId: updatedEventOption.id,
|
||||
name: v.name,
|
||||
price: variantPrice,
|
||||
stockLimit: variantStockLimit,
|
||||
staffPrice: variantStaffPrice,
|
||||
order: i,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stockLimit !== undefined || variants !== undefined) {
|
||||
require('../utils/waitlist').queueWaitlist(eventOption.eventId);
|
||||
}
|
||||
|
||||
const refreshed = await prisma.eventOption.findUnique({
|
||||
where: { id: updatedEventOption.id },
|
||||
include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } }
|
||||
});
|
||||
|
||||
res.json(refreshed);
|
||||
res.json(await details.updateOption(req.params.id, req.body || {}));
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -675,49 +317,10 @@ const updateEventOption = async (req, res) => {
|
||||
// @access Private/Admin
|
||||
const deleteEventOption = async (req, res) => {
|
||||
try {
|
||||
const eventOption = await prisma.eventOption.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!eventOption) {
|
||||
res.status(404);
|
||||
throw new Error('Event option not found');
|
||||
}
|
||||
|
||||
await assertEventOpen(eventOption.eventId, res);
|
||||
|
||||
// If deleting a main ticket, ensure at least one other main ticket exists for the same event
|
||||
if (eventOption.isMainTicket) {
|
||||
const otherMainCount = await prisma.eventOption.count({
|
||||
where: {
|
||||
eventId: eventOption.eventId,
|
||||
isMainTicket: true,
|
||||
NOT: { id: eventOption.id },
|
||||
}
|
||||
});
|
||||
if (otherMainCount === 0) {
|
||||
res.status(400);
|
||||
throw new Error('Cannot delete the only main ticket option');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there are any registrations using this option
|
||||
const registrationOptions = await prisma.registrationOption.findMany({
|
||||
where: { eventOptionId: req.params.id }
|
||||
});
|
||||
|
||||
if (registrationOptions.length > 0) {
|
||||
res.status(400);
|
||||
throw new Error('Cannot delete event option that has registrations');
|
||||
}
|
||||
|
||||
await prisma.eventOption.delete({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
await details.deleteOption(req.params.id);
|
||||
res.json({ message: 'Event option removed' });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -726,25 +329,9 @@ const deleteEventOption = async (req, res) => {
|
||||
// @access Private/Supervisor
|
||||
const createOptionVariant = async (req, res) => {
|
||||
try {
|
||||
const eventOption = await prisma.eventOption.findUnique({ where: { id: req.params.id } });
|
||||
if (!eventOption) { res.status(404); throw new Error('Event option not found'); }
|
||||
await assertEventOpen(eventOption.eventId, res);
|
||||
const { name, price, stockLimit, order, staffPrice } = req.body;
|
||||
if (!name) { res.status(400); throw new Error('Variant name is required'); }
|
||||
const variant = await prisma.optionVariant.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventOptionId: req.params.id,
|
||||
name,
|
||||
price: price !== undefined && price !== null && price !== '' ? parseFloat(price) : null,
|
||||
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
|
||||
staffPrice: parseStaffPrice(staffPrice),
|
||||
order: order !== undefined ? parseInt(order, 10) : 0,
|
||||
}
|
||||
});
|
||||
res.status(201).json(variant);
|
||||
res.status(201).json(await details.createVariant(req.params.id, req.body || {}));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -753,23 +340,9 @@ const createOptionVariant = async (req, res) => {
|
||||
// @access Private/Supervisor
|
||||
const updateOptionVariant = async (req, res) => {
|
||||
try {
|
||||
const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } });
|
||||
if (!variant) { res.status(404); throw new Error('Variant not found'); }
|
||||
await assertEventOpen(variant.eventOption.eventId, res);
|
||||
const { name, price, stockLimit, order, staffPrice } = req.body;
|
||||
const updated = await prisma.optionVariant.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
name: name !== undefined ? name : variant.name,
|
||||
staffPrice: parseStaffPrice(staffPrice, variant.staffPrice),
|
||||
price: price !== undefined ? (price === null || price === '' ? null : parseFloat(price)) : variant.price,
|
||||
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : variant.stockLimit,
|
||||
order: order !== undefined ? parseInt(order, 10) : variant.order,
|
||||
}
|
||||
});
|
||||
res.json(updated);
|
||||
res.json(await details.updateVariant(req.params.id, req.body || {}));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -778,25 +351,19 @@ const updateOptionVariant = async (req, res) => {
|
||||
// @access Private/Admin
|
||||
const deleteOptionVariant = async (req, res) => {
|
||||
try {
|
||||
const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } });
|
||||
if (!variant) { res.status(404); throw new Error('Variant not found'); }
|
||||
await assertEventOpen(variant.eventOption.eventId, res);
|
||||
const usageCount = await prisma.registrationOption.count({ where: { variantId: req.params.id } });
|
||||
if (usageCount > 0) { res.status(400); throw new Error('Cannot delete variant with existing registrations'); }
|
||||
await prisma.optionVariant.delete({ where: { id: req.params.id } });
|
||||
await details.deleteVariant(req.params.id);
|
||||
res.json({ message: 'Variant deleted' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// File upload setup for attachments (PDFs/docs)
|
||||
// File upload for attachments (PDFs/docs), shared with /api/v2/events/{id}/attachments.
|
||||
const attachmentsStorage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
||||
try {
|
||||
if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true });
|
||||
cb(null, uploadPath);
|
||||
if (!fs.existsSync(details.ATTACHMENTS_DIR)) fs.mkdirSync(details.ATTACHMENTS_DIR, { recursive: true });
|
||||
cb(null, details.ATTACHMENTS_DIR);
|
||||
} catch (err) {
|
||||
cb(err);
|
||||
}
|
||||
@@ -805,48 +372,31 @@ const attachmentsStorage = multer.diskStorage({
|
||||
// Extension only — file.originalname is untrusted and joining it into a
|
||||
// path allows `../` traversal to write outside the upload directory.
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const unique = `event-file-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
|
||||
cb(null, unique);
|
||||
cb(null, `event-file-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const allowedDocs = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv', '.zip'];
|
||||
const uploadAttachmentMulter = multer({
|
||||
storage: attachmentsStorage,
|
||||
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!allowedDocs.includes(ext)) {
|
||||
return cb(new Error('Unsupported file type'));
|
||||
}
|
||||
if (!details.ALLOWED_DOCS.includes(path.extname(file.originalname).toLowerCase())) return cb(new Error('Unsupported file type'));
|
||||
cb(null, true);
|
||||
}
|
||||
});
|
||||
// Saves the upload (field "file"); an error is kept on req for the handler to report.
|
||||
const receiveAttachment = (req, res, next) => uploadAttachmentMulter.single('file')(req, res, (e) => {
|
||||
if (e) req.multerError = e;
|
||||
next();
|
||||
});
|
||||
|
||||
// @desc List attachments for an event
|
||||
// @route GET /api/events/:id/attachments
|
||||
// @access Private/Supervisor (for dashboard) and could be public via event details
|
||||
const listEventAttachments = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.params.id;
|
||||
// If attachments model isn't available yet (pre-migration/generate), fall back to filesystem manifest
|
||||
if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function')) {
|
||||
try {
|
||||
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
||||
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
|
||||
if (!fs.existsSync(manifestPath)) return res.json([]);
|
||||
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
||||
const list = JSON.parse(raw);
|
||||
return res.json(list.map(att => ({ ...att, url: toAbsoluteUrl(req, att.url) })));
|
||||
} catch (e) {
|
||||
// If manifest is corrupt or unreadable, return empty list rather than failing
|
||||
return res.json([]);
|
||||
}
|
||||
}
|
||||
const items = await prisma.eventAttachment.findMany({ where: { eventId }, orderBy: { createdAt: 'desc' } });
|
||||
res.json(items.map(att => ({ ...att, url: toAbsoluteUrl(req, att.url) })));
|
||||
res.json(await details.listAttachments(req.params.id, (u) => toAbsoluteUrl(req, u)));
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -854,97 +404,14 @@ const listEventAttachments = async (req, res) => {
|
||||
// @route POST /api/events/:id/attachments
|
||||
// @access Private/Supervisor
|
||||
const uploadEventAttachment = [
|
||||
(req, res, next) => uploadAttachmentMulter.single('file')(req, res, (err) => {
|
||||
if (err) req.multerError = err;
|
||||
next();
|
||||
}),
|
||||
receiveAttachment,
|
||||
async (req, res) => {
|
||||
try {
|
||||
if (req.multerError) throw req.multerError;
|
||||
if (!req.file) return res.status(400).json({ message: 'No file uploaded' });
|
||||
const eventId = req.params.id;
|
||||
const relUrl = `/uploads/event-files/${req.file.filename}`;
|
||||
|
||||
// Fallback: if attachments model is not available yet, store in filesystem manifest
|
||||
if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.create === 'function')) {
|
||||
try { console.warn('[attachments] Falling back to filesystem manifest: Prisma client lacks EventAttachment model. Ensure prisma generate ran in the deployed app dir and server restarted.'); } catch {}
|
||||
try {
|
||||
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
|
||||
const entry = {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
originalName: req.file.originalname,
|
||||
filename: req.file.filename,
|
||||
mimeType: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
url: relUrl,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
let list = [];
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
try {
|
||||
list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || [];
|
||||
} catch {}
|
||||
}
|
||||
list.unshift(entry);
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8');
|
||||
return res.status(201).json({ ...entry, url: toAbsoluteUrl(req, entry.url) });
|
||||
} catch (e) {
|
||||
return res.status(500).json({ message: 'Failed to record attachment', detail: e?.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Normal path: DB-backed
|
||||
try {
|
||||
const created = await prisma.eventAttachment.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
originalName: req.file.originalname,
|
||||
filename: req.file.filename,
|
||||
mimeType: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
url: relUrl,
|
||||
}
|
||||
});
|
||||
return res.status(201).json({ ...created, url: toAbsoluteUrl(req, created.url) });
|
||||
} catch (e) {
|
||||
const msg = String(e?.message || e || '');
|
||||
const isMissingTable = msg.includes('does not exist') || msg.includes('relation') || msg.includes('P2021');
|
||||
if (isMissingTable) {
|
||||
try { console.warn('[attachments] DB create failed, falling back to filesystem manifest. Error:', msg); } catch {}
|
||||
try {
|
||||
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
|
||||
const entry = {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
originalName: req.file.originalname,
|
||||
filename: req.file.filename,
|
||||
mimeType: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
url: relUrl,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
let list = [];
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
try { list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || []; } catch {}
|
||||
}
|
||||
list.unshift(entry);
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8');
|
||||
return res.status(201).json({ ...entry, url: toAbsoluteUrl(req, entry.url) });
|
||||
} catch (fallbackErr) {
|
||||
return res.status(500).json({ message: 'Failed to record attachment', detail: String(fallbackErr?.message || fallbackErr) });
|
||||
}
|
||||
}
|
||||
// Unknown error
|
||||
throw e;
|
||||
}
|
||||
res.status(201).json(await details.recordAttachment(req.params.id, req.file, (u) => toAbsoluteUrl(req, u)));
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -954,43 +421,10 @@ const uploadEventAttachment = [
|
||||
// @access Private/Admin or Supervisor
|
||||
const deleteEventAttachment = async (req, res) => {
|
||||
try {
|
||||
const { eventId, attachmentId } = req.params;
|
||||
// If attachments model is not available yet, fall back to filesystem manifest
|
||||
if (!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findUnique === 'function')) {
|
||||
try {
|
||||
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
||||
const manifestPath = path.join(uploadDir, `${eventId}.attachments.json`);
|
||||
if (!fs.existsSync(manifestPath)) return res.status(404).json({ message: 'Attachment not found' });
|
||||
const list = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) || [];
|
||||
const idx = list.findIndex((x) => x.id === attachmentId);
|
||||
if (idx === -1) return res.status(404).json({ message: 'Attachment not found' });
|
||||
const att = list[idx];
|
||||
// Remove file on disk (best-effort)
|
||||
try {
|
||||
const filePath = path.join(uploadDir, att.filename);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
} catch {}
|
||||
// Remove from manifest
|
||||
list.splice(idx, 1);
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(list, null, 2), 'utf-8');
|
||||
return res.json({ message: 'Attachment deleted' });
|
||||
} catch (e) {
|
||||
return res.status(500).json({ message: 'Failed to delete attachment', detail: e?.message });
|
||||
}
|
||||
}
|
||||
const att = await prisma.eventAttachment.findUnique({ where: { id: attachmentId } });
|
||||
if (!att || att.eventId !== eventId) {
|
||||
return res.status(404).json({ message: 'Attachment not found' });
|
||||
}
|
||||
// Remove file on disk (best-effort)
|
||||
try {
|
||||
const filePath = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files', att.filename);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
} catch {}
|
||||
await prisma.eventAttachment.delete({ where: { id: attachmentId } });
|
||||
await details.deleteAttachment(req.params.eventId, req.params.attachmentId);
|
||||
res.json({ message: 'Attachment deleted' });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -999,52 +433,20 @@ const deleteEventAttachment = async (req, res) => {
|
||||
// @access Private/Admin
|
||||
const attachmentsStatus = async (req, res) => {
|
||||
try {
|
||||
const hasModel = !!(prisma && prisma.eventAttachment && typeof prisma.eventAttachment.findMany === 'function');
|
||||
let tableQueryable = false;
|
||||
let probeError = null;
|
||||
if (hasModel) {
|
||||
try {
|
||||
await prisma.eventAttachment.findFirst({});
|
||||
tableQueryable = true;
|
||||
} catch (e) {
|
||||
probeError = String(e?.message || e);
|
||||
}
|
||||
}
|
||||
// Mask DB URL to avoid leaking secrets
|
||||
const dbUrl = process.env.DATABASE_URL || '';
|
||||
let dbInfo = null;
|
||||
try {
|
||||
const u = new URL(dbUrl);
|
||||
dbInfo = {
|
||||
protocol: u.protocol.replace(':',''),
|
||||
host: u.hostname,
|
||||
port: u.port || undefined,
|
||||
database: (u.pathname || '').replace(/^\//,'') || undefined,
|
||||
};
|
||||
} catch {}
|
||||
res.json({
|
||||
prismaClientHasEventAttachmentModel: hasModel,
|
||||
databaseTableQueryable: tableQueryable,
|
||||
probeError,
|
||||
database: dbInfo,
|
||||
});
|
||||
res.json(await details.attachmentsStatus());
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Admin-triggered sync: import manifest files into DB
|
||||
// @route POST /api/events/attachments/sync
|
||||
// @access Private/Admin
|
||||
const { syncManifestsToDb } = require('../utils/attachmentsSync');
|
||||
const attachmentsSync = async (req, res) => {
|
||||
try {
|
||||
const dryRun = String(req.query.dryRun || '').toLowerCase() === 'true';
|
||||
const remove = String(req.query.remove || '').toLowerCase() === 'true';
|
||||
const summary = await syncManifestsToDb(prisma, { dryRun, removeManifestAfterImport: remove });
|
||||
res.json({ dryRun, remove, ...summary });
|
||||
res.json(await details.syncAttachmentManifests({ dryRun: String(req.query.dryRun || '').toLowerCase() === 'true', remove: String(req.query.remove || '').toLowerCase() === 'true' }));
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1635,58 +1037,17 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
|
||||
// catch-all [redirectUrl] route, so it's what every bot/scanner probe hitting
|
||||
// an unmatched top-level path (/wp-login.php, /.env, etc.) ends up calling.
|
||||
// Rejecting non-slug-shaped values here skips a DB round-trip for that traffic.
|
||||
const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
|
||||
|
||||
const getEventByAlias = async (req, res) => {
|
||||
const { redirectUrl } = req.params;
|
||||
|
||||
if (!VALID_ALIAS.test(redirectUrl)) {
|
||||
return res.status(404).json({ message: 'Event not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
const event = await prisma.event.findFirst({
|
||||
where: {
|
||||
redirectUrl: {
|
||||
equals: redirectUrl,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
registrationDeadline: true,
|
||||
goLiveAt: true,
|
||||
price: true,
|
||||
picture: true,
|
||||
isActive: true,
|
||||
redirectUrl: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ message: 'Event not found or inactive' });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const start = event.startDate ? new Date(event.startDate) : null;
|
||||
const end = event.endDate ? new Date(event.endDate) : null;
|
||||
const isLive =
|
||||
(!start || start >= now) &&
|
||||
(!end || end >= now);
|
||||
|
||||
res.json({ ...event, isLive });
|
||||
res.json(await details.eventByAlias(req.params.redirectUrl));
|
||||
} catch (error) {
|
||||
console.error('[getEventByAlias] Error:', error);
|
||||
res.status(500).json({ message: 'Internal server error' });
|
||||
res.status(error.statusCode || 500).json({ message: error.statusCode ? error.message : 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
receiveAttachment,
|
||||
createEvent,
|
||||
copyEventSetupFrom,
|
||||
getEvents,
|
||||
|
||||
@@ -1,189 +1,45 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
// Scanning sections: which ticket types may come in at which door. Shared with
|
||||
// /api/v2/sections (services/setup.ts, 1.51.8).
|
||||
const setup = require('../services/setup');
|
||||
const { listWhere } = require('../utils/campusScope');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message });
|
||||
|
||||
//
|
||||
// @desc Get sections (optionally by event)
|
||||
// @route GET /api/sections?eventId=xxx
|
||||
// @access Private/Staff
|
||||
//
|
||||
const getSections = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.query;
|
||||
|
||||
const { withScope, listWhere } = require('../utils/campusScope');
|
||||
const where = withScope(eventId && eventId !== 'all' ? { eventId } : {}, await listWhere(req, 'event'));
|
||||
|
||||
const sections = await prisma.section.findMany({
|
||||
where,
|
||||
include: {
|
||||
allowedOptions: {
|
||||
include: {
|
||||
eventOption: true
|
||||
}
|
||||
},
|
||||
event: {
|
||||
select: { id: true, title: true }
|
||||
}
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
res.json(sections);
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
try {
|
||||
res.json(await setup.listSections(req.query.eventId, await listWhere(req, 'event')));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
//
|
||||
// @desc Create section
|
||||
// @route POST /api/sections
|
||||
// @access Private/Admin/Supervisor
|
||||
//
|
||||
const createSection = async (req, res) => {
|
||||
try {
|
||||
const { eventId, name, allowedOptionIds } = req.body;
|
||||
|
||||
if (!eventId || !name) {
|
||||
res.status(400);
|
||||
throw new Error('Event and section name are required');
|
||||
}
|
||||
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId }
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
const section = await prisma.section.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
eventId,
|
||||
name,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// Attach allowed options if provided
|
||||
if (Array.isArray(allowedOptionIds) && allowedOptionIds.length > 0) {
|
||||
await prisma.sectionOption.createMany({
|
||||
data: allowedOptionIds.map(optionId => ({
|
||||
id: uuidv4(),
|
||||
sectionId: section.id,
|
||||
eventOptionId: optionId
|
||||
})),
|
||||
skipDuplicates: true
|
||||
});
|
||||
}
|
||||
|
||||
const fullSection = await prisma.section.findUnique({
|
||||
where: { id: section.id },
|
||||
include: {
|
||||
allowedOptions: {
|
||||
include: { eventOption: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json(fullSection);
|
||||
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
try {
|
||||
res.status(201).json(await setup.createSection(req.body || {}));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
//
|
||||
// @desc Update section
|
||||
// @route PUT /api/sections/:id
|
||||
// @access Private/Admin/Supervisor
|
||||
//
|
||||
const updateSection = async (req, res) => {
|
||||
try {
|
||||
const { name, allowedOptionIds } = req.body;
|
||||
|
||||
const section = await prisma.section.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!section) {
|
||||
res.status(404);
|
||||
throw new Error('Section not found');
|
||||
}
|
||||
|
||||
const updatedSection = await prisma.section.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
name: name || section.name,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// If allowed options supplied → reset them
|
||||
if (Array.isArray(allowedOptionIds)) {
|
||||
|
||||
await prisma.sectionOption.deleteMany({
|
||||
where: { sectionId: section.id }
|
||||
});
|
||||
|
||||
if (allowedOptionIds.length > 0) {
|
||||
await prisma.sectionOption.createMany({
|
||||
data: allowedOptionIds.map(optionId => ({
|
||||
id: uuidv4(),
|
||||
sectionId: section.id,
|
||||
eventOptionId: optionId
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fullSection = await prisma.section.findUnique({
|
||||
where: { id: section.id },
|
||||
include: {
|
||||
allowedOptions: {
|
||||
include: { eventOption: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json(fullSection);
|
||||
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
try {
|
||||
res.json(await setup.updateSection(req.params.id, req.body || {}));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
//
|
||||
// @desc Delete section
|
||||
// @route DELETE /api/sections/:id
|
||||
// @access Private/Admin
|
||||
//
|
||||
const deleteSection = async (req, res) => {
|
||||
try {
|
||||
const section = await prisma.section.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!section) {
|
||||
res.status(404);
|
||||
throw new Error('Section not found');
|
||||
}
|
||||
|
||||
await prisma.section.delete({
|
||||
where: { id: section.id }
|
||||
});
|
||||
|
||||
res.json({ message: 'Section deleted' });
|
||||
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
try {
|
||||
await setup.deleteSection(req.params.id);
|
||||
res.json({ message: 'Section deleted' });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getSections,
|
||||
createSection,
|
||||
updateSection,
|
||||
deleteSection
|
||||
};
|
||||
module.exports = { getSections, createSection, updateSection, deleteSection };
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* One event in full, its ticket options and variants, attachments, calendar file and short link
|
||||
* (1.51.8). Moved from eventController.js so /api/events/* (v1) and /api/v2/events/* share it.
|
||||
* The pre-migration fallbacks (attachments kept in JSON manifests on disk, clients without the
|
||||
* tier/variant models) are gone: every install has those tables.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail } from './errors';
|
||||
import { SESSIONS_INCLUDE, SERIES_SELECT, withSchedule } from './events';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const sessionsUtil = require('../utils/sessions') as {
|
||||
isPickOption(event: any, option: any): boolean;
|
||||
activeSessions(sessions: any[]): Array<{ id: string }>;
|
||||
sessionBookings(eventId: string): Promise<Map<string, number>>;
|
||||
forgetSchedule(eventId: string): void;
|
||||
};
|
||||
const { publicOptions } = require('../utils/volunteers') as { publicOptions<O extends { id: string }>(event: any): O[] };
|
||||
const { EVENT_CAMPUS_SELECT } = require('../utils/campuses') as { EVENT_CAMPUS_SELECT: Record<string, true> };
|
||||
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
|
||||
const { takenCount } = require('../utils/stock') as { takenCount(where: object): Promise<number> };
|
||||
|
||||
/** Where uploaded attachments live (served at /uploads/event-files/…). */
|
||||
export const ATTACHMENTS_DIR = path.join(__dirname, '..', '..', 'public', 'uploads', 'event-files');
|
||||
export const ALLOWED_DOCS = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv', '.zip'];
|
||||
|
||||
/** A stored path as an absolute URL (the caller knows the host the request came to). */
|
||||
export type UrlOf = (stored: string | null | undefined) => string | null;
|
||||
|
||||
/**
|
||||
* Who's looking: staff of a campus they may see get everything (inactive and not-yet-live
|
||||
* events, who created it, who gets its emails, the kids/volunteer tickets); everyone else gets
|
||||
* the public view and NOT_FOUND for events that aren't public.
|
||||
*/
|
||||
export type Viewer = { staff: boolean; scope: object | null };
|
||||
|
||||
const { canView } = require('../utils/campusScope') as { canView(scope: object | null, campusId: string | null): boolean };
|
||||
/** Staff see an event in full when it's at a campus they work with (or campuses are off for them). */
|
||||
const staffViewOf = (viewer: Viewer, campusId: string | null) => viewer.staff && canView(viewer.scope, campusId);
|
||||
|
||||
const notPublic = (ev: { isActive: boolean | null; goLiveAt: Date | null }) =>
|
||||
ev.isActive === false || (ev.goLiveAt != null && new Date() < new Date(ev.goLiveAt));
|
||||
|
||||
/**
|
||||
* One event in full: ticket options with what's left (per variant, and per session for
|
||||
* "pick sessions" tickets), sessions with bookings, the attendee form, attachments, bundle deals
|
||||
* on sale and the next event in its series.
|
||||
*/
|
||||
export async function getEventDetail(eventId: string, viewer: Viewer, urlOf: UrlOf) {
|
||||
const event: any = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: {
|
||||
// (createdBy and notifyRecipients are dropped again below for the public view)
|
||||
eventOptions: { include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } } },
|
||||
sessions: SESSIONS_INCLUDE,
|
||||
campus: { select: EVENT_CAMPUS_SELECT },
|
||||
series: SERIES_SELECT,
|
||||
attachments: true,
|
||||
...(viewer.staff ? {
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
notifyRecipients: { select: { id: true, name: true, email: true, role: true } },
|
||||
} : {}),
|
||||
},
|
||||
});
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
if (!staffViewOf(viewer, event.campusId)) {
|
||||
// Campus-scoped staff get the public view of other campuses' events.
|
||||
delete event.createdBy;
|
||||
delete event.notifyRecipients;
|
||||
if (notPublic(event)) fail(404, 'Event not found');
|
||||
// The kids check-in and volunteer tickets are handed out by staff, never sold.
|
||||
event.eventOptions = publicOptions(event);
|
||||
}
|
||||
|
||||
const f = await prisma.eventForm.findUnique({ where: { eventId }, include: { fields: true } });
|
||||
const form = f ? {
|
||||
id: f.id,
|
||||
isRequired: !!f.isRequired,
|
||||
fields: [...f.fields].sort((a, b) => (a.order || 0) - (b.order || 0)).map(fl => ({
|
||||
id: fl.id, type: fl.type, label: fl.label, isRequired: !!fl.isRequired, order: fl.order || 0, helpText: fl.helpText || null, options: fl.options || null,
|
||||
})),
|
||||
} : null;
|
||||
|
||||
// What's left per option and variant (taken = non-cancelled registrations + places held for
|
||||
// live waitlist offers). "Pick sessions" tickets have places per session instead.
|
||||
const eventOptions = await Promise.all((event.eventOptions || []).map(async (opt: any) => {
|
||||
if (sessionsUtil.isPickOption(event, opt)) {
|
||||
const sessionAvailability: Record<string, number> = {};
|
||||
if (opt.stockLimit > 0) {
|
||||
for (const s of sessionsUtil.activeSessions(event.sessions)) {
|
||||
sessionAvailability[s.id] = Math.max(0, opt.stockLimit - await takenCount({ eventOptionId: opt.id, sessionId: s.id }));
|
||||
}
|
||||
}
|
||||
return { ...opt, soldCount: 0, availableCount: null, sessionAvailability };
|
||||
}
|
||||
const soldCount = await takenCount({ eventOptionId: opt.id });
|
||||
const variants = await Promise.all((opt.variants || []).map(async (v: any) => {
|
||||
if (!v.stockLimit) return { ...v, soldCount: 0, availableCount: null };
|
||||
const vSold = await takenCount({ eventOptionId: opt.id, variantId: v.id });
|
||||
return { ...v, soldCount: vSold, availableCount: Math.max(0, v.stockLimit - vSold) };
|
||||
}));
|
||||
return { ...opt, soldCount, availableCount: opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null, variants };
|
||||
}));
|
||||
|
||||
const bookings = event.scheduleType === 'sessions' ? await sessionsUtil.sessionBookings(eventId).catch(() => null) : null;
|
||||
const { publicBundlesForEvent } = require('../controllers/bundleController') as { publicBundlesForEvent(id: string): Promise<unknown[]> };
|
||||
const { nextInSeries } = require('../utils/series') as { nextInSeries(ev: unknown): Promise<unknown> };
|
||||
return withSchedule({
|
||||
...event,
|
||||
eventOptions,
|
||||
picture: urlOf(event.picture),
|
||||
attachments: (event.attachments || []).map((att: any) => ({ ...att, url: urlOf(att.url) })),
|
||||
form,
|
||||
bundles: await publicBundlesForEvent(eventId).catch(() => []),
|
||||
nextInSeries: await nextInSeries(event).catch(() => null),
|
||||
}, bookings);
|
||||
}
|
||||
|
||||
/** The event (or each session of a multi-day event) as an .ics calendar file. */
|
||||
export async function eventCalendar(eventId: string, viewer: Viewer): Promise<{ filename: string; ics: string }> {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true, scheduleType: true, campusId: true, sessions: { orderBy: { startsAt: 'asc' } } },
|
||||
});
|
||||
if (!event || (!staffViewOf(viewer, event.campusId) && notPublic(event))) return fail(404, 'Event not found');
|
||||
const { buildEventIcs } = require('../utils/icsUtils') as { buildEventIcs(ev: unknown, url: string): string };
|
||||
let frontendUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
// The ics library's URL check needs a scheme.
|
||||
if (!/^[a-z0-9+.-]+:\/\//i.test(frontendUrl)) frontendUrl = `https://${frontendUrl}`;
|
||||
return {
|
||||
filename: `${event.title.replace(/[^a-z0-9 -]/gi, '').slice(0, 60) || 'event'}.ics`,
|
||||
ics: buildEventIcs(event, `${frontendUrl}/events/${event.id}`),
|
||||
};
|
||||
}
|
||||
|
||||
const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
|
||||
|
||||
/** An active event by its short link (`/camp2026`), any letter case. */
|
||||
export async function eventByAlias(alias: string) {
|
||||
if (!VALID_ALIAS.test(alias)) return fail(404, 'Event not found');
|
||||
const event = await prisma.event.findFirst({
|
||||
where: { redirectUrl: { equals: alias, mode: 'insensitive' }, isActive: true },
|
||||
select: { id: true, title: true, description: true, startDate: true, endDate: true, registrationDeadline: true, goLiveAt: true, price: true, picture: true, isActive: true, redirectUrl: true },
|
||||
});
|
||||
if (!event) return fail(404, 'Event not found or inactive');
|
||||
const now = new Date();
|
||||
const isLive = (!event.startDate || new Date(event.startDate) >= now) && (!event.endDate || new Date(event.endDate) >= now);
|
||||
return { ...event, isLive };
|
||||
}
|
||||
|
||||
// ─── Ticket options and variants ─────────────────────────────────────────────
|
||||
|
||||
/** A staff price: undefined keeps `current`, '' or null clears it. */
|
||||
export function parseStaffPrice(value: unknown, current: number | null = null): number | null {
|
||||
if (value === undefined) return current;
|
||||
if (value === null || value === '') return null;
|
||||
const n = parseFloat(String(value));
|
||||
if (!(n >= 0)) fail(400, 'Staff price must be zero or more');
|
||||
return n;
|
||||
}
|
||||
|
||||
const openEvent = async (eventId: string) => {
|
||||
try {
|
||||
await assertEventOpen(eventId);
|
||||
} catch (e: any) {
|
||||
fail(e.message === 'Event not found' ? 404 : 400, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
export type OptionInput = {
|
||||
name?: string; price?: number | string; isMainTicket?: boolean; stockLimit?: number | string; staffPrice?: number | string | null;
|
||||
sessionMode?: string; earlyBirdTiers?: any[]; variants?: any[];
|
||||
};
|
||||
|
||||
export async function createOption(eventId: string, input: OptionInput) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
await openEvent(eventId);
|
||||
if (!input.name) fail(400, 'Give the ticket a name');
|
||||
const option = await prisma.eventOption.create({
|
||||
data: {
|
||||
id: uuidv4(), eventId, name: String(input.name), price: parseFloat(String(input.price ?? 0)) || 0, isMainTicket: input.isMainTicket || false,
|
||||
stockLimit: input.stockLimit ? parseInt(String(input.stockLimit), 10) : 0, staffPrice: parseStaffPrice(input.staffPrice),
|
||||
sessionMode: input.sessionMode === 'pick' ? 'pick' : 'all',
|
||||
},
|
||||
});
|
||||
sessionsUtil.forgetSchedule(eventId);
|
||||
return option;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes a ticket option, and — when sent — its early-bird tiers and variants. Tiers and
|
||||
* variants are upserted by id, so purchases keep pointing at the tier they were bought under;
|
||||
* ones left out are removed only when nothing was bought under them.
|
||||
*/
|
||||
export async function updateOption(optionId: string, input: OptionInput) {
|
||||
const existing = await prisma.eventOption.findUnique({ where: { id: optionId } });
|
||||
if (!existing) return fail(404, 'Event option not found');
|
||||
await openEvent(existing.eventId);
|
||||
const updated = await prisma.eventOption.update({
|
||||
where: { id: optionId },
|
||||
data: {
|
||||
name: input.name !== undefined ? String(input.name) : existing.name,
|
||||
price: input.price !== undefined ? parseFloat(String(input.price)) : existing.price,
|
||||
isMainTicket: input.isMainTicket !== undefined ? input.isMainTicket : existing.isMainTicket,
|
||||
stockLimit: input.stockLimit !== undefined ? parseInt(String(input.stockLimit), 10) : existing.stockLimit,
|
||||
staffPrice: parseStaffPrice(input.staffPrice, existing.staffPrice),
|
||||
sessionMode: input.sessionMode !== undefined ? (input.sessionMode === 'pick' ? 'pick' : 'all') : existing.sessionMode,
|
||||
},
|
||||
});
|
||||
|
||||
// Early-bird tiers: deleting and recreating them would give every tier a new id and cut past
|
||||
// purchases off from the tier they were bought under (and reset its stock count).
|
||||
if (Array.isArray(input.earlyBirdTiers)) {
|
||||
const incoming = input.earlyBirdTiers.filter(t => t && t.id).map(t => t.id);
|
||||
for (const et of await prisma.earlyBirdTier.findMany({ where: { eventOptionId: optionId } })) {
|
||||
if (incoming.includes(et.id)) continue;
|
||||
// Never delete a tier still attributed on past purchases.
|
||||
if (await prisma.registrationOptionTranche.count({ where: { appliedTierId: et.id } }) === 0) {
|
||||
await prisma.earlyBirdTier.delete({ where: { id: et.id } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < input.earlyBirdTiers.length; i++) {
|
||||
const t = input.earlyBirdTiers[i];
|
||||
if (!t || !t.deadline || t.price === undefined || t.price === null) continue;
|
||||
const deadline = new Date(t.deadline);
|
||||
const p = parseFloat(t.price);
|
||||
if (isNaN(deadline.getTime()) || !(p >= 0)) continue;
|
||||
const data = { eventOptionId: optionId, variantId: t.variantId || null, deadline, price: p, order: typeof t.order === 'number' ? t.order : i, stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0 };
|
||||
if (t.id) await prisma.earlyBirdTier.upsert({ where: { id: t.id }, update: data, create: { id: t.id, ...data } });
|
||||
else await prisma.earlyBirdTier.create({ data: { id: uuidv4(), ...data } });
|
||||
}
|
||||
}
|
||||
|
||||
// Variants: upserted by id; ones left out are deleted only when nobody has bought them.
|
||||
if (Array.isArray(input.variants)) {
|
||||
const incoming = input.variants.filter(v => v.id).map(v => v.id);
|
||||
for (const ov of await prisma.optionVariant.findMany({ where: { eventOptionId: optionId } })) {
|
||||
if (!incoming.includes(ov.id) && await prisma.registrationOption.count({ where: { variantId: ov.id } }) === 0) {
|
||||
await prisma.optionVariant.delete({ where: { id: ov.id } });
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < input.variants.length; i++) {
|
||||
const v = input.variants[i];
|
||||
if (!v.name) continue;
|
||||
const data = {
|
||||
name: v.name,
|
||||
price: v.price !== undefined && v.price !== null && v.price !== '' ? parseFloat(v.price) : null,
|
||||
stockLimit: v.stockLimit !== undefined ? parseInt(v.stockLimit, 10) : 0,
|
||||
staffPrice: parseStaffPrice(v.staffPrice),
|
||||
order: i,
|
||||
};
|
||||
if (v.id) await prisma.optionVariant.update({ where: { id: v.id }, data });
|
||||
else await prisma.optionVariant.create({ data: { id: uuidv4(), eventOptionId: optionId, ...data } });
|
||||
}
|
||||
}
|
||||
|
||||
if (input.stockLimit !== undefined || input.variants !== undefined) {
|
||||
(require('../utils/waitlist') as { queueWaitlist(eventId: string): void }).queueWaitlist(existing.eventId);
|
||||
}
|
||||
return prisma.eventOption.findUniqueOrThrow({ where: { id: updated.id }, include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } } });
|
||||
}
|
||||
|
||||
/** Removes a ticket option nobody has bought (and never the event's only main ticket). */
|
||||
export async function deleteOption(optionId: string) {
|
||||
const option = await prisma.eventOption.findUnique({ where: { id: optionId } });
|
||||
if (!option) return fail(404, 'Event option not found');
|
||||
await openEvent(option.eventId);
|
||||
if (option.isMainTicket) {
|
||||
const others = await prisma.eventOption.count({ where: { eventId: option.eventId, isMainTicket: true, NOT: { id: option.id } } });
|
||||
if (others === 0) fail(400, 'Cannot delete the only main ticket option');
|
||||
}
|
||||
if (await prisma.registrationOption.count({ where: { eventOptionId: optionId } }) > 0) fail(400, 'Cannot delete event option that has registrations');
|
||||
await prisma.eventOption.delete({ where: { id: optionId } });
|
||||
}
|
||||
|
||||
export type VariantInput = { name?: string; price?: number | string | null; stockLimit?: number | string; order?: number | string; staffPrice?: number | string | null };
|
||||
|
||||
export async function createVariant(optionId: string, input: VariantInput) {
|
||||
const option = await prisma.eventOption.findUnique({ where: { id: optionId } });
|
||||
if (!option) return fail(404, 'Event option not found');
|
||||
await openEvent(option.eventId);
|
||||
if (!input.name) fail(400, 'Variant name is required');
|
||||
return prisma.optionVariant.create({
|
||||
data: {
|
||||
id: uuidv4(), eventOptionId: optionId, name: String(input.name),
|
||||
price: input.price !== undefined && input.price !== null && input.price !== '' ? parseFloat(String(input.price)) : null,
|
||||
stockLimit: input.stockLimit ? parseInt(String(input.stockLimit), 10) : 0,
|
||||
staffPrice: parseStaffPrice(input.staffPrice),
|
||||
order: input.order !== undefined ? parseInt(String(input.order), 10) : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateVariant(variantId: string, input: VariantInput) {
|
||||
const variant = await prisma.optionVariant.findUnique({ where: { id: variantId }, include: { eventOption: { select: { eventId: true } } } });
|
||||
if (!variant) return fail(404, 'Variant not found');
|
||||
await openEvent(variant.eventOption.eventId);
|
||||
return prisma.optionVariant.update({
|
||||
where: { id: variantId },
|
||||
data: {
|
||||
name: input.name !== undefined ? String(input.name) : variant.name,
|
||||
staffPrice: parseStaffPrice(input.staffPrice, variant.staffPrice),
|
||||
price: input.price !== undefined ? (input.price === null || input.price === '' ? null : parseFloat(String(input.price))) : variant.price,
|
||||
stockLimit: input.stockLimit !== undefined ? parseInt(String(input.stockLimit), 10) : variant.stockLimit,
|
||||
order: input.order !== undefined ? parseInt(String(input.order), 10) : variant.order,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Removes a variant nobody has bought. */
|
||||
export async function deleteVariant(variantId: string) {
|
||||
const variant = await prisma.optionVariant.findUnique({ where: { id: variantId }, include: { eventOption: { select: { eventId: true } } } });
|
||||
if (!variant) return fail(404, 'Variant not found');
|
||||
await openEvent(variant.eventOption.eventId);
|
||||
if (await prisma.registrationOption.count({ where: { variantId } }) > 0) fail(400, 'Cannot delete variant with existing registrations');
|
||||
await prisma.optionVariant.delete({ where: { id: variantId } });
|
||||
}
|
||||
|
||||
// ─── Attachments ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listAttachments(eventId: string, urlOf: UrlOf) {
|
||||
const items = await prisma.eventAttachment.findMany({ where: { eventId }, orderBy: { createdAt: 'desc' } });
|
||||
return items.map(att => ({ ...att, url: urlOf(att.url) }));
|
||||
}
|
||||
|
||||
/** Records a file multer has already saved in ATTACHMENTS_DIR. */
|
||||
export async function recordAttachment(eventId: string, file: { originalname: string; filename: string; mimetype: string; size: number }, urlOf: UrlOf) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
|
||||
if (!event) {
|
||||
fs.promises.unlink(path.join(ATTACHMENTS_DIR, file.filename)).catch(() => {});
|
||||
return fail(404, 'Event not found');
|
||||
}
|
||||
const created = await prisma.eventAttachment.create({
|
||||
data: { id: uuidv4(), eventId, originalName: file.originalname, filename: file.filename, mimeType: file.mimetype, size: file.size, url: `/uploads/event-files/${file.filename}` },
|
||||
});
|
||||
return { ...created, url: urlOf(created.url) };
|
||||
}
|
||||
|
||||
export async function deleteAttachment(eventId: string, attachmentId: string) {
|
||||
const att = await prisma.eventAttachment.findUnique({ where: { id: attachmentId } });
|
||||
if (!att || att.eventId !== eventId) return fail(404, 'Attachment not found');
|
||||
// The file goes too (best effort; the record is what the site shows).
|
||||
await fs.promises.unlink(path.join(ATTACHMENTS_DIR, att.filename)).catch(() => {});
|
||||
await prisma.eventAttachment.delete({ where: { id: attachmentId } });
|
||||
}
|
||||
|
||||
/** Admin diagnostics: whether the attachments table answers, and which database it is (no secrets). */
|
||||
export async function attachmentsStatus() {
|
||||
let tableQueryable = false;
|
||||
let probeError: string | null = null;
|
||||
try {
|
||||
await prisma.eventAttachment.findFirst({});
|
||||
tableQueryable = true;
|
||||
} catch (e: any) {
|
||||
probeError = String(e?.message || e);
|
||||
}
|
||||
let database: Record<string, string | undefined> | null = null;
|
||||
try {
|
||||
const u = new URL(process.env.DATABASE_URL || '');
|
||||
database = { protocol: u.protocol.replace(':', ''), host: u.hostname, port: u.port || undefined, database: u.pathname.replace(/^\//, '') || undefined };
|
||||
} catch { /* not a URL */ }
|
||||
return { prismaClientHasEventAttachmentModel: true, databaseTableQueryable: tableQueryable, probeError, database };
|
||||
}
|
||||
|
||||
/** Imports attachments still listed in old JSON manifests on disk into the database. */
|
||||
export async function syncAttachmentManifests(opts: { dryRun: boolean; remove: boolean }) {
|
||||
const { syncManifestsToDb } = require('../utils/attachmentsSync') as { syncManifestsToDb(db: unknown, o: { dryRun: boolean; removeManifestAfterImport: boolean }): Promise<Record<string, unknown>> };
|
||||
return { ...opts, ...(await syncManifestsToDb(prisma, { dryRun: opts.dryRun, removeManifestAfterImport: opts.remove })) };
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Scanning sections and planned event costs (1.51.8). Moved from sectionController.js and
|
||||
* costController.js so v1 and /api/v2 share them.
|
||||
*/
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail } from './errors';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const cashup = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown>; ALL_METHODS: string[] };
|
||||
|
||||
const openEvent = async (eventId: string) => {
|
||||
try {
|
||||
await cashup.assertEventOpen(eventId);
|
||||
} catch (e: any) {
|
||||
fail(e.message === 'Event not found' ? 404 : 400, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Sections (which ticket types may come in at which door) ─────────────────
|
||||
|
||||
const SECTION_INCLUDE = { allowedOptions: { include: { eventOption: true } } } as const;
|
||||
|
||||
/** Sections, for one event or every event the caller may see (`scopeWhere` from campus scope). */
|
||||
export function listSections(eventId: string | undefined, scopeWhere: object) {
|
||||
const where = { AND: [eventId && eventId !== 'all' ? { eventId } : {}, scopeWhere] };
|
||||
return prisma.section.findMany({ where, include: { ...SECTION_INCLUDE, event: { select: { id: true, title: true } } }, orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
export async function createSection(input: { eventId?: string; name?: string; allowedOptionIds?: string[] }) {
|
||||
if (!input.eventId || !input.name) return fail(400, 'Event and section name are required');
|
||||
const event = await prisma.event.findUnique({ where: { id: input.eventId }, select: { id: true } });
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
const section = await prisma.section.create({ data: { id: uuidv4(), eventId: input.eventId, name: String(input.name), updatedAt: new Date() } });
|
||||
if (Array.isArray(input.allowedOptionIds) && input.allowedOptionIds.length > 0) {
|
||||
await prisma.sectionOption.createMany({
|
||||
data: input.allowedOptionIds.map(optionId => ({ id: uuidv4(), sectionId: section.id, eventOptionId: optionId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
return prisma.section.findUniqueOrThrow({ where: { id: section.id }, include: SECTION_INCLUDE });
|
||||
}
|
||||
|
||||
/** Renames a section and/or replaces its ticket types (when `allowedOptionIds` is sent). */
|
||||
export async function updateSection(sectionId: string, input: { name?: string; allowedOptionIds?: string[] }) {
|
||||
const section = await prisma.section.findUnique({ where: { id: sectionId } });
|
||||
if (!section) return fail(404, 'Section not found');
|
||||
await prisma.section.update({ where: { id: sectionId }, data: { name: input.name || section.name, updatedAt: new Date() } });
|
||||
if (Array.isArray(input.allowedOptionIds)) {
|
||||
await prisma.sectionOption.deleteMany({ where: { sectionId } });
|
||||
if (input.allowedOptionIds.length > 0) {
|
||||
await prisma.sectionOption.createMany({ data: input.allowedOptionIds.map(optionId => ({ id: uuidv4(), sectionId, eventOptionId: optionId })) });
|
||||
}
|
||||
}
|
||||
return prisma.section.findUniqueOrThrow({ where: { id: sectionId }, include: SECTION_INCLUDE });
|
||||
}
|
||||
|
||||
export async function deleteSection(sectionId: string) {
|
||||
const section = await prisma.section.findUnique({ where: { id: sectionId }, select: { id: true } });
|
||||
if (!section) return fail(404, 'Section not found');
|
||||
await prisma.section.delete({ where: { id: sectionId } });
|
||||
}
|
||||
|
||||
// ─── Planned costs (for cashup and the profit report) ────────────────────────
|
||||
|
||||
const COST_INCLUDE = { eventOption: { select: { id: true, name: true } } } as const;
|
||||
|
||||
function paidFromMethod(value: unknown): string | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null || value === '') return null;
|
||||
if (!cashup.ALL_METHODS.includes(String(value))) fail(400, "paidFromMethod must be one of 'cash', 'card', 'eft', 'other', or null");
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function listCosts(eventId: string) {
|
||||
return prisma.eventCost.findMany({ where: { eventId }, include: COST_INCLUDE, orderBy: { createdAt: 'asc' } });
|
||||
}
|
||||
|
||||
export type CostInput = { label?: string; costType?: string; amount?: number | string; eventOptionId?: string | null; notes?: string | null; paidFromMethod?: string | null };
|
||||
|
||||
/** A cost per event ("hall hire") or per ticket sold ("T-shirt per camper"). */
|
||||
export async function createCost(eventId: string, input: CostInput) {
|
||||
await openEvent(eventId);
|
||||
if (!input.label || !String(input.label).trim()) fail(400, 'Label is required');
|
||||
if (input.costType !== 'once_off' && input.costType !== 'per_item') fail(400, "costType must be 'once_off' or 'per_item'");
|
||||
const amount = parseFloat(String(input.amount));
|
||||
if (!(amount >= 0)) fail(400, 'Amount must be a non-negative number');
|
||||
if (input.costType === 'per_item') {
|
||||
if (!input.eventOptionId) fail(400, 'eventOptionId is required for per-item costs');
|
||||
const option = await prisma.eventOption.findUnique({ where: { id: input.eventOptionId! }, select: { eventId: true } });
|
||||
if (!option || option.eventId !== eventId) fail(404, 'Ticket type not found for this event');
|
||||
}
|
||||
return prisma.eventCost.create({
|
||||
data: {
|
||||
id: uuidv4(), eventId, label: String(input.label).trim(), costType: input.costType as 'once_off' | 'per_item', amount,
|
||||
eventOptionId: input.costType === 'per_item' ? input.eventOptionId! : null,
|
||||
paidFromMethod: (paidFromMethod(input.paidFromMethod) ?? null) as any, notes: input.notes || null,
|
||||
},
|
||||
include: COST_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCost(costId: string, input: CostInput) {
|
||||
const existing = await prisma.eventCost.findUnique({ where: { id: costId } });
|
||||
if (!existing) return fail(404, 'Cost not found');
|
||||
await openEvent(existing.eventId);
|
||||
const costType = input.costType !== undefined ? input.costType : existing.costType;
|
||||
if (costType !== 'once_off' && costType !== 'per_item') fail(400, "costType must be 'once_off' or 'per_item'");
|
||||
const eventOptionId = costType === 'per_item' ? (input.eventOptionId !== undefined ? input.eventOptionId : existing.eventOptionId) : null;
|
||||
if (costType === 'per_item') {
|
||||
if (!eventOptionId) fail(400, 'eventOptionId is required for per-item costs');
|
||||
const option = await prisma.eventOption.findUnique({ where: { id: eventOptionId! }, select: { eventId: true } });
|
||||
if (!option || option.eventId !== existing.eventId) fail(404, 'Ticket type not found for this event');
|
||||
}
|
||||
let amount = existing.amount;
|
||||
if (input.amount !== undefined) {
|
||||
amount = parseFloat(String(input.amount));
|
||||
if (!(amount >= 0)) fail(400, 'Amount must be a non-negative number');
|
||||
}
|
||||
const paid = paidFromMethod(input.paidFromMethod);
|
||||
return prisma.eventCost.update({
|
||||
where: { id: costId },
|
||||
data: {
|
||||
label: input.label !== undefined ? String(input.label).trim() : existing.label,
|
||||
costType: costType as 'once_off' | 'per_item',
|
||||
amount,
|
||||
eventOptionId,
|
||||
paidFromMethod: (paid !== undefined ? paid : existing.paidFromMethod) as any,
|
||||
notes: input.notes !== undefined ? input.notes : existing.notes,
|
||||
},
|
||||
include: COST_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCost(costId: string) {
|
||||
const existing = await prisma.eventCost.findUnique({ where: { id: costId }, select: { eventId: true } });
|
||||
if (!existing) return fail(404, 'Cost not found');
|
||||
await openEvent(existing.eventId);
|
||||
await prisma.eventCost.delete({ where: { id: costId } });
|
||||
}
|
||||
@@ -13,7 +13,31 @@ type Successor = { method: string; v1: RegExp; v2: (m: RegExpMatchArray) => stri
|
||||
|
||||
const SUCCESSORS: Successor[] = [
|
||||
{ method: 'GET', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
|
||||
{ method: 'GET', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
|
||||
// v1's event detail is the full record (v2's /events/{id} is the public summary).
|
||||
{ method: 'GET', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/full` },
|
||||
// 1.51.8: the rest of the events area
|
||||
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/ics$/, v2: m => `/api/v2/events/${m[1]}/calendar.ics` },
|
||||
{ method: 'GET', v1: /^\/api\/events\/by-alias\/([^/]+)$/, v2: m => `/api/v2/events/by-alias/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/options$/, v2: m => `/api/v2/events/${m[1]}/ticket-options` },
|
||||
{ method: 'PUT', v1: /^\/api\/events\/options\/([^/]+)$/, v2: m => `/api/v2/ticket-options/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/events\/options\/([^/]+)$/, v2: m => `/api/v2/ticket-options/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/events\/options\/([^/]+)\/variants$/, v2: m => `/api/v2/ticket-options/${m[1]}/variants` },
|
||||
{ method: 'PUT', v1: /^\/api\/events\/variants\/([^/]+)$/, v2: m => `/api/v2/variants/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/events\/variants\/([^/]+)$/, v2: m => `/api/v2/variants/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/events\/attachments\/status$/, v2: () => '/api/v2/attachments/status' },
|
||||
{ method: 'POST', v1: /^\/api\/events\/attachments\/sync$/, v2: () => '/api/v2/attachments/sync' },
|
||||
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/attachments$/, v2: m => `/api/v2/events/${m[1]}/attachments` },
|
||||
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/attachments$/, v2: m => `/api/v2/events/${m[1]}/attachments` },
|
||||
{ method: 'DELETE', v1: /^\/api\/events\/([^/]+)\/attachments\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/attachments/${m[2]}` },
|
||||
{ method: 'GET', v1: /^\/api\/sections\/?$/, v2: () => '/api/v2/sections' },
|
||||
{ method: 'POST', v1: /^\/api\/sections\/?$/, v2: () => '/api/v2/sections' },
|
||||
{ method: 'PUT', v1: /^\/api\/sections\/([^/]+)$/, v2: m => `/api/v2/sections/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/sections\/([^/]+)$/, v2: m => `/api/v2/sections/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/costs$/, v2: m => `/api/v2/events/${m[1]}/costs` },
|
||||
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/costs$/, v2: m => `/api/v2/events/${m[1]}/costs` },
|
||||
{ method: 'PUT', v1: /^\/api\/costs\/([^/]+)$/, v2: m => `/api/v2/costs/${m[1]}` },
|
||||
{ method: 'DELETE', v1: /^\/api\/costs\/([^/]+)$/, v2: m => `/api/v2/costs/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/uploads\/(event-image|logo|favicon)$/, v2: m => `/api/v2/uploads/${m[1]}` },
|
||||
// 1.51.7: setting events up
|
||||
{ method: 'GET', v1: /^\/api\/events\/all$/, v2: () => '/api/v2/events/manage' },
|
||||
{ method: 'POST', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
|
||||
|
||||
@@ -7,6 +7,8 @@ import auth from '../middleware/authMiddleware';
|
||||
// Resources: importing one registers its routes.
|
||||
// eventsManage first: /events/manage must match before /events/:id.
|
||||
import './resources/eventsManage';
|
||||
import './resources/eventDetails';
|
||||
import './resources/setup';
|
||||
import './resources/events';
|
||||
import './resources/me';
|
||||
import './resources/registrations';
|
||||
|
||||
@@ -71,9 +71,13 @@ export function buildOpenApi(opts: { version: string; serverUrl?: string; list?:
|
||||
operationId: `${def.method}${path.replace(/[{}]/g, '').split('/').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')}`,
|
||||
...(secured ? { security: [{ bearerAuth: [] }] } : def.access === 'optional' ? { security: [{}, { bearerAuth: [] }] } : {}),
|
||||
parameters: parameters(def),
|
||||
...(def.body ? { requestBody: { required: true, content: { 'application/json': { schema: toSchema(def.body, 'input') } } } } : {}),
|
||||
...(def.upload
|
||||
? { requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: [def.upload.field], properties: { [def.upload.field]: { type: 'string', format: 'binary', description: def.upload.description } } } } } } }
|
||||
: def.body ? { requestBody: { required: true, content: { 'application/json': { schema: toSchema(def.body, 'input') } } } } : {}),
|
||||
responses: {
|
||||
[String(def.status ?? 200)]: { description: 'OK', content: { 'application/json': { schema: toSchema(def.response, 'output') } } },
|
||||
[String(def.status ?? 200)]: def.file
|
||||
? { description: 'The file', content: { [def.file.contentType]: { schema: { type: 'string' } } } }
|
||||
: { description: 'OK', content: { 'application/json': { schema: toSchema(def.response, 'output') } } },
|
||||
...(def.params || def.query || def.body ? { 400: errorResponse('Validation failed (VALIDATION_FAILED)') } : {}),
|
||||
...(secured ? { 401: errorResponse('Not signed in (UNAUTHENTICATED)') } : {}),
|
||||
...(secured && def.access !== 'user' ? { 403: errorResponse('Not allowed for this role (FORBIDDEN)') } : {}),
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { z } from 'zod';
|
||||
import type { Request } from 'express';
|
||||
import { defineRoute, fileResponse, type ApiUser } from '../route';
|
||||
import { ApiError } from '../errors';
|
||||
import { single } from '../paging';
|
||||
import { absoluteUrl, id, idParams, isoDate } from '../schemas';
|
||||
import * as details from '../../services/eventDetails';
|
||||
import { managedEvent } from './eventsManage';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- stored records are passed through */
|
||||
const campusScope = require('../../utils/campusScope') as {
|
||||
scopeFor(req: unknown): Promise<object | null>;
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
|
||||
};
|
||||
// The same multer step as v1 (field "file"; PDFs, Office files, text, CSV, ZIP; 20 MB).
|
||||
const { receiveAttachment } = require('../../controllers/eventController') as { receiveAttachment: any };
|
||||
|
||||
const urlOf = (req: Request) => (u: string | null | undefined) => absoluteUrl(req, u);
|
||||
const plain = <T>(v: T): any => JSON.parse(JSON.stringify(v));
|
||||
const viewerOf = async (req: Request, user: ApiUser | null) => ({ staff: !!user && ['admin', 'supervisor', 'staff'].includes(user.role), scope: await campusScope.scopeFor(req) });
|
||||
|
||||
// ─── The event in full, calendar file, short link ──────────────────────────────
|
||||
|
||||
export const full = defineRoute({
|
||||
method: 'get',
|
||||
path: '/events/:id/full',
|
||||
tag: 'Events',
|
||||
summary: 'One event in full',
|
||||
description: [
|
||||
'What the registration page and the set-up screens need: ticket options with what\'s left (per variant, and per session for "pick sessions" tickets), early-bird tiers, sessions with bookings, the attendee form, attachments, bundle deals on sale and the next event in its series.',
|
||||
'Staff (of a campus they work with) also get inactive and not-yet-live events, who created it, who gets its emails, and the kids/volunteer tickets. Everyone else gets NOT_FOUND for events that aren\'t public.',
|
||||
].join('\n\n'),
|
||||
access: 'optional',
|
||||
params: idParams,
|
||||
response: single(managedEvent.extend({
|
||||
form: z.looseObject({ id, isRequired: z.boolean(), fields: z.array(z.looseObject({ id, type: z.string(), label: z.string() })) }).nullable(),
|
||||
attachments: z.array(z.looseObject({ id, originalName: z.string(), url: z.string().nullable() })),
|
||||
})),
|
||||
async handler({ params, user, req }) {
|
||||
return { data: plain(await details.getEventDetail(params.id, await viewerOf(req, user), urlOf(req))) };
|
||||
},
|
||||
});
|
||||
|
||||
export const calendar = defineRoute({
|
||||
method: 'get',
|
||||
path: '/events/:id/calendar.ics',
|
||||
tag: 'Events',
|
||||
summary: 'The event as a calendar file',
|
||||
description: 'An .ics file (each session for a multi-day event). Same visibility as the event.',
|
||||
access: 'optional',
|
||||
file: { contentType: 'text/calendar' },
|
||||
params: idParams,
|
||||
response: fileResponse,
|
||||
async handler({ params, user, req }) {
|
||||
const { filename, ics } = await details.eventCalendar(params.id, await viewerOf(req, user));
|
||||
return { data: { contentType: 'text/calendar; charset=utf-8', filename, body: ics } };
|
||||
},
|
||||
});
|
||||
|
||||
export const byAlias = defineRoute({
|
||||
method: 'get',
|
||||
path: '/events/by-alias/:alias',
|
||||
tag: 'Events',
|
||||
summary: 'An event by its short link',
|
||||
description: 'The part after the site address (`/camp2026`), any letter case. Active events only.',
|
||||
access: 'public',
|
||||
params: z.object({ alias: z.string().min(1).max(100).describe('The short link, e.g. camp2026') }),
|
||||
response: single(z.object({
|
||||
id, title: z.string(), description: z.string().nullable(), startDate: isoDate, endDate: isoDate, registrationDeadline: isoDate.nullable(),
|
||||
goLiveAt: isoDate.nullable(), price: z.number(), picture: z.string().nullable(), redirectUrl: z.string().nullable(),
|
||||
isLive: z.boolean().describe('Not over yet'),
|
||||
})),
|
||||
async handler({ params, req }) {
|
||||
const ev = plain(await details.eventByAlias(params.alias));
|
||||
return { data: { ...ev, picture: absoluteUrl(req, ev.picture) } };
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Ticket options and variants ─────────────────────────────────────────────
|
||||
|
||||
const variant = z.looseObject({ id, name: z.string(), price: z.number().nullable(), stockLimit: z.number().int(), staffPrice: z.number().nullable(), order: z.number().int() });
|
||||
const tier = z.looseObject({ id, deadline: isoDate, price: z.number(), stockLimit: z.number().int(), variantId: id.nullable() });
|
||||
const ticketOption = z.looseObject({
|
||||
id, eventId: id, name: z.string(), price: z.number(), isMainTicket: z.boolean(), stockLimit: z.number().int(), staffPrice: z.number().nullable(),
|
||||
sessionMode: z.enum(['all', 'pick']), earlyBirdTiers: z.array(tier).optional(), variants: z.array(variant).optional(),
|
||||
});
|
||||
const money = z.union([z.number(), z.string().max(20)]);
|
||||
const optionBody = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
price: money,
|
||||
isMainTicket: z.boolean().optional(),
|
||||
stockLimit: z.union([z.number().int().min(0), z.string().max(10)]).optional().describe('0 = no limit'),
|
||||
staffPrice: money.nullable().optional().describe('What staff pay (empty = the normal price)'),
|
||||
sessionMode: z.enum(['all', 'pick']).optional().describe('"pick" = buyers choose sessions (multi-day events)'),
|
||||
});
|
||||
const variantBody = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
price: money.nullable().optional().describe('Empty = the option\'s price'),
|
||||
stockLimit: z.union([z.number().int().min(0), z.string().max(10)]).optional(),
|
||||
staffPrice: money.nullable().optional(),
|
||||
order: z.union([z.number().int(), z.string().max(10)]).optional(),
|
||||
});
|
||||
|
||||
export const createOption = defineRoute({
|
||||
method: 'post',
|
||||
path: '/events/:id/ticket-options',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Add a ticket option',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(manage)',
|
||||
middleware: [campusScope.scopeEvent({ need: 'manage' })],
|
||||
status: 201,
|
||||
params: idParams,
|
||||
body: optionBody,
|
||||
response: single(ticketOption),
|
||||
async handler({ params, body }) {
|
||||
return { data: plain(await details.createOption(params.id, body)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const updateOption = defineRoute({
|
||||
method: 'patch',
|
||||
path: '/ticket-options/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Change a ticket option',
|
||||
description: 'Only what\'s sent changes. `earlyBirdTiers` and `variants`, when sent, are the full lists: kept by id, added without one, and ones left out removed when nothing was bought under them.',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(eventOption, manage)',
|
||||
middleware: [campusScope.scopeEvent({ resolve: 'eventOption', need: 'manage' })],
|
||||
params: idParams,
|
||||
body: optionBody.partial().extend({
|
||||
earlyBirdTiers: z.array(z.looseObject({ id: id.optional(), deadline: z.string().max(40), price: money, stockLimit: z.union([z.number(), z.string()]).optional(), variantId: id.nullable().optional() })).max(50).optional(),
|
||||
variants: z.array(variantBody.extend({ id: id.optional() })).max(100).optional(),
|
||||
}),
|
||||
response: single(ticketOption),
|
||||
async handler({ params, body }) {
|
||||
return { data: plain(await details.updateOption(params.id, body)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteOption = defineRoute({
|
||||
method: 'delete',
|
||||
path: '/ticket-options/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Remove a ticket option',
|
||||
description: 'Admins only. Not one anybody has bought, and never the event\'s only main ticket.',
|
||||
access: 'admin',
|
||||
params: idParams,
|
||||
response: single(z.object({ id, deleted: z.literal(true) })),
|
||||
async handler({ params }) {
|
||||
await details.deleteOption(params.id);
|
||||
return { data: { id: params.id, deleted: true as const } };
|
||||
},
|
||||
});
|
||||
|
||||
export const createVariant = defineRoute({
|
||||
method: 'post',
|
||||
path: '/ticket-options/:id/variants',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Add a variant (e.g. a T-shirt size)',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(eventOption, manage)',
|
||||
middleware: [campusScope.scopeEvent({ resolve: 'eventOption', need: 'manage' })],
|
||||
status: 201,
|
||||
params: idParams,
|
||||
body: variantBody,
|
||||
response: single(variant),
|
||||
async handler({ params, body }) {
|
||||
return { data: plain(await details.createVariant(params.id, body)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const updateVariant = defineRoute({
|
||||
method: 'patch',
|
||||
path: '/variants/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Change a variant',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(optionVariant, manage)',
|
||||
middleware: [campusScope.scopeEvent({ resolve: 'optionVariant', need: 'manage' })],
|
||||
params: idParams,
|
||||
body: variantBody.partial(),
|
||||
response: single(variant),
|
||||
async handler({ params, body }) {
|
||||
return { data: plain(await details.updateVariant(params.id, body)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteVariant = defineRoute({
|
||||
method: 'delete',
|
||||
path: '/variants/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Remove a variant',
|
||||
description: 'Admins only, and not one anybody has bought.',
|
||||
access: 'admin',
|
||||
params: idParams,
|
||||
response: single(z.object({ id, deleted: z.literal(true) })),
|
||||
async handler({ params }) {
|
||||
await details.deleteVariant(params.id);
|
||||
return { data: { id: params.id, deleted: true as const } };
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Attachments ─────────────────────────────────────────────────────────────
|
||||
|
||||
const attachment = z.object({ id, eventId: id, originalName: z.string(), filename: z.string(), mimeType: z.string(), size: z.number().int(), url: z.string().nullable(), createdAt: isoDate });
|
||||
|
||||
export const listAttachments = defineRoute({
|
||||
method: 'get',
|
||||
path: '/events/:id/attachments',
|
||||
tag: 'Events',
|
||||
summary: 'An event\'s attachments',
|
||||
description: 'Files for attendees (programmes, packing lists…), newest first.',
|
||||
access: 'public',
|
||||
params: idParams,
|
||||
response: z.object({ data: z.array(attachment) }),
|
||||
async handler({ params, req }) {
|
||||
return { data: plain(await details.listAttachments(params.id, urlOf(req))) };
|
||||
},
|
||||
});
|
||||
|
||||
export const uploadAttachment = defineRoute({
|
||||
method: 'post',
|
||||
path: '/events/:id/attachments',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Attach a file to an event',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(manage)',
|
||||
middleware: [campusScope.scopeEvent({ need: 'manage' }), receiveAttachment],
|
||||
upload: { field: 'file', description: 'PDF, Word, Excel, PowerPoint, text, CSV or ZIP, up to 20 MB' },
|
||||
status: 201,
|
||||
params: idParams,
|
||||
response: single(attachment),
|
||||
async handler({ params, req }) {
|
||||
const r = req as Request & { multerError?: Error; file?: { originalname: string; filename: string; mimetype: string; size: number } };
|
||||
if (r.multerError) throw new ApiError('VALIDATION_FAILED', r.multerError.message, { file: r.multerError.message });
|
||||
if (!r.file) throw new ApiError('VALIDATION_FAILED', 'No file uploaded', { file: 'Choose a file' });
|
||||
return { data: plain(await details.recordAttachment(params.id, r.file, urlOf(req))) };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteAttachment = defineRoute({
|
||||
method: 'delete',
|
||||
path: '/events/:id/attachments/:attachmentId',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Remove an attachment',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(manage)',
|
||||
middleware: [campusScope.scopeEvent({ need: 'manage' })],
|
||||
params: z.object({ id, attachmentId: id }),
|
||||
response: single(z.object({ id, deleted: z.literal(true) })),
|
||||
async handler({ params }) {
|
||||
await details.deleteAttachment(params.id, params.attachmentId);
|
||||
return { data: { id: params.attachmentId, deleted: true as const } };
|
||||
},
|
||||
});
|
||||
|
||||
export const attachmentsStatus = defineRoute({
|
||||
method: 'get',
|
||||
path: '/attachments/status',
|
||||
tag: 'Admin',
|
||||
summary: 'Attachments diagnostics',
|
||||
access: 'admin',
|
||||
response: single(z.looseObject({ databaseTableQueryable: z.boolean(), probeError: z.string().nullable() })),
|
||||
async handler() {
|
||||
return { data: plain(await details.attachmentsStatus()) };
|
||||
},
|
||||
});
|
||||
|
||||
export const attachmentsSync = defineRoute({
|
||||
method: 'post',
|
||||
path: '/attachments/sync',
|
||||
tag: 'Admin',
|
||||
summary: 'Import attachments from old manifests',
|
||||
description: 'Attachments recorded in JSON files on disk by very old versions go into the database. `dryRun` only reports; `remove` deletes each manifest once imported.',
|
||||
access: 'admin',
|
||||
body: z.object({ dryRun: z.boolean().default(false), remove: z.boolean().default(false) }),
|
||||
response: single(z.looseObject({ dryRun: z.boolean(), remove: z.boolean() })),
|
||||
async handler({ body }) {
|
||||
return { data: plain(await details.syncAttachmentManifests(body)) };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { z } from 'zod';
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import { defineRoute } from '../route';
|
||||
import { ApiError } from '../errors';
|
||||
import { single } from '../paging';
|
||||
import { absoluteUrl, id, idParams, isoDate } from '../schemas';
|
||||
import * as setup from '../../services/setup';
|
||||
import { prisma } from '../legacy';
|
||||
import auth from '../../middleware/authMiddleware';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- multer and the scope middleware are JS */
|
||||
const campusScope = require('../../utils/campusScope') as {
|
||||
listWhere(req: unknown, path?: string): Promise<object>;
|
||||
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
|
||||
};
|
||||
const uploads = require('../../controllers/uploadController') as { upload: any; uploadLogo: any; uploadFavicon: any };
|
||||
const plain = <T>(v: T): any => JSON.parse(JSON.stringify(v));
|
||||
|
||||
// ─── Sections ────────────────────────────────────────────────────────────────
|
||||
|
||||
const section = z.looseObject({
|
||||
id, eventId: id, name: z.string(),
|
||||
allowedOptions: z.array(z.looseObject({ eventOptionId: id, eventOption: z.looseObject({ id, name: z.string() }) })),
|
||||
});
|
||||
|
||||
export const listSections = defineRoute({
|
||||
method: 'get',
|
||||
path: '/sections',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Scanning sections',
|
||||
description: 'Which ticket types may come in at which door, for one event or every event you can see.',
|
||||
access: 'staff',
|
||||
campusScope: 'scopeList (listWhere)',
|
||||
query: z.object({ eventId: id.optional() }),
|
||||
response: z.object({ data: z.array(section.extend({ event: z.object({ id, title: z.string() }) })) }),
|
||||
async handler({ query, req }) {
|
||||
return { data: plain(await setup.listSections(query.eventId, await campusScope.listWhere(req, 'event'))) };
|
||||
},
|
||||
});
|
||||
|
||||
export const createSection = defineRoute({
|
||||
method: 'post',
|
||||
path: '/sections',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Add a scanning section',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(body.eventId, manage)',
|
||||
middleware: [campusScope.scopeEvent({ from: req => req.body?.eventId, need: 'manage' })],
|
||||
status: 201,
|
||||
body: z.object({ eventId: id, name: z.string().trim().min(1).max(120), allowedOptionIds: z.array(id).max(100).optional() }),
|
||||
response: single(section),
|
||||
async handler({ body }) {
|
||||
return { data: plain(await setup.createSection(body)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const updateSection = defineRoute({
|
||||
method: 'patch',
|
||||
path: '/sections/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Change a scanning section',
|
||||
description: '`allowedOptionIds`, when sent, replaces its ticket types.',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(section, manage)',
|
||||
middleware: [campusScope.scopeEvent({ resolve: 'section', need: 'manage' })],
|
||||
params: idParams,
|
||||
body: z.object({ name: z.string().trim().min(1).max(120).optional(), allowedOptionIds: z.array(id).max(100).optional() }),
|
||||
response: single(section),
|
||||
async handler({ params, body }) {
|
||||
return { data: plain(await setup.updateSection(params.id, body)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteSection = defineRoute({
|
||||
method: 'delete',
|
||||
path: '/sections/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Remove a scanning section',
|
||||
access: 'admin',
|
||||
params: idParams,
|
||||
response: single(z.object({ id, deleted: z.literal(true) })),
|
||||
async handler({ params }) {
|
||||
await setup.deleteSection(params.id);
|
||||
return { data: { id: params.id, deleted: true as const } };
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Planned costs ───────────────────────────────────────────────────────────
|
||||
|
||||
const cost = z.object({
|
||||
id, eventId: id, label: z.string(), costType: z.enum(['once_off', 'per_item']), amount: z.number(),
|
||||
eventOptionId: id.nullable(), eventOption: z.object({ id, name: z.string() }).nullable(),
|
||||
paidFromMethod: z.string().nullable().describe('How it was paid out of the takings (cash, card, eft, other)'),
|
||||
notes: z.string().nullable(), createdAt: isoDate, updatedAt: isoDate.optional(),
|
||||
});
|
||||
const costBody = z.object({
|
||||
label: z.string().trim().min(1).max(200),
|
||||
costType: z.enum(['once_off', 'per_item']).describe('per_item = per ticket sold of `eventOptionId`'),
|
||||
amount: z.union([z.number().min(0), z.string().max(20)]),
|
||||
eventOptionId: id.nullable().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
paidFromMethod: z.enum(['cash', 'card', 'eft', 'other']).nullable().optional(),
|
||||
});
|
||||
|
||||
export const listCosts = defineRoute({
|
||||
method: 'get',
|
||||
path: '/events/:id/costs',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'An event\'s planned costs',
|
||||
access: 'supervisor',
|
||||
campusScope: 'scopeEvent(manage)',
|
||||
middleware: [campusScope.scopeEvent({ need: 'manage' })],
|
||||
params: idParams,
|
||||
response: z.object({ data: z.array(cost) }),
|
||||
async handler({ params }) {
|
||||
return { data: plain(await setup.listCosts(params.id)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const createCost = defineRoute({
|
||||
method: 'post',
|
||||
path: '/events/:id/costs',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Add a planned cost',
|
||||
description: 'Admins. Not on a cashed-up event.',
|
||||
access: 'admin',
|
||||
status: 201,
|
||||
params: idParams,
|
||||
body: costBody,
|
||||
response: single(cost),
|
||||
async handler({ params, body }) {
|
||||
return { data: plain(await setup.createCost(params.id, body)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const updateCost = defineRoute({
|
||||
method: 'patch',
|
||||
path: '/costs/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Change a planned cost',
|
||||
access: 'admin',
|
||||
params: idParams,
|
||||
body: costBody.partial(),
|
||||
response: single(cost),
|
||||
async handler({ params, body }) {
|
||||
return { data: plain(await setup.updateCost(params.id, body)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCost = defineRoute({
|
||||
method: 'delete',
|
||||
path: '/costs/:id',
|
||||
tag: 'Events (staff)',
|
||||
summary: 'Remove a planned cost',
|
||||
access: 'admin',
|
||||
params: idParams,
|
||||
response: single(z.object({ id, deleted: z.literal(true) })),
|
||||
async handler({ params }) {
|
||||
await setup.deleteCost(params.id);
|
||||
return { data: { id: params.id, deleted: true as const } };
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Image uploads ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Runs a multer step for field "image", keeping any error on the request. */
|
||||
const receive = (m: any): RequestHandler => (req, res, next) => m.single('image')(req, res, (e: Error | undefined) => {
|
||||
if (e) (req as Request & { multerError?: Error }).multerError = e;
|
||||
next();
|
||||
});
|
||||
|
||||
/** Branding images: admins — or anyone during first-time setup, before any account exists. */
|
||||
const brandingAccess: RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
if (await prisma.user.count() === 0) return next();
|
||||
} catch { /* fall through to the admin check */ }
|
||||
auth.protect(req, res, (e?: unknown) => (e ? next(e) : auth.admin(req, res, next)));
|
||||
};
|
||||
|
||||
const uploaded = (folder: string) => async ({ req }: { req: Request }) => {
|
||||
const r = req as Request & { multerError?: Error; file?: { filename: string } };
|
||||
if (r.multerError) throw new ApiError('VALIDATION_FAILED', `Upload failed: ${r.multerError.message}`, { image: r.multerError.message });
|
||||
if (!r.file) throw new ApiError('VALIDATION_FAILED', 'No file uploaded', { image: 'Choose an image' });
|
||||
const path = `/uploads/${folder}/${r.file.filename}`;
|
||||
return { data: { path, url: absoluteUrl(req, path)! } };
|
||||
};
|
||||
const uploadResult = single(z.object({ path: z.string().describe('What to save on the event or setting'), url: z.string().describe('The same, as a full address') }));
|
||||
const IMAGE = 'A JPEG, PNG, GIF or WebP image';
|
||||
const LOGO = 'A JPEG, PNG, WebP or SVG image';
|
||||
|
||||
export const uploadEventImage = defineRoute({
|
||||
method: 'post',
|
||||
path: '/uploads/event-image',
|
||||
tag: 'Uploads',
|
||||
summary: 'Upload an event picture',
|
||||
description: 'Returns the path to save as the event\'s `picture`.',
|
||||
access: 'supervisor',
|
||||
campusScope: 'campusIndependent (only stores the file; the event is checked when it is saved)',
|
||||
middleware: [receive(uploads.upload)],
|
||||
upload: { field: 'image', description: IMAGE },
|
||||
response: uploadResult,
|
||||
handler: uploaded('events'),
|
||||
});
|
||||
|
||||
export const uploadLogo = defineRoute({
|
||||
method: 'post',
|
||||
path: '/uploads/logo',
|
||||
tag: 'Uploads',
|
||||
summary: 'Upload the site logo',
|
||||
description: 'Admins — or anyone during first-time setup, before any account exists.',
|
||||
access: 'public',
|
||||
middleware: [brandingAccess, receive(uploads.uploadLogo)],
|
||||
upload: { field: 'image', description: LOGO },
|
||||
response: uploadResult,
|
||||
handler: uploaded('branding'),
|
||||
});
|
||||
|
||||
export const uploadFavicon = defineRoute({
|
||||
method: 'post',
|
||||
path: '/uploads/favicon',
|
||||
tag: 'Uploads',
|
||||
summary: 'Upload the site icon (favicon)',
|
||||
description: 'Admins — or anyone during first-time setup, before any account exists.',
|
||||
access: 'public',
|
||||
middleware: [brandingAccess, receive(uploads.uploadFavicon)],
|
||||
upload: { field: 'image', description: 'An ICO, PNG or SVG image' },
|
||||
response: uploadResult,
|
||||
handler: uploaded('branding'),
|
||||
});
|
||||
@@ -50,6 +50,13 @@ export type RouteDef<P extends Schema | undefined, Q extends Schema | undefined,
|
||||
status?: number;
|
||||
/** Runs after the access check, e.g. a rate limiter. */
|
||||
middleware?: RequestHandler[];
|
||||
/** A multipart upload (the file in `field`, saved by a multer step in `middleware`). */
|
||||
upload?: { field: string; description: string };
|
||||
/**
|
||||
* The answer is a file, not JSON: the handler returns `{ data: { contentType, filename, body } }`
|
||||
* and `response` is `fileResponse`. `contentType` here is what the docs show.
|
||||
*/
|
||||
file?: { contentType: string };
|
||||
handler: (ctx: Ctx<Infer<P>, Infer<Q>, Infer<B>>) => Promise<z.input<R>> | z.input<R>;
|
||||
};
|
||||
|
||||
@@ -97,6 +104,13 @@ export function mount(router: Router, list: AnyRoute[] = routes): void {
|
||||
console.error(`[api/v2] ${def.method.toUpperCase()} ${def.path}: response doesn't match its schema`, checked.error.issues);
|
||||
throw new ApiError('INTERNAL', 'Something went wrong on the server.');
|
||||
}
|
||||
if (def.file) {
|
||||
const f = (checked.data as { data: { contentType: string; filename: string; body: string } }).data;
|
||||
res.setHeader('Content-Type', f.contentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${f.filename.replace(/"/g, '')}"`);
|
||||
res.status(def.status ?? 200).send(f.body);
|
||||
return;
|
||||
}
|
||||
res.status(def.status ?? 200).json(checked.data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -105,3 +119,6 @@ export function mount(router: Router, list: AnyRoute[] = routes): void {
|
||||
router[def.method](def.path, ...ACCESS[def.access], ...(def.middleware ?? []), run);
|
||||
}
|
||||
}
|
||||
|
||||
/** The `response` of a `file` route: what the handler hands back to be sent as a download. */
|
||||
export const fileResponse = z.object({ data: z.object({ contentType: z.string(), filename: z.string(), body: z.string() }) });
|
||||
|
||||
+125
-1
@@ -49,10 +49,32 @@ jest.mock('../src/utils/campusScope', () => ({
|
||||
// Route-level checks: refuse like v1 does (v1 shape — the v2 wrapper isn't in this test app).
|
||||
scopeEvent: () => (_req: unknown, res: { status(n: number): { json(b: unknown): void } }, next: () => void) => (mockScope.blockEvent ? res.status(404).json({ message: 'Not found' }) : next()),
|
||||
scopeBody: () => (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
listWhere: async () => ({}),
|
||||
}));
|
||||
const mockEvents = { listManagedEvents: jest.fn(), createEvent: jest.fn(), updateEvent: jest.fn(), deactivateEvent: jest.fn(), getNotifyRecipients: jest.fn(), setNotifyRecipients: jest.fn(), copySetupFrom: jest.fn() };
|
||||
jest.mock('../src/services/events', () => Object.fromEntries(Object.keys(mockEvents).map(k => [k, (...a: unknown[]) => (mockEvents as Record<string, jest.Mock>)[k](...a)])));
|
||||
|
||||
const mockDetails = {
|
||||
getEventDetail: jest.fn(), eventCalendar: jest.fn(), eventByAlias: jest.fn(), createOption: jest.fn(), updateOption: jest.fn(), deleteOption: jest.fn(),
|
||||
createVariant: jest.fn(), updateVariant: jest.fn(), deleteVariant: jest.fn(), listAttachments: jest.fn(), recordAttachment: jest.fn(), deleteAttachment: jest.fn(),
|
||||
attachmentsStatus: jest.fn(), syncAttachmentManifests: jest.fn(),
|
||||
};
|
||||
jest.mock('../src/services/eventDetails', () => Object.fromEntries(Object.keys(mockDetails).map(k => [k, (...a: unknown[]) => (mockDetails as Record<string, jest.Mock>)[k](...a)])));
|
||||
// The upload step: pretend multer saved the file (or refused it).
|
||||
const mockUpload = { file: null as object | null, error: null as Error | null };
|
||||
jest.mock('../src/controllers/eventController', () => ({
|
||||
receiveAttachment: (req: { file?: object; multerError?: Error }, _res: unknown, next: () => void) => { if (mockUpload.error) req.multerError = mockUpload.error; else if (mockUpload.file) req.file = mockUpload.file; next(); },
|
||||
}));
|
||||
|
||||
const mockSetup = { listSections: jest.fn(), createSection: jest.fn(), updateSection: jest.fn(), deleteSection: jest.fn(), listCosts: jest.fn(), createCost: jest.fn(), updateCost: jest.fn(), deleteCost: jest.fn() };
|
||||
jest.mock('../src/services/setup', () => Object.fromEntries(Object.keys(mockSetup).map(k => [k, (...a: unknown[]) => (mockSetup as Record<string, jest.Mock>)[k](...a)])));
|
||||
// Image uploads: a stand-in for multer that "saves" mockImage.
|
||||
const mockImage = { file: null as object | null };
|
||||
jest.mock('../src/controllers/uploadController', () => {
|
||||
const m = { single: () => (req: { file?: object }, _res: unknown, cb: () => void) => { if (mockImage.file) req.file = mockImage.file; cb(); } };
|
||||
return { upload: m, uploadLogo: m, uploadFavicon: m };
|
||||
});
|
||||
|
||||
import { v2Router } from '../src/v2';
|
||||
import { ServiceError } from '../src/services/errors';
|
||||
import { routes } from '../src/v2/route';
|
||||
@@ -347,7 +369,9 @@ describe('v1 deprecation headers', () => {
|
||||
const { successorOf } = require('../src/v2/deprecation');
|
||||
test('v1 endpoints with a v2 replacement point at it; the rest say nothing', () => {
|
||||
expect(successorOf('GET', '/api/events')).toBe('/api/v2/events');
|
||||
expect(successorOf('GET', '/api/events/e1')).toBe('/api/v2/events/e1');
|
||||
expect(successorOf('GET', '/api/events/e1')).toBe('/api/v2/events/e1/full');
|
||||
expect(successorOf('PUT', '/api/events/options/o1')).toBe('/api/v2/ticket-options/o1');
|
||||
expect(successorOf('DELETE', '/api/events/e1/attachments/a1')).toBe('/api/v2/events/e1/attachments/a1');
|
||||
expect(successorOf('GET', '/api/events/all')).toBe('/api/v2/events/manage');
|
||||
expect(successorOf('PUT', '/api/events/e1')).toBe('/api/v2/events/e1');
|
||||
expect(successorOf('DELETE', '/api/registrations/r1')).toBe('/api/v2/registrations/r1/cancel');
|
||||
@@ -430,3 +454,103 @@ describe('events (staff)', () => {
|
||||
expect((await request(app).delete('/api/v2/events/e1')).body.data).toEqual({ id: 'e1', isActive: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('one event in full, options, attachments', () => {
|
||||
const detail = {
|
||||
id: 'e1', title: 'Camp', description: null, startDate: '2026-12-04T16:00:00.000Z', endDate: '2026-12-06T12:00:00.000Z', isActive: true, isHidden: false,
|
||||
requiresRegistration: true, scheduleType: 'single', attendanceMode: 'whole', cashupStatus: 'open', campusId: null, seriesId: null, picture: null,
|
||||
eventOptions: [], sessions: [], form: null, attachments: [], bundles: [],
|
||||
};
|
||||
afterEach(() => { mockUpload.file = null; mockUpload.error = null; });
|
||||
|
||||
test('/events/{id}/full asks for the staff view only for staff', async () => {
|
||||
mockDetails.getEventDetail.mockResolvedValue(detail);
|
||||
await request(app).get('/api/v2/events/e1/full');
|
||||
expect(mockDetails.getEventDetail.mock.calls[0][1]).toEqual({ staff: false, scope: null });
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
const res = await request(app).get('/api/v2/events/e1/full');
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockDetails.getEventDetail.mock.calls[1][1]).toEqual({ staff: true, scope: null });
|
||||
});
|
||||
|
||||
test('the calendar file is a download, not JSON', async () => {
|
||||
mockDetails.eventCalendar.mockResolvedValue({ filename: 'Camp.ics', ics: 'BEGIN:VCALENDAR\nEND:VCALENDAR' });
|
||||
const res = await request(app).get('/api/v2/events/e1/calendar.ics');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/calendar');
|
||||
expect(res.headers['content-disposition']).toBe('attachment; filename="Camp.ics"');
|
||||
expect(res.text).toContain('BEGIN:VCALENDAR');
|
||||
});
|
||||
|
||||
test('ticket options: supervisors add and change them, only admins remove them', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
mockDetails.createOption.mockResolvedValue({ id: 'o2', eventId: 'e1', name: 'Child', price: 300, isMainTicket: false, stockLimit: 0, staffPrice: null, sessionMode: 'all' });
|
||||
const made = await request(app).post('/api/v2/events/e1/ticket-options').send({ name: 'Child', price: 300 });
|
||||
expect(made.status).toBe(201);
|
||||
expect((await request(app).post('/api/v2/events/e1/ticket-options').send({ price: 300 })).body.error.fields).toHaveProperty('name');
|
||||
expect((await request(app).delete('/api/v2/ticket-options/o2')).status).toBe(403);
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
mockDetails.deleteOption.mockRejectedValueOnce(new ServiceError(400, 'Cannot delete event option that has registrations'));
|
||||
expect((await request(app).delete('/api/v2/ticket-options/o2')).body.error.message).toBe('Cannot delete event option that has registrations');
|
||||
});
|
||||
|
||||
test('uploading an attachment: the file, or why it was refused', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
mockUpload.error = new Error('Unsupported file type');
|
||||
const refused = await request(app).post('/api/v2/events/e1/attachments');
|
||||
expect(refused.body.error).toMatchObject({ code: 'VALIDATION_FAILED', fields: { file: 'Unsupported file type' } });
|
||||
mockUpload.error = null;
|
||||
mockUpload.file = { originalname: 'packing.pdf', filename: 'event-file-1.pdf', mimetype: 'application/pdf', size: 1200 };
|
||||
mockDetails.recordAttachment.mockResolvedValue({ id: 'a1', eventId: 'e1', originalName: 'packing.pdf', filename: 'event-file-1.pdf', mimeType: 'application/pdf', size: 1200, url: 'http://x/uploads/event-files/event-file-1.pdf', createdAt: new Date('2026-10-29T10:00:00Z') });
|
||||
const ok = await request(app).post('/api/v2/events/e1/attachments');
|
||||
expect(ok.status).toBe(201);
|
||||
expect(ok.body.data).toMatchObject({ originalName: 'packing.pdf', createdAt: '2026-10-29T10:00:00.000Z' });
|
||||
});
|
||||
|
||||
test('the upload is described as multipart and the calendar as a file', () => {
|
||||
const doc = buildOpenApi({ version: 'test' }) as { paths: Record<string, Record<string, { requestBody?: { content: Record<string, unknown> }; responses: Record<string, { content: Record<string, unknown> }> }>> };
|
||||
expect(Object.keys(doc.paths['/events/{id}/attachments'].post.requestBody!.content)).toEqual(['multipart/form-data']);
|
||||
expect(Object.keys(doc.paths['/events/{id}/calendar.ics'].get.responses['200'].content)).toEqual(['text/calendar']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sections, costs, image uploads', () => {
|
||||
afterEach(() => { mockImage.file = null; });
|
||||
|
||||
test('costs: supervisors read them, admins add them; per-item needs a ticket type', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
mockSetup.listCosts.mockResolvedValue([{ id: 'c1', eventId: 'e1', label: 'Hall', costType: 'once_off', amount: 1500, eventOptionId: null, eventOption: null, paidFromMethod: null, notes: null, createdAt: new Date('2026-10-01T10:00:00Z') }]);
|
||||
expect((await request(app).get('/api/v2/events/e1/costs')).body.data[0]).toMatchObject({ label: 'Hall', createdAt: '2026-10-01T10:00:00.000Z' });
|
||||
expect((await request(app).post('/api/v2/events/e1/costs').send({ label: 'Hall', costType: 'once_off', amount: 1500 })).status).toBe(403);
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
expect((await request(app).post('/api/v2/events/e1/costs').send({ label: 'Hall', costType: 'monthly', amount: 1 })).body.error.fields).toHaveProperty('costType');
|
||||
mockSetup.createCost.mockRejectedValueOnce(new ServiceError(400, 'eventOptionId is required for per-item costs'));
|
||||
expect((await request(app).post('/api/v2/events/e1/costs').send({ label: 'Shirt', costType: 'per_item', amount: 80 })).body.error.message).toBe('eventOptionId is required for per-item costs');
|
||||
});
|
||||
|
||||
test('sections need staff to list and a supervisor to add', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
expect((await request(app).get('/api/v2/sections')).status).toBe(403);
|
||||
signedIn = { id: 's1', role: 'staff' };
|
||||
mockSetup.listSections.mockResolvedValue([]);
|
||||
expect((await request(app).get('/api/v2/sections?eventId=e1')).body).toEqual({ data: [] });
|
||||
expect(mockSetup.listSections).toHaveBeenCalledWith('e1', expect.anything());
|
||||
});
|
||||
|
||||
test('an event picture comes back as a path to save and a full address', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
expect((await request(app).post('/api/v2/uploads/event-image')).body.error.fields).toEqual({ image: 'Choose an image' });
|
||||
mockImage.file = { filename: 'event-123.jpg' };
|
||||
const res = await request(app).post('/api/v2/uploads/event-image');
|
||||
expect(res.body.data.path).toBe('/uploads/events/event-123.jpg');
|
||||
expect(res.body.data.url).toMatch(/^http:\/\/.*\/uploads\/events\/event-123\.jpg$/);
|
||||
});
|
||||
|
||||
test('branding images are for admins once accounts exist', async () => {
|
||||
signedIn = { id: 'v1', role: 'supervisor' };
|
||||
mockImage.file = { filename: 'logo.png' };
|
||||
expect((await request(app).post('/api/v2/uploads/logo')).status).toBe(403);
|
||||
signedIn = { id: 'a1', role: 'admin' };
|
||||
expect((await request(app).post('/api/v2/uploads/logo')).body.data.path).toBe('/uploads/branding/logo.png');
|
||||
});
|
||||
});
|
||||
|
||||
+16
-3
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-29, after **1.51.7** (and Hub 1.5.0). The plan itself is in
|
||||
Last updated: 2026-10-29, after **1.51.8** (and Hub 1.5.0). The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
@@ -17,6 +17,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.8 | **Rest of event set-up on v2**: event in full, calendar, alias, ticket options and variants, attachments, sections, costs, image uploads — `src/services/eventDetails.ts`, `src/services/setup.ts`; frontend `fetchEvent()` and `lib/eventSetup.ts`; v2 `file` and `upload` routes |
|
||||
| 1.51.7 | **Setting events up on v2** (`/events/manage`, create, `PATCH`, deactivate, copy-from, notify-recipients) via `src/services/events.ts`; `fetchManagedEvents()` for every picker; **pg-boss 11** in schema `pgboss_v11` |
|
||||
| 1.51.6 | Command palette lists every report (from `ReportCatalog`) and every dashboard page (e2e drift check); exceljs → ExcelTS 10 (`@cj-tech-master/excelts`, no dependencies; e2e `e2e/excel.spec.ts`); backend `engines.node` >= 22 |
|
||||
| 1.51.0 | **TypeScript backend + `/api/v2` foundations** (§14): tsconfig (`allowJs`), build to `dist/`, launcher `src/index.js` → `src/app.js`; v2 route declarations with zod → validation, response stripping and OpenAPI 3.1 at `/docs/v2`; first endpoints events, event, me, my registrations. Tests `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts` |
|
||||
@@ -67,8 +68,8 @@ encrypted settings unreadable or break existing installs.
|
||||
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
|
||||
a test server (both done 2026-10-28).
|
||||
|
||||
1. **1.51.8 – 1.51.14: 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.14 gives incoming
|
||||
1. **1.51.9 – 1.51.15: every endpoint onto v2**, one area per release (roadmap build order, row
|
||||
11b): services in TS, screens on `apiV2`, v1 routes into `SUCCESSORS`. 1.51.15 gives incoming
|
||||
webhooks and links in sent messages v2 URLs, with the v1 paths still answering (links redirect,
|
||||
webhooks handled by the same code). Then **2.0.0** — AI helpers and v1 removed.
|
||||
2. **Servers run Node 24** (Joshua, 2026-10-29), so the backend may need Node 22+. Still to do:
|
||||
@@ -465,6 +466,18 @@ a test server (both done 2026-10-28).
|
||||
`fetchAllUsers` (Payments, Email/WhatsApp attendees, Forms, Manual) — a server-side search
|
||||
picker would replace them; the coupons list; `GET /api/registrations` without `page` (other callers still use the plain array).
|
||||
|
||||
## The rest of event set-up on v2 (1.51.8)
|
||||
|
||||
- **One event:** `fetchEvent(id, { authToken, nextOptions })` (`lib/api.ts`, v2 `GET /events/{id}/full`) —
|
||||
never `/api/events/:id`. Server pages pass `nextOptions` for revalidation.
|
||||
- **Set-up calls:** `lib/eventSetup.ts` (ticket options, sections, attachments, costs, `uploadImage()`,
|
||||
which returns the path to save).
|
||||
- **Downloads and uploads on v2:** `file: { contentType }` + `response: fileResponse` makes a route
|
||||
send a file; `upload: { field, description }` documents a multipart body (the multer step goes in
|
||||
`middleware`; check `req.multerError`/`req.file` in the handler).
|
||||
- `eventController.js` still holds the public list (`getEvents`), the admin list and the attendee
|
||||
email/WhatsApp senders (messaging, 1.51.13).
|
||||
|
||||
## Setting events up on v2 (1.51.7)
|
||||
|
||||
- **Staff event lists:** use `fetchManagedEvents(token, { past, inactive, closed, campus })`
|
||||
|
||||
+4
-4
@@ -41,8 +41,8 @@ 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** and 1.51.7 **setting events up on v2, pg-boss 11** shipped. **Next: 1.51.8 – 1.51.14 (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** shipped.
|
||||
**Next: 1.51.9 – 1.51.15 (every endpoint onto v2), then 2.0.0.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -1011,7 +1011,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.5 ✅, 1.51.6 ✅, 1.51.7 ✅, 1.51.8 – 1.51.14 | **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 the events area (event details for staff, ticket options and variants, attachments, sections, costs, uploads, series, season passes, bundles, coupons, campuses, calendar file, alias); 1.51.9 registrations and money, staff side (manage registrations, payments, cashup, provider transactions, waitlist, sponsorship, consent, forms, feedback); 1.51.10 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.11 kids check-in and volunteer rosters; 1.51.12 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.13 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.14 **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.8 ✅, 1.51.9 – 1.51.15 | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 registrations and money, staff side (manage registrations, payments, cashup, provider transactions, waitlist, sponsorship, consent, forms, feedback); 1.51.11 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.12 kids check-in and volunteer rosters; 1.51.13 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.14 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.15 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each moves its area's logic into TS services, its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
|
||||
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 removed (all of it is on v2 by then; the old webhook URLs and links already sent in messages keep forwarding to v2), migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
@@ -1049,7 +1049,7 @@ feature.
|
||||
| — | AI in 2.0 | The **headline feature of 2.0**: one-button helpers only (draft an event, summarise reports and feedback, draft messages, improve text) — no chat yet (2026-10-24) |
|
||||
| — | AI provider and key | One provider, **Claude** (Haiku 5.5 to start, for cost). Joshua provides the key; installs call AI **through the Hub**, which holds the key and meters usage (2026-10-24) |
|
||||
| — | AI cost limits | A **monthly rand cap per install**, set in the Hub per licence to match what the church pays; admins see their usage in Site settings → AI (2026-10-24) |
|
||||
| — | v1 in 2.0 | **Every endpoint moves to v2 before 2.0** (1.51.7 – 1.51.14, by area), then 2.0 removes v1. Changed the same day from "retire only what's replaced". Incoming webhooks (Yoco, PayFast, WAWP) and links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds) also get v2 URLs (1.51.14); their v1 paths keep working — links redirect (308) to v2, provider webhooks are handled by the same code — because old messages and provider registrations still use them (2026-10-29) |
|
||||
| — | v1 in 2.0 | **Every endpoint moves to v2 before 2.0** (1.51.7 – 1.51.15, by area), then 2.0 removes v1. Changed the same day from "retire only what's replaced". Incoming webhooks (Yoco, PayFast, WAWP) and links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds) also get v2 URLs (1.51.15); their v1 paths keep working — links redirect (308) to v2, provider webhooks are handled by the same code — because old messages and provider registrations still use them (2026-10-29) |
|
||||
| — | AI buttons and markup | AI buttons show only when the licence's monthly cap is **above R0**. When the cap is reached they stay visible but **disabled** until next month or until the cap is raised. Usage is charged at the real cost **plus a markup %** set in the Hub (a default, plus an override per licence). The markup covers small overruns and is the profit. The cap is counted in charged rand, and each call is checked against its worst-case cost before it's sent (2026-10-29) |
|
||||
| — | AI later | Ask AI (read-only, tools only), help/navigation assistant, actions with confirmation, more helpers, and long-term **self-hosted** AI behind the same Hub endpoint — not tied to versions (2026-10-24) |
|
||||
| — | POPIA: message kinds | Bulk messages are either **Event updates** (to registrants — still sent after unsubscribing) or **News & announcements** (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
|
||||
|
||||
@@ -116,6 +116,38 @@ test.describe("API v2", () => {
|
||||
expect((await (await request.delete(`${API}/api/v2/events/${ev.id}`, { headers: admin })).json()).data).toEqual({ id: ev.id, isActive: false });
|
||||
});
|
||||
|
||||
test("an event in full, its calendar file, ticket options, sections and costs", async ({ request }) => {
|
||||
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
|
||||
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
|
||||
const full = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`)).json()).data;
|
||||
expect(full.eventOptions.length).toBeGreaterThan(0);
|
||||
expect(full).toHaveProperty("attachments");
|
||||
// The public don't see who gets the emails; staff do.
|
||||
expect(full.notifyRecipients).toBeUndefined();
|
||||
expect((await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`, { headers: sup })).json()).data).toHaveProperty("notifyRecipients");
|
||||
|
||||
const ics = await request.get(`${API}/api/v2/events/${seed().events.conference}/calendar.ics`);
|
||||
expect(ics.headers()["content-type"]).toContain("text/calendar");
|
||||
expect(await ics.text()).toContain("BEGIN:VCALENDAR");
|
||||
|
||||
const opt = (await (await request.post(`${API}/api/v2/events/${seed().events.conference}/ticket-options`, { headers: sup, data: { name: "E2E v2 Extra", price: 25 } })).json()).data;
|
||||
expect(opt).toMatchObject({ name: "E2E v2 Extra", price: 25, isMainTicket: false });
|
||||
const withVariant = (await (await request.patch(`${API}/api/v2/ticket-options/${opt.id}`, { headers: sup, data: { variants: [{ name: "Large", price: 30 }] } })).json()).data;
|
||||
expect(withVariant.variants.map((v: { name: string }) => v.name)).toEqual(["Large"]);
|
||||
expect((await request.delete(`${API}/api/v2/ticket-options/${opt.id}`, { headers: sup })).status()).toBe(403);
|
||||
expect((await (await request.delete(`${API}/api/v2/ticket-options/${opt.id}`, { headers: admin })).json()).data).toEqual({ id: opt.id, deleted: true });
|
||||
|
||||
const section = (await (await request.post(`${API}/api/v2/sections`, { headers: sup, data: { eventId: seed().events.conference, name: "E2E Side door", allowedOptionIds: [full.eventOptions[0].id] } })).json()).data;
|
||||
expect(section.allowedOptions).toHaveLength(1);
|
||||
const listed = (await (await request.get(`${API}/api/v2/sections?eventId=${seed().events.conference}`, { headers: sup })).json()).data;
|
||||
expect(listed.map((x: { id: string }) => x.id)).toContain(section.id);
|
||||
|
||||
const cost = await request.post(`${API}/api/v2/events/${seed().events.conference}/costs`, { headers: admin, data: { label: "E2E Hall", costType: "once_off", amount: 500 } });
|
||||
expect(cost.status()).toBe(201);
|
||||
const costs = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/costs`, { headers: sup })).json()).data;
|
||||
expect(costs.map((c: { label: string }) => c.label)).toContain("E2E Hall");
|
||||
});
|
||||
|
||||
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
|
||||
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
expect((await request.get(`${API}/api/v2/openapi.json`, { headers })).status()).toBe(403);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.7",
|
||||
"version": "1.51.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.7",
|
||||
"version": "1.51.8",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.7",
|
||||
"version": "1.51.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { Event } from "@/types";
|
||||
|
||||
export const revalidate = 60;
|
||||
@@ -20,7 +19,8 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
|
||||
|
||||
if (VALID_ALIAS.test(redirectUrl)) {
|
||||
try {
|
||||
event = await apiFetch<Event & { message?: string }>(`/api/events/by-alias/${redirectUrl}`);
|
||||
const { apiV2 } = await import("@/lib/apiV2");
|
||||
event = (await apiV2("get", "/events/by-alias/{alias}", { params: { alias: redirectUrl } })).data as unknown as Event;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch event:", error);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { apiFetch, fetchEvent } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import type { EventCost, EventCostType, EventFinancials, CashupMethod, CashupWriteOffCategory, ReconciledByMethod, Event } from "@/types";
|
||||
import { Wallet, DollarSign, ClipboardCheck, FileBarChart } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { createCost, updateCost, deleteCost } from "@/lib/eventSetup";
|
||||
|
||||
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
|
||||
const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"];
|
||||
@@ -42,7 +43,7 @@ export default function EventCashupPage() {
|
||||
try {
|
||||
const [financials, event] = await Promise.all([
|
||||
apiFetch<EventFinancials>(`/api/cashups/event/${eventId}`, { authToken: token }),
|
||||
apiFetch<Event>(`/api/events/${eventId}`, { authToken: token })
|
||||
fetchEvent<Event>(eventId, { authToken: token })
|
||||
]);
|
||||
setData(financials);
|
||||
setEventOptions((event.eventOptions || []).map((o) => ({ id: o.id, name: o.name })));
|
||||
@@ -142,11 +143,11 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
setSaving(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const body = { label: label.trim(), costType, amount: parseFloat(amount), eventOptionId: costType === "per_item" ? eventOptionId : null, paidFromMethod: paidFromMethod || null, notes: notes || null };
|
||||
const body = { label: label.trim(), costType, amount: parseFloat(amount), eventOptionId: costType === "per_item" ? eventOptionId : null, paidFromMethod: (paidFromMethod || null) as "cash" | "card" | "eft" | "other" | null, notes: notes || null };
|
||||
if (editingId === "new") {
|
||||
await apiFetch(`/api/events/${eventId}/costs`, { method: "POST", authToken: token, body });
|
||||
await createCost(token, eventId, body);
|
||||
} else {
|
||||
await apiFetch(`/api/costs/${editingId}`, { method: "PUT", authToken: token, body });
|
||||
await updateCost(token, editingId!, body);
|
||||
}
|
||||
setEditingId(null);
|
||||
onChanged();
|
||||
@@ -160,7 +161,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
const remove = async (id: string) => {
|
||||
if (!confirm("Delete this cost?")) return;
|
||||
try {
|
||||
await apiFetch(`/api/costs/${id}`, { method: "DELETE", authToken: token });
|
||||
await deleteCost(token, id);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
alert(errorMessage(e) || "Failed to delete cost");
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { Suspense, useCallback, useEffect, useRef, useState } from "react
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, MapPinned, type LucideIcon, Webhook } from "lucide-react";
|
||||
@@ -20,6 +20,7 @@ import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { uploadImage } from "@/lib/eventSetup";
|
||||
|
||||
type TabId = "organisation" | "campuses" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "webhooks" | "backups" | "jobs" | "licence";
|
||||
|
||||
@@ -232,16 +233,11 @@ function SiteSettingsPageInner() {
|
||||
});
|
||||
|
||||
const uploadBrandingAsset = async (file: File, kind: "logo" | "favicon"): Promise<string> => {
|
||||
const fd = new FormData();
|
||||
fd.append("image", file);
|
||||
const res = await fetch(`${API_BASE}/api/uploads/${kind}`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) throw new Error(`${kind === "logo" ? "Logo" : "Favicon"} upload failed`);
|
||||
const data = await res.json();
|
||||
return data.url || "";
|
||||
try {
|
||||
return await uploadImage(token, kind, file);
|
||||
} catch {
|
||||
throw new Error(`${kind === "logo" ? "Logo" : "Favicon"} upload failed`);
|
||||
}
|
||||
};
|
||||
|
||||
const saveBranding = async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { activeSessions, currentSessionLabel, formatSessionWhen, sessionLabel } from "@/lib/sessions";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { apiV2 } from "@/lib/apiV2";
|
||||
import { listSections } from "@/lib/eventSetup";
|
||||
|
||||
// What the confirm step needs, from GET /api/v2/checkin/{qrCode}. Offline scans build the
|
||||
// same shape from the downloaded ticket list.
|
||||
@@ -120,7 +121,7 @@ export default function TicketScanningPage() {
|
||||
}
|
||||
try {
|
||||
setLoadingSections(true);
|
||||
const list = await apiFetch<Section[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
|
||||
const list = await listSections<Section>(token, selectedEventId);
|
||||
setSections(Array.isArray(list) ? list : []);
|
||||
setSelectedSectionId("all");
|
||||
} catch {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchManagedEvents } from "@/lib/api";
|
||||
import { apiFetch, fetchManagedEvents, fetchEvent } from "@/lib/api";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
@@ -162,7 +162,7 @@ export default function AtTheDoorPage() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const ev = await apiFetch<Event>(`/api/events/${eventId}`, {
|
||||
const ev = await fetchEvent<Event>(eventId, {
|
||||
authToken: token
|
||||
});
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchManagedEvents } from "@/lib/api";
|
||||
import { fetchManagedEvents } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Ticket } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, EventOption } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { createTicketOption, updateTicketOption, deleteTicketOption } from "@/lib/eventSetup";
|
||||
|
||||
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
|
||||
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
|
||||
@@ -160,11 +161,7 @@ function EventOptionsContent() {
|
||||
if (!newOpt.name) { setError("Option name is required"); return; }
|
||||
const priceNum = parseFloat(newOpt.price || "0");
|
||||
try {
|
||||
await apiFetch(`/api/events/${encodeURIComponent(selectedEventId)}/options`, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { name: newOpt.name, price: priceNum || 0, isMainTicket: !!newOpt.isMainTicket }
|
||||
});
|
||||
await createTicketOption(token, selectedEventId, { name: newOpt.name, price: priceNum || 0, isMainTicket: !!newOpt.isMainTicket });
|
||||
setNewOpt({ name: "", price: "", isMainTicket: false });
|
||||
setInfo("Option created");
|
||||
await loadEvents();
|
||||
@@ -177,11 +174,7 @@ function EventOptionsContent() {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
await apiFetch(`/api/events/options/${encodeURIComponent(opt.id)}`, {
|
||||
method: "PUT",
|
||||
authToken: token,
|
||||
body: patch
|
||||
});
|
||||
await updateTicketOption(token, opt.id, patch);
|
||||
setInfo("Option updated");
|
||||
await loadEvents();
|
||||
} catch (e) {
|
||||
@@ -193,10 +186,7 @@ function EventOptionsContent() {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
await apiFetch(`/api/events/options/${encodeURIComponent(opt.id)}`, {
|
||||
method: "DELETE",
|
||||
authToken: token,
|
||||
});
|
||||
await deleteTicketOption(token, opt.id);
|
||||
setInfo("Option deleted");
|
||||
await loadEvents();
|
||||
} catch (e) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
|
||||
import { createPortal } from "react-dom";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, API_BASE, fetchManagedEvents } from "@/lib/api";
|
||||
import { apiFetch, API_BASE, fetchManagedEvents, fetchEvent } from "@/lib/api";
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { toSessionDraft, scheduleSummary } from "@/lib/sessions";
|
||||
import { fetchApiFile, openBlob } from "@/lib/export";
|
||||
import { apiV2 } from "@/lib/apiV2";
|
||||
import { createTicketOption, updateTicketOption, listSections, createSection, updateSection, deleteSection, listAttachments, uploadAttachment, deleteAttachment, uploadImage } from "@/lib/eventSetup";
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -61,7 +62,7 @@ type FormFieldType = "yes_no" | "text" | "date" | "numeric" | "statement" | "par
|
||||
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string | null; options?: unknown }[] };
|
||||
type FieldDef = EventFormDef["fields"][number];
|
||||
|
||||
// What PUT /api/events/options/:id takes as earlyBirdTiers (variantId null = option-level).
|
||||
// What PATCH /api/v2/ticket-options/{id} takes as earlyBirdTiers (variantId null = option-level).
|
||||
type TierPayload = { id?: string; deadline: string; price: number; stockLimit: number; order: number; variantId: string | null };
|
||||
|
||||
// GET /api/feedback/event/:id — the event's post-event feedback form, if it has one.
|
||||
@@ -120,10 +121,7 @@ function UploadImageButton({ onUploaded, label = "Upload image" }: { onUploaded:
|
||||
if (!file || !token) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("image", file);
|
||||
const res = await apiFetch<{ url?: string; path?: string }>("/api/uploads/event-image", { method: "POST", body: fd, authToken: token });
|
||||
const uploaded = res?.url || res?.path;
|
||||
const uploaded = await uploadImage(token, "event-image", file);
|
||||
if (uploaded) onUploaded(uploaded);
|
||||
} finally { setUploading(false); if (ref.current) ref.current.value = ""; }
|
||||
}} />
|
||||
@@ -506,8 +504,8 @@ function SectionsManager({ eventId }: { eventId: string }) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [secList, evFull] = await Promise.all([
|
||||
apiFetch<Section[]>("/api/sections", { authToken: token || undefined }),
|
||||
apiFetch<Event>(`/api/events/${eventId}`)
|
||||
listSections<Section>(token),
|
||||
fetchEvent<Event>(eventId)
|
||||
]);
|
||||
setSections((secList || []).filter(s => s.eventId === eventId));
|
||||
setEventOptions(evFull?.eventOptions || []);
|
||||
@@ -517,17 +515,17 @@ function SectionsManager({ eventId }: { eventId: string }) {
|
||||
|
||||
const create = async () => {
|
||||
if (!newName.trim() || !token) return;
|
||||
await apiFetch("/api/sections", { method: "POST", authToken: token, body: { eventId, name: newName.trim(), allowedOptionIds: newOptionIds } });
|
||||
await createSection(token, { eventId, name: newName.trim(), allowedOptionIds: newOptionIds });
|
||||
setNewName(""); setNewOptionIds([]); load();
|
||||
};
|
||||
const del = async (id: string) => {
|
||||
if (!token) return;
|
||||
await apiFetch(`/api/sections/${id}`, { method: "DELETE", authToken: token });
|
||||
await deleteSection(token, id);
|
||||
load();
|
||||
};
|
||||
const update = async (id: string, name: string, optionIds: string[]) => {
|
||||
if (!token) return;
|
||||
await apiFetch(`/api/sections/${id}`, { method: "PUT", authToken: token, body: { name, allowedOptionIds: optionIds } });
|
||||
await updateSection(token, id, { name, allowedOptionIds: optionIds });
|
||||
load();
|
||||
};
|
||||
|
||||
@@ -595,8 +593,7 @@ function AttachmentsManager({ eventId }: { eventId: string }) {
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const res = await apiFetch<EventAttachment[]>(`/api/events/${eventId}/attachments`);
|
||||
setItems(Array.isArray(res) ? res : []);
|
||||
setItems(await listAttachments(eventId));
|
||||
}, [eventId]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
@@ -605,16 +602,14 @@ function AttachmentsManager({ eventId }: { eventId: string }) {
|
||||
if (!file || !token) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
await apiFetch(`/api/events/${eventId}/attachments`, { method: "POST", body: fd, authToken: token });
|
||||
await uploadAttachment(token, eventId, file);
|
||||
await load();
|
||||
} finally { setUploading(false); if (ref.current) ref.current.value = ""; }
|
||||
};
|
||||
|
||||
const del = async (id: string) => {
|
||||
if (!token || !confirm("Delete this attachment?")) return;
|
||||
await apiFetch(`/api/events/${eventId}/attachments/${id}`, { method: "DELETE", authToken: token });
|
||||
await deleteAttachment(token, eventId, id);
|
||||
await load();
|
||||
};
|
||||
|
||||
@@ -1040,7 +1035,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
// left out (the kids or volunteer ticket) is dropped rather than widened to "all options".
|
||||
useEffect(() => {
|
||||
if (!cloned || !source?.id || !token) return;
|
||||
apiFetch<Section[]>(`/api/sections?eventId=${source.id}`, { authToken: token }).then(list => {
|
||||
listSections<Section>(token, source.id).then(list => {
|
||||
const drafts: SectionDraft[] = [];
|
||||
for (const sec of list || []) {
|
||||
const ids = (sec.allowedOptions || []).map(o => o.eventOptionId);
|
||||
@@ -1065,7 +1060,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
const formLoadedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !ev?.id || formLoadedRef.current) return;
|
||||
apiFetch<Event>(`/api/events/${ev.id}`).then(full => {
|
||||
fetchEvent<Event>(ev.id).then(full => {
|
||||
const f = full?.form;
|
||||
if (f) setFormDef({ isRequired: !!f.isRequired, fields: toFieldDefs(f.fields) });
|
||||
}).catch(() => {}).finally(() => { formLoadedRef.current = true; });
|
||||
@@ -1205,10 +1200,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
|
||||
if (opt.id) {
|
||||
// Update option + variants first to get/confirm variant IDs
|
||||
const updated = await apiFetch<EventOption>(`/api/events/options/${opt.id}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all", variants: variantsPayload }
|
||||
});
|
||||
const updated = await updateTicketOption(token, opt.id, { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all", variants: variantsPayload });
|
||||
|
||||
// Build variant tiers with confirmed IDs from response
|
||||
const serverVariants = updated?.variants || [];
|
||||
@@ -1224,10 +1216,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
}
|
||||
|
||||
if (allTiers.length > 0 || opt.earlyBirdTiers.length > 0 || opt.variants.some(v => v.earlyBirdTiers.length > 0)) {
|
||||
await apiFetch(`/api/events/options/${opt.id}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { earlyBirdTiers: allTiers }
|
||||
});
|
||||
await updateTicketOption(token, opt.id, { earlyBirdTiers: allTiers });
|
||||
}
|
||||
|
||||
// Handle form save (edit only)
|
||||
@@ -1237,15 +1226,9 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
});
|
||||
} else {
|
||||
// New option added during edit
|
||||
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
|
||||
method: "POST", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
|
||||
});
|
||||
const created = await createTicketOption(token, eventId, { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" });
|
||||
if (created?.id) {
|
||||
const updated = await apiFetch<EventOption>(`/api/events/options/${created.id}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { variants: variantsPayload }
|
||||
});
|
||||
const updated = await updateTicketOption(token, created.id, { variants: variantsPayload });
|
||||
const serverVariants = updated?.variants || [];
|
||||
for (let vi = 0; vi < opt.variants.length; vi++) {
|
||||
const v = opt.variants[vi];
|
||||
@@ -1258,9 +1241,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
}
|
||||
}
|
||||
if (allTiers.length > 0) {
|
||||
await apiFetch(`/api/events/options/${created.id}`, {
|
||||
method: "PUT", authToken: token || undefined, body: { earlyBirdTiers: allTiers }
|
||||
});
|
||||
await updateTicketOption(token, created.id, { earlyBirdTiers: allTiers });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1268,7 +1249,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
} else {
|
||||
// Create mode — backend auto-creates a "Main Ticket" option; replace/create from draft
|
||||
let createdEvent: Event | null = null;
|
||||
try { createdEvent = await apiFetch<Event>(`/api/events/${eventId}`); } catch {}
|
||||
try { createdEvent = await fetchEvent<Event>(eventId); } catch {}
|
||||
const existingMain = (createdEvent?.eventOptions || []).find((o) => !!o.isMainTicket);
|
||||
const uiMainIdx = options.findIndex(o => o.isMainTicket);
|
||||
|
||||
@@ -1284,16 +1265,10 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
|
||||
if (i === uiMainIdx && existingMain) {
|
||||
// Update the auto-created main ticket
|
||||
await apiFetch(`/api/events/options/${existingMain.id}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
|
||||
});
|
||||
await updateTicketOption(token, existingMain.id, { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" });
|
||||
optId = existingMain.id;
|
||||
} else {
|
||||
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
|
||||
method: "POST", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
|
||||
});
|
||||
const created = await createTicketOption(token, eventId, { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" });
|
||||
optId = created?.id;
|
||||
}
|
||||
|
||||
@@ -1305,9 +1280,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
.map((t, ti) => ({ deadline: new Date(t.deadline).toISOString(), price: parseFloat(t.price), stockLimit: parseInt(t.stockLimit || "0"), order: ti, variantId: null }));
|
||||
|
||||
if (variantsPayload.length > 0 || opt.variants.some(v => v.earlyBirdTiers.length > 0)) {
|
||||
const updated = await apiFetch<EventOption>(`/api/events/options/${optId}`, {
|
||||
method: "PUT", authToken: token || undefined, body: { variants: variantsPayload }
|
||||
});
|
||||
const updated = await updateTicketOption(token, optId, { variants: variantsPayload });
|
||||
const serverVariants = updated?.variants || [];
|
||||
for (let vi = 0; vi < opt.variants.length; vi++) {
|
||||
const v = opt.variants[vi];
|
||||
@@ -1322,9 +1295,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
}
|
||||
|
||||
if (allTiers.length > 0) {
|
||||
await apiFetch(`/api/events/options/${optId}`, {
|
||||
method: "PUT", authToken: token || undefined, body: { earlyBirdTiers: allTiers }
|
||||
});
|
||||
await updateTicketOption(token, optId, { earlyBirdTiers: allTiers });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1337,10 +1308,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
.map(idx => createdOptions[idx]?.id)
|
||||
.filter(Boolean);
|
||||
try {
|
||||
await apiFetch("/api/sections", {
|
||||
method: "POST", authToken: token || undefined,
|
||||
body: { eventId, name: sec.name.trim(), allowedOptionIds: optionIds }
|
||||
});
|
||||
await createSection(token, { eventId, name: sec.name.trim(), allowedOptionIds: optionIds as string[] });
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
@@ -1430,14 +1398,12 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
|
||||
await apiV2("post", "/events/{id}/copy-from", { params: { id: created.id }, authToken: token, body: { fromEventId: source!.id } });
|
||||
}
|
||||
// Fetch event to get final option IDs for sections
|
||||
const fullEvent = await apiFetch<Event>(`/api/events/${created.id}`).catch(() => null);
|
||||
const fullEvent = await fetchEvent<Event>(created.id).catch(() => null);
|
||||
const createdOptions = fullEvent?.eventOptions || [];
|
||||
if (sectionDrafts.length > 0) await saveStagedSections(created.id, createdOptions);
|
||||
// Upload staged attachments
|
||||
for (const file of stagedFiles) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
await apiFetch(`/api/events/${created.id}/attachments`, { method: "POST", body: fd, authToken: token || undefined });
|
||||
await uploadAttachment(token, created.id, file);
|
||||
}
|
||||
// Save staged notify recipients
|
||||
if (notifyRecipientDrafts.length > 0) {
|
||||
@@ -2090,7 +2056,7 @@ export default function ManageEventsPage() {
|
||||
const openCreate = () => { setEditingEvent(null); setModalMode("create"); };
|
||||
const openEdit = async (ev: Event) => {
|
||||
try {
|
||||
const full = await apiFetch<Event>(`/api/events/${ev.id}`, { authToken: token || undefined });
|
||||
const full = await fetchEvent<Event>(ev.id, { authToken: token || undefined });
|
||||
setEditingEvent(full);
|
||||
} catch { setEditingEvent(ev); }
|
||||
setModalMode("edit");
|
||||
@@ -2099,7 +2065,7 @@ export default function ManageEventsPage() {
|
||||
const [copyFrom, setCopyFrom] = useState<Event | null>(null);
|
||||
const openCopy = async (ev: Event) => {
|
||||
try {
|
||||
const full = await apiFetch<Event>(`/api/events/${ev.id}`, { authToken: token || undefined });
|
||||
const full = await fetchEvent<Event>(ev.id, { authToken: token || undefined });
|
||||
setEditingEvent(null);
|
||||
setCopyFrom(full);
|
||||
setModalMode("create");
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ExtraFieldInput, isExtraFieldType, ChoicesEditor } from "@/components/s
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers, fetchManagedEvents } from "@/lib/api";
|
||||
import { apiFetch, fetchAllUsers, fetchManagedEvents, fetchEvent } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
@@ -308,7 +308,7 @@ export default function FormsBrowserPage() {
|
||||
if (!eventId) return;
|
||||
try {
|
||||
setFormLoading(true);
|
||||
const evFull = await apiFetch<Event>(`/api/events/${encodeURIComponent(eventId)}`);
|
||||
const evFull = await fetchEvent<Event>(eventId);
|
||||
setFormDef(toFormDef(evFull?.form));
|
||||
setFormLoadedFor(eventId);
|
||||
} catch (e) {
|
||||
@@ -377,7 +377,7 @@ export default function FormsBrowserPage() {
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
|
||||
setFillRegistration(reg);
|
||||
if (reg?.event?.id) {
|
||||
const evFull = await apiFetch<Event>(`/api/events/${encodeURIComponent(reg.event.id)}`);
|
||||
const evFull = await fetchEvent<Event>(reg.event.id);
|
||||
setFillForm(toFormDef(evFull?.form));
|
||||
}
|
||||
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormField
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { apiFetch, fetchEvent } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
@@ -51,7 +51,7 @@ export default function ManualRegistrationPage() {
|
||||
setCreatedReg(res);
|
||||
// Load form definition for this event (if any)
|
||||
try {
|
||||
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(eventId)}`);
|
||||
const ev = await fetchEvent<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(eventId);
|
||||
if (ev?.form) setForm(ev.form);
|
||||
} catch {}
|
||||
alert("Manual registration created");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers, fetchManagedEvents } from "@/lib/api";
|
||||
import { apiFetch, fetchAllUsers, fetchManagedEvents, fetchEvent } from "@/lib/api";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
@@ -277,7 +277,7 @@ export default function ManualRegistrationPage() {
|
||||
useEffect(() => {
|
||||
if (!selectedEventId) { setBundles([]); setFullEvent(null); return; }
|
||||
let cancelled = false;
|
||||
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: token || undefined })
|
||||
fetchEvent<Event>(selectedEventId, { authToken: token || undefined })
|
||||
.then(ev => { if (!cancelled) { setBundles(ev?.bundles || []); setFullEvent(ev || null); } })
|
||||
.catch(() => { if (!cancelled) { setBundles([]); setFullEvent(null); } });
|
||||
return () => { cancelled = true; };
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormField
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { apiFetch, fetchEvent } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
@@ -37,7 +37,7 @@ function FormsContent() {
|
||||
try {
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
setRegistration(reg);
|
||||
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(reg.eventId)}`);
|
||||
const ev = await fetchEvent<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(reg.eventId);
|
||||
if (ev?.form) setEventForm(ev.form);
|
||||
// Load any saved draft
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, createFullPaymentCheckout } from "@/lib/api";
|
||||
import { apiFetch, createFullPaymentCheckout, fetchEvent } from "@/lib/api";
|
||||
import { apiV2 } from "@/lib/apiV2";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -383,7 +383,7 @@ export default function UserDashboardPage() {
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const ev = await apiFetch<Event>(`/api/events/${encodeURIComponent(ar.eventId)}`);
|
||||
const ev = await fetchEvent<Event>(ar.eventId);
|
||||
const has = !!(ev?.form && Array.isArray(ev.form.fields) && ev.form.fields.length > 0);
|
||||
setActiveEventHasForm(has);
|
||||
} catch {
|
||||
@@ -509,7 +509,7 @@ export default function UserDashboardPage() {
|
||||
setEditLoading(true);
|
||||
try {
|
||||
// Load event options (with variants) for this event
|
||||
const eventData = await apiFetch<Event>(`/api/events/${encodeURIComponent(activeReg.eventId)}`);
|
||||
const eventData = await fetchEvent<Event>(activeReg.eventId);
|
||||
const eventOptions = (eventData?.eventOptions || []).filter((eo) => !isPickOption(eventData, eo));
|
||||
|
||||
const regOptById = new Map<string, RegistrationOption>((activeReg.registrationOptions || []).map((o) => [o.id, o]));
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function ClientActions({ event }: Props) {
|
||||
Save QR
|
||||
</button>
|
||||
<a
|
||||
href={`${API_BASE}/api/events/${event.id}/ics`}
|
||||
href={`${API_BASE}/api/v2/events/${event.id}/calendar.ics`}
|
||||
download
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
|
||||
>
|
||||
|
||||
@@ -59,7 +59,7 @@ type Event = {
|
||||
nextInSeries?: NextInSeries | null;
|
||||
};
|
||||
|
||||
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
|
||||
import { ApiError, resolveToApiOrigin, fetchEvent } from "@/lib/api";
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
import { BundleOffers } from "@/components/shared/BundleDeals";
|
||||
@@ -142,7 +142,7 @@ function RegisterCta({ event }: { event: Event }) {
|
||||
// request, so calling this again from the page component below is free.
|
||||
async function loadEvent(id: string): Promise<Event | null> {
|
||||
try {
|
||||
return await apiFetch<Event>(`/api/events/${id}`, { nextOptions: { next: { revalidate } } });
|
||||
return await fetchEvent<Event>(id, { nextOptions: { next: { revalidate } } });
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) return null;
|
||||
throw e;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormField
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { apiFetch, fetchEvent } from "@/lib/api";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { FileText } from "lucide-react";
|
||||
@@ -42,7 +42,7 @@ function FormsContent() {
|
||||
// authToken optional — guests access via registrationId alone
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token || undefined });
|
||||
setRegistration(reg);
|
||||
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(reg.eventId)}`);
|
||||
const ev = await fetchEvent<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(reg.eventId);
|
||||
if (ev?.form) setEventForm(ev.form);
|
||||
// Load any saved draft to prefill fields
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { ApiError, fetchEvent } from "@/lib/api";
|
||||
import RegisterForm, { type RegisterEvent } from "./RegisterForm";
|
||||
import { FunnelTracker } from "@/components/events/FunnelTracker";
|
||||
|
||||
@@ -13,7 +13,7 @@ export default async function RegisterPage({ params }: { params: Promise<{ event
|
||||
try {
|
||||
// Fetched server-side (same pattern as /events/[id]) so the form's content is part of
|
||||
// the initial HTML instead of showing a loading skeleton after a client-side fetch.
|
||||
event = await apiFetch<RegisterEvent>(`/api/events/${eventId}`, { nextOptions: { next: { revalidate } } });
|
||||
event = await fetchEvent<RegisterEvent>(eventId, { nextOptions: { next: { revalidate } } });
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) notFound();
|
||||
throw e;
|
||||
|
||||
@@ -43,7 +43,7 @@ function RegistrationSuccessContent() {
|
||||
const r = token ? await (await import('@/lib/api')).apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null;
|
||||
if (r) {
|
||||
setReg(r);
|
||||
const ev = await (await import('@/lib/api')).apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(r.eventId)}`);
|
||||
const ev = await (await import('@/lib/api')).fetchEvent<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(r.eventId);
|
||||
if (ev?.form) setForm(ev.form);
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { apiFetch, fetchManagedEvents } from "@/lib/api";
|
||||
import { apiFetch, fetchManagedEvents, fetchEvent } from "@/lib/api";
|
||||
import { AuthResponse, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
||||
@@ -218,7 +218,7 @@ export default function SelfServicePage() {
|
||||
useEffect(() => {
|
||||
if (!selectedEventId) { setKioskBundles([]); setKioskFull(null); return; }
|
||||
let cancelled = false;
|
||||
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: supervisorToken || undefined })
|
||||
fetchEvent<Event>(selectedEventId, { authToken: supervisorToken || undefined })
|
||||
.then(ev => { if (!cancelled) { setKioskBundles(ev?.bundles || []); setKioskFull(ev || null); } })
|
||||
.catch(() => { if (!cancelled) { setKioskBundles([]); setKioskFull(null); } });
|
||||
return () => { cancelled = true; };
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { ConnectForm } from "@/components/admin/LicenceTab";
|
||||
import { uploadImage } from "@/lib/eventSetup";
|
||||
|
||||
type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
@@ -196,36 +197,12 @@ export default function SetupPage() {
|
||||
// setupToken from /api/setup/register covers that.
|
||||
let logoUrl = "";
|
||||
if (logoFile) {
|
||||
const fd = new FormData();
|
||||
fd.append("image", logoFile);
|
||||
const uploadRes = await fetch(`${API_BASE}/api/uploads/logo`, {
|
||||
method: "POST",
|
||||
headers: setupToken ? { Authorization: `Bearer ${setupToken}` } : undefined,
|
||||
body: fd,
|
||||
});
|
||||
if (uploadRes.ok) {
|
||||
const up = await uploadRes.json();
|
||||
logoUrl = up.url || "";
|
||||
} else {
|
||||
throw new Error("Logo upload failed. Please try again.");
|
||||
}
|
||||
logoUrl = await uploadImage(setupToken, "logo", logoFile).catch(() => { throw new Error("Logo upload failed. Please try again."); });
|
||||
}
|
||||
|
||||
let faviconUrl = "";
|
||||
if (faviconFile) {
|
||||
const fd = new FormData();
|
||||
fd.append("image", faviconFile);
|
||||
const uploadRes = await fetch(`${API_BASE}/api/uploads/favicon`, {
|
||||
method: "POST",
|
||||
headers: setupToken ? { Authorization: `Bearer ${setupToken}` } : undefined,
|
||||
body: fd,
|
||||
});
|
||||
if (uploadRes.ok) {
|
||||
const up = await uploadRes.json();
|
||||
faviconUrl = up.url || "";
|
||||
} else {
|
||||
throw new Error("Favicon upload failed. Please try again.");
|
||||
}
|
||||
faviconUrl = await uploadImage(setupToken, "favicon", faviconFile).catch(() => { throw new Error("Favicon upload failed. Please try again."); });
|
||||
}
|
||||
|
||||
const settings: Record<string, string> = {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { uploadImage } from "@/lib/eventSetup";
|
||||
|
||||
export function FileUploader({ onUploaded }: { onUploaded: (url: string) => void }) {
|
||||
const { token } = useAuth();
|
||||
@@ -16,14 +16,7 @@ export function FileUploader({ onUploaded }: { onUploaded: (url: string) => void
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("image", file);
|
||||
const data = await apiFetch<{ url: string }>(`/api/uploads/event-image`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
authToken: token,
|
||||
});
|
||||
onUploaded(data.url);
|
||||
onUploaded(await uploadImage(token, "event-image", file));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Upload failed");
|
||||
} finally {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+2219
File diff suppressed because it is too large
Load Diff
@@ -216,6 +216,17 @@ export async function fetchManagedEvents<T = Event>(token: string | null | undef
|
||||
return data as unknown as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One event in full (API v2 GET /events/{id}/full): ticket options with what's left, sessions,
|
||||
* form, attachments, bundles. Staff signed in (`authToken`) also see inactive and not-yet-live
|
||||
* events; `nextOptions` passes Next.js caching (e.g. revalidate) through for server pages.
|
||||
*/
|
||||
export async function fetchEvent<T = Event>(id: string, options: { authToken?: string | null; nextOptions?: RequestInit } = {}): Promise<T> {
|
||||
const { apiV2 } = await import("@/lib/apiV2");
|
||||
const { data } = await apiV2("get", "/events/{id}/full", { params: { id }, authToken: options.authToken, nextOptions: options.nextOptions });
|
||||
return data as unknown as T;
|
||||
}
|
||||
|
||||
export function fetchAllUsers(token: string, params: Record<string, string> = {}): Promise<User[]> {
|
||||
return fetchAllPages<User>("/api/users", token, params);
|
||||
}
|
||||
|
||||
@@ -27,18 +27,21 @@ type Body<O> = O extends { requestBody: { content: { "application/json": infer B
|
||||
|
||||
type IfAny<T, Then> = [T] extends [never] ? object : Then;
|
||||
export type V2Options<P extends keyof paths, M extends Method> =
|
||||
{ authToken?: string | null }
|
||||
{ authToken?: string | null; nextOptions?: RequestInit }
|
||||
& IfAny<PathParams<Op<P, M>>, { params: PathParams<Op<P, M>> }>
|
||||
& IfAny<QueryParams<Op<P, M>>, { query?: QueryParams<Op<P, M>> }>
|
||||
& IfAny<Body<Op<P, M>>, { body: Body<Op<P, M>> }>;
|
||||
|
||||
export function apiV2<M extends Method, P extends PathsWith<M>>(method: M, path: P, ...[options]: object extends V2Options<P, M> ? [V2Options<P, M>?] : [V2Options<P, M>]): Promise<V2Result<P, M>> {
|
||||
const opts = (options || {}) as { authToken?: string | null; params?: Record<string, string>; query?: Record<string, unknown>; body?: unknown };
|
||||
const opts = (options || {}) as { authToken?: string | null; nextOptions?: RequestInit; params?: Record<string, string>; query?: Record<string, unknown>; body?: unknown };
|
||||
let url = String(path).replace(/\{(\w+)\}/g, (_, name: string) => encodeURIComponent(String(opts.params?.[name] ?? "")));
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(opts.query || {})) {
|
||||
if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
|
||||
}
|
||||
if (qs.toString()) url += `?${qs.toString()}`;
|
||||
return apiFetch<V2Result<P, M>>(`/api/v2${url}`, { method: method.toUpperCase(), authToken: opts.authToken, body: opts.body });
|
||||
return apiFetch<V2Result<P, M>>(`/api/v2${url}`, { method: method.toUpperCase(), authToken: opts.authToken, body: opts.body, nextOptions: opts.nextOptions });
|
||||
}
|
||||
|
||||
/** The JSON body a v2 endpoint takes. */
|
||||
export type V2Body<P extends keyof paths, M extends Method> = Body<Op<P, M>>;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { apiV2, type V2Body } from "@/lib/apiV2";
|
||||
import type { EventAttachment, EventOption } from "@/types";
|
||||
|
||||
/**
|
||||
* Event set-up calls on API v2 (1.51.8): ticket options and variants, scanning sections,
|
||||
* attachments, planned costs and image uploads. Each returns what the screens already work with.
|
||||
*/
|
||||
|
||||
type Token = string | null | undefined;
|
||||
|
||||
// ─── Ticket options ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function createTicketOption(token: Token, eventId: string, body: V2Body<"/events/{id}/ticket-options", "post">): Promise<EventOption> {
|
||||
const { data } = await apiV2("post", "/events/{id}/ticket-options", { params: { id: eventId }, authToken: token, body });
|
||||
return data as unknown as EventOption;
|
||||
}
|
||||
|
||||
/** Only what's sent changes; `earlyBirdTiers` and `variants`, when sent, are the full lists. */
|
||||
export async function updateTicketOption(token: Token, optionId: string, body: V2Body<"/ticket-options/{id}", "patch">): Promise<EventOption> {
|
||||
const { data } = await apiV2("patch", "/ticket-options/{id}", { params: { id: optionId }, authToken: token, body });
|
||||
return data as unknown as EventOption;
|
||||
}
|
||||
|
||||
export async function deleteTicketOption(token: Token, optionId: string): Promise<void> {
|
||||
await apiV2("delete", "/ticket-options/{id}", { params: { id: optionId }, authToken: token });
|
||||
}
|
||||
|
||||
// ─── Scanning sections ───────────────────────────────────────────────────────
|
||||
|
||||
export type SectionRecord = { id: string; eventId: string; name: string; allowedOptions?: { eventOptionId: string; eventOption?: { id: string; name: string } }[] };
|
||||
|
||||
export async function listSections<T = SectionRecord>(token: Token, eventId?: string): Promise<T[]> {
|
||||
const { data } = await apiV2("get", "/sections", { authToken: token, query: eventId ? { eventId } : {} });
|
||||
return data as unknown as T[];
|
||||
}
|
||||
|
||||
export async function createSection(token: Token, body: V2Body<"/sections", "post">): Promise<SectionRecord> {
|
||||
const { data } = await apiV2("post", "/sections", { authToken: token, body });
|
||||
return data as unknown as SectionRecord;
|
||||
}
|
||||
|
||||
export async function updateSection(token: Token, sectionId: string, body: V2Body<"/sections/{id}", "patch">): Promise<SectionRecord> {
|
||||
const { data } = await apiV2("patch", "/sections/{id}", { params: { id: sectionId }, authToken: token, body });
|
||||
return data as unknown as SectionRecord;
|
||||
}
|
||||
|
||||
export async function deleteSection(token: Token, sectionId: string): Promise<void> {
|
||||
await apiV2("delete", "/sections/{id}", { params: { id: sectionId }, authToken: token });
|
||||
}
|
||||
|
||||
// ─── Attachments ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listAttachments(eventId: string): Promise<EventAttachment[]> {
|
||||
const { data } = await apiV2("get", "/events/{id}/attachments", { params: { id: eventId } });
|
||||
return data as unknown as EventAttachment[];
|
||||
}
|
||||
|
||||
/** Uploads a file (PDF, Office, text, CSV or ZIP, up to 20 MB) to an event. */
|
||||
export async function uploadAttachment(token: Token, eventId: string, file: File): Promise<EventAttachment> {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const { apiFetch } = await import("@/lib/api");
|
||||
const { data } = await apiFetch<{ data: EventAttachment }>(`/api/v2/events/${encodeURIComponent(eventId)}/attachments`, { method: "POST", body: fd, authToken: token });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteAttachment(token: Token, eventId: string, attachmentId: string): Promise<void> {
|
||||
await apiV2("delete", "/events/{id}/attachments/{attachmentId}", { params: { id: eventId, attachmentId }, authToken: token });
|
||||
}
|
||||
|
||||
// ─── Planned costs ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function createCost(token: Token, eventId: string, body: V2Body<"/events/{id}/costs", "post">): Promise<void> {
|
||||
await apiV2("post", "/events/{id}/costs", { params: { id: eventId }, authToken: token, body });
|
||||
}
|
||||
|
||||
export async function updateCost(token: Token, costId: string, body: V2Body<"/costs/{id}", "patch">): Promise<void> {
|
||||
await apiV2("patch", "/costs/{id}", { params: { id: costId }, authToken: token, body });
|
||||
}
|
||||
|
||||
export async function deleteCost(token: Token, costId: string): Promise<void> {
|
||||
await apiV2("delete", "/costs/{id}", { params: { id: costId }, authToken: token });
|
||||
}
|
||||
|
||||
// ─── Image uploads ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Uploads an image (field "image") and returns the path to save (`/uploads/…`). `logo` and
|
||||
* `favicon` work without a token during first-time setup.
|
||||
*/
|
||||
export async function uploadImage(token: Token, kind: "event-image" | "logo" | "favicon", file: File | Blob): Promise<string> {
|
||||
const fd = new FormData();
|
||||
fd.append("image", file);
|
||||
const { apiFetch } = await import("@/lib/api");
|
||||
const { data } = await apiFetch<{ data: { path: string; url: string } }>(`/api/v2/uploads/${kind}`, { method: "POST", body: fd, authToken: token });
|
||||
return data.path;
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.7",
|
||||
"version": "1.51.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.51.7",
|
||||
"version": "1.51.8",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.7",
|
||||
"version": "1.51.8",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user