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 };