Bundles get a kind: exact (as before), mix (any setSize of the qualifying tickets for a set price), free (cheapest freeCount in each set free) and group (setSize or more qualifying tickets at a per-ticket price or % off, applied once). The supervisor page has a type picker, buyers see a one-line description of each deal, and the Complete bundle cards handle every type. Demo: Family Fun Day adds a Lunch for four deal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
326 lines
15 KiB
JavaScript
326 lines
15 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { safeErrorMessage } = require('../utils/errorUtils');
|
|
const { logAdminAction } = require('../utils/adminAudit');
|
|
const { getClientIp } = require('../utils/requestUtils');
|
|
const { KINDS, describeBundle, describeBundleItems, isBundleAvailable } = require('../utils/bundles');
|
|
const { previewBundles } = require('../utils/coupons');
|
|
const { basketBuyer, priceBasket } = require('./couponController');
|
|
|
|
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
|
|
const fmtR = (n) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
|
|
const GROUP_PRICING = ['per_ticket', 'percent'];
|
|
|
|
const optionalDate = (v, label) => {
|
|
if (v === undefined) return undefined;
|
|
if (v === null || v === '') return null;
|
|
const d = new Date(v);
|
|
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
|
|
return d;
|
|
};
|
|
|
|
/** Standard (variant or option) price of one unit of an item. */
|
|
function itemPrice(i) {
|
|
const variant = i.variantId ? (i.eventOption?.variants || []).find(v => v.id === i.variantId) : null;
|
|
return Number((variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price) || 0);
|
|
}
|
|
|
|
/**
|
|
* What the deal is worth at standard prices: `standardValue` is what an exact set costs
|
|
* separately (or the dearest pick-any set); `savingText` is a short "save …" line, or
|
|
* null when the headline already says it.
|
|
*/
|
|
function valueOf(bundle) {
|
|
const kind = bundle.kind || 'exact';
|
|
const items = bundle.items || [];
|
|
const dearest = items.reduce((m, i) => Math.max(m, itemPrice(i)), 0);
|
|
const price = Number(bundle.price || 0);
|
|
if (kind === 'exact') {
|
|
const standard = round2(items.reduce((s, i) => s + itemPrice(i) * Number(i.quantity || 0), 0));
|
|
return { standardValue: standard, savingText: standard - price > 0.005 ? `save ${fmtR(standard - price)}` : null };
|
|
}
|
|
if (kind === 'mix') {
|
|
const standard = round2(dearest * Number(bundle.setSize || 0));
|
|
return { standardValue: standard, savingText: standard - price > 0.005 ? `save up to ${fmtR(standard - price)}` : null };
|
|
}
|
|
if (kind === 'free') {
|
|
return { standardValue: null, savingText: dearest > 0 ? `save up to ${fmtR(dearest * Number(bundle.freeCount || 0))} a set` : null };
|
|
}
|
|
if (bundle.groupPricing === 'per_ticket' && dearest - price > 0.005) {
|
|
return { standardValue: null, savingText: `save up to ${fmtR(dearest - price)} a ticket` };
|
|
}
|
|
return { standardValue: null, savingText: null };
|
|
}
|
|
|
|
// Items with prices loaded, so the standard value can be shown next to the bundle price.
|
|
const DETAIL_INCLUDE = {
|
|
items: {
|
|
include: {
|
|
eventOption: { select: { id: true, name: true, price: true, variants: { select: { id: true, price: true } } } },
|
|
variant: { select: { id: true, name: true } },
|
|
},
|
|
},
|
|
};
|
|
|
|
function shape(bundle, now = new Date()) {
|
|
const items = (bundle.items || []).map(({ eventOption, ...i }) => ({ ...i, eventOption: eventOption ? { id: eventOption.id, name: eventOption.name, price: eventOption.price } : null }));
|
|
return {
|
|
...bundle,
|
|
items,
|
|
summary: describeBundleItems(bundle),
|
|
headline: describeBundle(bundle),
|
|
...valueOf(bundle),
|
|
available: isBundleAvailable(bundle, now),
|
|
};
|
|
}
|
|
|
|
const PRICING_FIELDS = ['kind', 'price', 'setSize', 'freeCount', 'groupPricing', 'items'];
|
|
|
|
/**
|
|
* Turns a create/update body into Prisma data plus the item list. On update, fields
|
|
* left out of the body are left alone; when any pricing field is sent, the bundle as a
|
|
* whole (saved values plus the new ones) is checked. Items must belong to the event.
|
|
*/
|
|
async function parseBundleBody(body, eventId, { isCreate, current = null }) {
|
|
const data = {};
|
|
if (isCreate || body.name !== undefined) {
|
|
const name = String(body.name || '').trim();
|
|
if (!name) throw new Error('Give the bundle a name');
|
|
if (name.length > 80) throw new Error('Keep the name under 80 characters');
|
|
data.name = name;
|
|
}
|
|
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
|
if (body.isActive !== undefined) data.isActive = !!body.isActive;
|
|
if (body.order !== undefined) data.order = Number.isInteger(Number(body.order)) ? Number(body.order) : 0;
|
|
const validFrom = optionalDate(body.validFrom, 'Available from');
|
|
const validUntil = optionalDate(body.validUntil, 'Available until');
|
|
if (validFrom !== undefined) data.validFrom = validFrom;
|
|
if (validUntil !== undefined) data.validUntil = validUntil;
|
|
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Available from" must be before "Available until"');
|
|
|
|
let items;
|
|
if (!isCreate && !PRICING_FIELDS.some(f => body[f] !== undefined)) return { data, items };
|
|
|
|
const pick = (f) => (body[f] !== undefined ? body[f] : current?.[f]);
|
|
const kind = pick('kind') || 'exact';
|
|
if (!KINDS.includes(kind)) throw new Error('Unknown bundle type');
|
|
data.kind = kind;
|
|
|
|
const rawItems = body.items !== undefined ? body.items : (current?.items || []);
|
|
// The same option/variant twice becomes one line.
|
|
const merged = new Map();
|
|
for (const it of Array.isArray(rawItems) ? rawItems : []) {
|
|
if (!it || !it.eventOptionId) continue;
|
|
const quantity = kind === 'exact' ? Number(it.quantity) : 1;
|
|
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Each ticket needs a quantity of at least 1');
|
|
const key = `${it.eventOptionId}::${it.variantId || ''}`;
|
|
const prev = merged.get(key);
|
|
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: kind === 'exact' ? (prev?.quantity || 0) + quantity : 1 });
|
|
}
|
|
items = [...merged.values()];
|
|
if (items.length === 0) throw new Error(kind === 'exact' ? 'Add at least one ticket to the bundle' : 'Choose at least one qualifying ticket');
|
|
|
|
const price = Number(pick('price'));
|
|
const setSize = Number(pick('setSize'));
|
|
const freeCount = Number(pick('freeCount'));
|
|
const groupPricing = pick('groupPricing');
|
|
const priceGiven = pick('price') !== '' && pick('price') !== null && pick('price') !== undefined;
|
|
|
|
if (kind === 'exact') {
|
|
if (items.reduce((s, i) => s + i.quantity, 0) < 2) throw new Error('A bundle needs at least two tickets in it');
|
|
if (!priceGiven || !(price >= 0)) throw new Error('Enter the bundle price (zero or more)');
|
|
Object.assign(data, { price: round2(price), setSize: null, freeCount: null, groupPricing: null });
|
|
} else if (kind === 'mix') {
|
|
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A pick-any bundle needs at least 2 tickets per set');
|
|
if (!priceGiven || !(price >= 0)) throw new Error('Enter the price for the set (zero or more)');
|
|
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing: null });
|
|
} else if (kind === 'free') {
|
|
if (!Number.isInteger(freeCount) || freeCount < 1) throw new Error('At least one ticket must be free');
|
|
if (!Number.isInteger(setSize) || setSize <= freeCount) throw new Error('Buy at least one ticket to get the free ones');
|
|
Object.assign(data, { price: 0, setSize, freeCount, groupPricing: null });
|
|
} else {
|
|
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A group needs at least 2 tickets');
|
|
if (!GROUP_PRICING.includes(groupPricing)) throw new Error('Choose a price per ticket or a percentage off');
|
|
if (groupPricing === 'percent' && !(price > 0 && price <= 100)) throw new Error('Enter a percentage between 1 and 100');
|
|
if (groupPricing === 'per_ticket' && (!priceGiven || !(price >= 0))) throw new Error('Enter the price per ticket (zero or more)');
|
|
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing });
|
|
}
|
|
|
|
const options = await prisma.eventOption.findMany({
|
|
where: { id: { in: items.map(i => i.eventOptionId) } },
|
|
select: { id: true, eventId: true, variants: { select: { id: true } } },
|
|
});
|
|
for (const i of items) {
|
|
const row = options.find(o => o.id === i.eventOptionId);
|
|
if (!row || row.eventId !== eventId) throw new Error('One of the tickets isn\'t part of this event');
|
|
if (i.variantId && !row.variants.some(v => v.id === i.variantId)) throw new Error('One of the selected variants no longer exists');
|
|
}
|
|
return { data, items };
|
|
}
|
|
|
|
async function eventOr404(eventId, res) {
|
|
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true } });
|
|
if (!event) { res.status(404); throw new Error('Event not found'); }
|
|
return event;
|
|
}
|
|
|
|
// @desc An event's bundle deals with how often each has been used
|
|
// @route GET /api/bundles/event/:eventId
|
|
// @access Private/Supervisor
|
|
const listBundles = async (req, res) => {
|
|
try {
|
|
await eventOr404(req.params.eventId, res);
|
|
const bundles = await prisma.eventBundle.findMany({
|
|
where: { eventId: req.params.eventId },
|
|
include: DETAIL_INCLUDE,
|
|
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
|
|
});
|
|
const totals = await prisma.registrationBundle.groupBy({
|
|
by: ['bundleId'],
|
|
where: { bundle: { eventId: req.params.eventId }, registration: { status: { not: 'cancelled' } } },
|
|
_sum: { count: true, discount: true },
|
|
_count: { _all: true },
|
|
});
|
|
const byId = Object.fromEntries(totals.map(t => [t.bundleId, t]));
|
|
const now = new Date();
|
|
res.json(bundles.map(b => ({
|
|
...shape(b, now),
|
|
uses: byId[b.id]?._sum?.count || 0,
|
|
registrations: byId[b.id]?._count?._all || 0,
|
|
totalDiscount: round2(byId[b.id]?._sum?.discount || 0),
|
|
})));
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc Create a bundle deal
|
|
// @route POST /api/bundles/event/:eventId
|
|
// @access Private/Supervisor
|
|
const createBundle = async (req, res) => {
|
|
try {
|
|
const event = await eventOr404(req.params.eventId, res);
|
|
let parsed;
|
|
try {
|
|
parsed = await parseBundleBody(req.body || {}, event.id, { isCreate: true });
|
|
} catch (e) { res.status(400); throw e; }
|
|
const bundle = await prisma.eventBundle.create({
|
|
data: { ...parsed.data, eventId: event.id, items: { create: parsed.items } },
|
|
include: DETAIL_INCLUDE,
|
|
});
|
|
logAdminAction({
|
|
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_created',
|
|
targetType: 'EventBundle', targetId: bundle.id,
|
|
metadata: { eventId: event.id, name: bundle.name, kind: bundle.kind, deal: describeBundle(bundle) },
|
|
ip: getClientIp(req),
|
|
});
|
|
res.status(201).json({ ...shape(bundle), uses: 0, registrations: 0, totalDiscount: 0 });
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc Edit a bundle deal (including switching it off). Registrations that already
|
|
// have it are only re-worked when their own items change.
|
|
// @route PUT /api/bundles/:id
|
|
// @access Private/Supervisor
|
|
const updateBundle = async (req, res) => {
|
|
try {
|
|
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id }, include: { items: true } });
|
|
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
|
|
let parsed;
|
|
try {
|
|
parsed = await parseBundleBody(req.body || {}, existing.eventId, { isCreate: false, current: existing });
|
|
} catch (e) { res.status(400); throw e; }
|
|
const bundle = await prisma.eventBundle.update({
|
|
where: { id: existing.id },
|
|
data: { ...parsed.data, ...(parsed.items ? { items: { deleteMany: {}, create: parsed.items } } : {}) },
|
|
include: DETAIL_INCLUDE,
|
|
});
|
|
logAdminAction({
|
|
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_updated',
|
|
targetType: 'EventBundle', targetId: bundle.id,
|
|
metadata: { eventId: bundle.eventId, name: bundle.name, changedKeys: Object.keys(req.body || {}) },
|
|
ip: getClientIp(req),
|
|
});
|
|
res.json(shape(bundle));
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc Delete a bundle deal. One that's been used is switched off instead, so the
|
|
// registrations that got it keep their price.
|
|
// @route DELETE /api/bundles/:id
|
|
// @access Private/Supervisor
|
|
const deleteBundle = async (req, res) => {
|
|
try {
|
|
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id } });
|
|
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
|
|
const used = await prisma.registrationBundle.count({ where: { bundleId: existing.id } });
|
|
if (used > 0) {
|
|
await prisma.eventBundle.update({ where: { id: existing.id }, data: { isActive: false } });
|
|
} else {
|
|
await prisma.eventBundle.delete({ where: { id: existing.id } });
|
|
}
|
|
logAdminAction({
|
|
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_deleted',
|
|
targetType: 'EventBundle', targetId: existing.id,
|
|
metadata: { eventId: existing.eventId, name: existing.name, switchedOff: used > 0 },
|
|
ip: getClientIp(req),
|
|
});
|
|
res.json(used > 0
|
|
? { switchedOff: true, message: 'This bundle has been used, so it\'s been switched off instead. Registrations that got it keep their price.' }
|
|
: { deleted: true, message: 'Bundle deleted' });
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc The bundle deals a basket gets, before any coupon code
|
|
// @route POST /api/bundles/quote
|
|
// @access Public. Body: { eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
|
|
const quoteBundles = async (req, res) => {
|
|
try {
|
|
const { eventId, options, forUserId } = req.body || {};
|
|
if (!eventId || !Array.isArray(options)) { res.status(400); throw new Error('eventId and options are required'); }
|
|
const event = await prisma.event.findUnique({
|
|
where: { id: eventId },
|
|
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
|
|
});
|
|
if (!event) { res.status(404); throw new Error('Event not found'); }
|
|
const { buyer } = await basketBuyer(req, forUserId);
|
|
const lines = await priceBasket(event, options, buyer);
|
|
const subtotal = round2(lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0));
|
|
const bundles = await previewBundles(eventId, lines);
|
|
res.json({
|
|
subtotal,
|
|
bundleDiscount: bundles.discount,
|
|
bundles: bundles.applications,
|
|
total: round2(Math.max(0, subtotal - bundles.discount)),
|
|
});
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
/** The deals to show on an event's page: available bundles, without internal fields. */
|
|
async function publicBundlesForEvent(eventId) {
|
|
const bundles = await prisma.eventBundle.findMany({
|
|
where: { eventId, isActive: true },
|
|
include: DETAIL_INCLUDE,
|
|
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
|
|
});
|
|
const now = new Date();
|
|
return bundles.filter(b => isBundleAvailable(b, now)).map(b => {
|
|
const s = shape(b, now);
|
|
return {
|
|
id: s.id, name: s.name, description: s.description, validUntil: s.validUntil,
|
|
kind: s.kind, price: s.price, setSize: s.setSize, freeCount: s.freeCount, groupPricing: s.groupPricing,
|
|
summary: s.summary, headline: s.headline, standardValue: s.standardValue, savingText: s.savingText,
|
|
items: s.items.map(i => ({ eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: i.quantity })),
|
|
};
|
|
});
|
|
}
|
|
|
|
module.exports = { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles, publicBundlesForEvent };
|