Options on a multi-day event can be bought per session (one line and ticket per session, priced per session, bookable until the session starts). Sessions can have a capacity covering picked places and whole-course tickets. Picked tickets only scan on their own session; the register, certificates and reminders follow the sessions booked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
17 KiB
JavaScript
373 lines
17 KiB
JavaScript
const prisma = require('../config/db');
|
||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||
const { logAdminAction } = require('../utils/adminAudit');
|
||
const { getClientIp } = require('../utils/requestUtils');
|
||
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
|
||
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
|
||
const { describeBundle } = require('../utils/bundles');
|
||
|
||
const TYPES = ['percent', 'fixed', 'comp'];
|
||
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
|
||
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
|
||
|
||
const COUPON_DETAIL_INCLUDE = {
|
||
events: { include: { event: { select: { id: true, title: true, startDate: true } } } },
|
||
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
|
||
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||
createdBy: { select: { id: true, name: true } },
|
||
};
|
||
|
||
const optionalDate = (v, label) => {
|
||
if (v === undefined) return undefined;
|
||
if (v === null || v === '') return null;
|
||
const d = new Date(v);
|
||
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
|
||
return d;
|
||
};
|
||
|
||
const optionalInt = (v, label, min = 0) => {
|
||
if (v === undefined) return undefined;
|
||
if (v === null || v === '') return null;
|
||
const n = Number(v);
|
||
if (!Number.isInteger(n) || n < min) throw new Error(`${label} must be a whole number of at least ${min}`);
|
||
return n;
|
||
};
|
||
|
||
/**
|
||
* Turns a create/update body into Prisma data plus the relation lists. On update,
|
||
* fields left out of the body are left alone.
|
||
*/
|
||
async function parseCouponBody(body, { isCreate }) {
|
||
const data = {};
|
||
if (isCreate || body.code !== undefined) {
|
||
const code = normalizeCode(body.code);
|
||
if (!/^[A-Z0-9_-]{3,32}$/.test(code)) {
|
||
throw new Error('Codes are 3–32 letters, numbers, hyphens or underscores');
|
||
}
|
||
data.code = code;
|
||
}
|
||
if (isCreate || body.type !== undefined) {
|
||
if (!TYPES.includes(body.type)) throw new Error("Discount type must be 'percent', 'fixed' or 'comp'");
|
||
data.type = body.type;
|
||
}
|
||
if (isCreate || body.value !== undefined) {
|
||
const type = data.type || body.type;
|
||
const value = type === 'comp' ? 0 : Number(body.value);
|
||
if (type !== 'comp' && !(value > 0)) throw new Error('Enter a discount value greater than zero');
|
||
if (type === 'percent' && value > 100) throw new Error('A percentage discount can\'t be more than 100');
|
||
data.value = value;
|
||
}
|
||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||
if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird;
|
||
if (body.active !== undefined) data.active = !!body.active;
|
||
const validFrom = optionalDate(body.validFrom, 'Valid from');
|
||
const validUntil = optionalDate(body.validUntil, 'Valid until');
|
||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Valid from" must be before "Valid until"');
|
||
const maxUses = optionalInt(body.maxUses, 'Max total uses', 1);
|
||
const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1);
|
||
const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1);
|
||
if (maxUses !== undefined) data.maxUses = maxUses;
|
||
if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser;
|
||
if (minQuantity !== undefined) data.minQuantity = minQuantity;
|
||
if (body.restrictRoles !== undefined) {
|
||
const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : [];
|
||
if (roles.some(r => !ROLES.includes(r))) throw new Error('Unknown role in restrictions');
|
||
data.restrictRoles = roles;
|
||
}
|
||
if (body.restrictDomain !== undefined) {
|
||
const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
|
||
if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) throw new Error('Enter an email domain like church.org');
|
||
data.restrictDomain = domain || null;
|
||
}
|
||
|
||
const eventIds = body.eventIds !== undefined ? [...new Set((body.eventIds || []).filter(Boolean))] : undefined;
|
||
const scopes = body.options !== undefined
|
||
? (body.options || []).filter(o => o && o.eventOptionId).map(o => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null }))
|
||
: undefined;
|
||
const userIds = body.userIds !== undefined ? [...new Set((body.userIds || []).filter(Boolean))] : undefined;
|
||
|
||
if (scopes && scopes.length > 0) {
|
||
const optionRows = await prisma.eventOption.findMany({
|
||
where: { id: { in: scopes.map(s => s.eventOptionId) } },
|
||
select: { id: true, eventId: true, variants: { select: { id: true } } },
|
||
});
|
||
for (const s of scopes) {
|
||
const row = optionRows.find(o => o.id === s.eventOptionId);
|
||
if (!row) throw new Error('One of the selected ticket options no longer exists');
|
||
if (s.variantId && !row.variants.some(v => v.id === s.variantId)) throw new Error('One of the selected variants no longer exists');
|
||
// A scoped option implies its event, so the code can't be used elsewhere by mistake.
|
||
if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) {
|
||
throw new Error('Selected ticket options must belong to the selected events');
|
||
}
|
||
}
|
||
}
|
||
return { data, eventIds, scopes, userIds };
|
||
}
|
||
|
||
function relationWrites({ eventIds, scopes, userIds }, isCreate) {
|
||
const out = {};
|
||
if (eventIds !== undefined) out.events = { ...(isCreate ? {} : { deleteMany: {} }), create: eventIds.map(eventId => ({ eventId })) };
|
||
if (scopes !== undefined) out.options = { ...(isCreate ? {} : { deleteMany: {} }), create: scopes };
|
||
if (userIds !== undefined) out.allowedUsers = { ...(isCreate ? {} : { deleteMany: {} }), create: userIds.map(userId => ({ userId })) };
|
||
return out;
|
||
}
|
||
|
||
// @desc List coupons with their use counts
|
||
// @route GET /api/coupons
|
||
// @access Private/Supervisor
|
||
const listCoupons = async (req, res) => {
|
||
try {
|
||
const coupons = await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } });
|
||
const totals = await prisma.couponRedemption.groupBy({
|
||
by: ['couponId'],
|
||
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
|
||
_count: { _all: true },
|
||
_sum: { discountAmount: true },
|
||
});
|
||
const byId = Object.fromEntries(totals.map(t => [t.couponId, t]));
|
||
res.json(coupons.map(c => ({
|
||
...c,
|
||
summary: describeCoupon(c),
|
||
uses: byId[c.id]?._count?._all || 0,
|
||
totalDiscount: byId[c.id]?._sum?.discountAmount || 0,
|
||
})));
|
||
} catch (error) {
|
||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||
}
|
||
};
|
||
|
||
// @desc Get one coupon with its redemptions
|
||
// @route GET /api/coupons/:id
|
||
// @access Private/Supervisor
|
||
const getCoupon = async (req, res) => {
|
||
try {
|
||
const coupon = await prisma.coupon.findUnique({
|
||
where: { id: req.params.id },
|
||
include: {
|
||
...COUPON_DETAIL_INCLUDE,
|
||
redemptions: {
|
||
orderBy: { createdAt: 'desc' },
|
||
include: {
|
||
user: { select: { id: true, name: true, email: true } },
|
||
appliedBy: { select: { id: true, name: true } },
|
||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||
},
|
||
},
|
||
},
|
||
});
|
||
if (!coupon) { res.status(404); throw new Error('Coupon not found'); }
|
||
res.json({ ...coupon, summary: describeCoupon(coupon) });
|
||
} catch (error) {
|
||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||
}
|
||
};
|
||
|
||
// @desc Create a coupon
|
||
// @route POST /api/coupons
|
||
// @access Private/Supervisor
|
||
const createCoupon = async (req, res) => {
|
||
try {
|
||
let parsed;
|
||
try {
|
||
parsed = await parseCouponBody(req.body || {}, { isCreate: true });
|
||
} catch (e) { res.status(400); throw e; }
|
||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
|
||
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
|
||
}
|
||
const coupon = await prisma.coupon.create({
|
||
data: { ...parsed.data, createdById: req.user.id, ...relationWrites(parsed, true) },
|
||
include: COUPON_DETAIL_INCLUDE,
|
||
});
|
||
logAdminAction({
|
||
actorId: req.user.id, actorRole: req.user.role, action: 'coupon_created',
|
||
targetType: 'Coupon', targetId: coupon.id,
|
||
metadata: { code: coupon.code, type: coupon.type, value: coupon.value },
|
||
ip: getClientIp(req),
|
||
});
|
||
res.status(201).json({ ...coupon, summary: describeCoupon(coupon), uses: 0, totalDiscount: 0 });
|
||
} catch (error) {
|
||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||
}
|
||
};
|
||
|
||
// @desc Update a coupon (including turning it off). Discounts already applied keep
|
||
// their locked amount.
|
||
// @route PUT /api/coupons/:id
|
||
// @access Private/Supervisor
|
||
const updateCoupon = async (req, res) => {
|
||
try {
|
||
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id } });
|
||
if (!existing) { res.status(404); throw new Error('Coupon not found'); }
|
||
let parsed;
|
||
try {
|
||
parsed = await parseCouponBody({ type: existing.type, ...req.body }, { isCreate: false });
|
||
} catch (e) { res.status(400); throw e; }
|
||
if (parsed.data.code && parsed.data.code !== existing.code) {
|
||
const used = await prisma.couponRedemption.count({ where: { couponId: existing.id } });
|
||
if (used > 0) { res.status(400); throw new Error('This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.'); }
|
||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
|
||
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
|
||
}
|
||
}
|
||
const coupon = await prisma.coupon.update({
|
||
where: { id: existing.id },
|
||
data: { ...parsed.data, ...relationWrites(parsed, false) },
|
||
include: COUPON_DETAIL_INCLUDE,
|
||
});
|
||
const disabled = existing.active && coupon.active === false;
|
||
const changedKeys = Object.keys(req.body || {});
|
||
logAdminAction({
|
||
actorId: req.user.id, actorRole: req.user.role,
|
||
action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated',
|
||
targetType: 'Coupon', targetId: coupon.id,
|
||
metadata: { code: coupon.code, changedKeys },
|
||
ip: getClientIp(req),
|
||
});
|
||
const { usesTotal } = await countCouponUses(coupon.id, null);
|
||
res.json({ ...coupon, summary: describeCoupon(coupon), uses: usesTotal });
|
||
} catch (error) {
|
||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Who a basket is being priced for: the logged-in buyer, or, for staff, the attendee
|
||
* (`forUserId`) or a walk-in with no account yet (`forUserId: null`).
|
||
*/
|
||
async function basketBuyer(req, forUserId) {
|
||
const isStaff = !!(req.user && STAFF_ROLES.includes(req.user.role));
|
||
let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
|
||
if (isStaff && forUserId) {
|
||
buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
|
||
} else if (isStaff && forUserId === null) {
|
||
buyer = null; // a walk-in without an account yet
|
||
}
|
||
return { buyer, isStaff };
|
||
}
|
||
|
||
/** Prices a basket the same way registration will, as coupon/bundle lines. */
|
||
async function priceBasket(event, options, buyer) {
|
||
const registrationOptions = [];
|
||
// A "pick sessions" line counts once per chosen session.
|
||
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
|
||
for (const o of expanded) {
|
||
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
|
||
const qty = Number(o.quantity || 0);
|
||
if (!eo || qty < 1) continue;
|
||
const resolved = o.variantId
|
||
? await resolveVariantTierPrice(eo, o.variantId, qty, buyer)
|
||
: await resolveOptionPrice(eo, qty, buyer);
|
||
registrationOptions.push({
|
||
eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo,
|
||
tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }],
|
||
});
|
||
}
|
||
return buildCouponLines({ registrationOptions });
|
||
}
|
||
|
||
// @desc Check a code against the items someone is about to register for
|
||
// @route POST /api/coupons/validate
|
||
// @access Public (login needed for restricted codes)
|
||
// Body: { code, eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
|
||
// Staff can pass forUserId to check on an attendee's behalf, or leave it out for a new
|
||
// attendee — restricted codes are then checked when the registration is saved.
|
||
const validateCode = async (req, res) => {
|
||
try {
|
||
const { code, eventId, options, forUserId } = req.body || {};
|
||
if (!code || !eventId || !Array.isArray(options) || options.length === 0) {
|
||
res.status(400); throw new Error('code, eventId and options are required');
|
||
}
|
||
const event = await prisma.event.findUnique({
|
||
where: { id: eventId },
|
||
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
|
||
});
|
||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||
|
||
const { buyer, isStaff } = await basketBuyer(req, forUserId);
|
||
const lines = await priceBasket(event, options, buyer);
|
||
const subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||
|
||
try {
|
||
const { coupon, discount, bundles } = await previewCoupon({
|
||
code, eventId, user: buyer, lines,
|
||
deferRestrictions: isStaff && !buyer,
|
||
});
|
||
res.json({
|
||
valid: true,
|
||
code: coupon.code,
|
||
type: coupon.type,
|
||
value: coupon.value,
|
||
summary: describeCoupon(coupon),
|
||
description: coupon.description,
|
||
discount,
|
||
bundleDiscount: bundles.discount,
|
||
bundles: bundles.applications,
|
||
subtotal,
|
||
total: Math.max(0, subtotal - bundles.discount - discount),
|
||
});
|
||
} catch (e) {
|
||
if (!e.isCouponError) throw e;
|
||
res.status(400).json({ valid: false, message: e.message });
|
||
}
|
||
} catch (error) {
|
||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||
}
|
||
};
|
||
|
||
// @desc Coupon and bundle-deal uses for the Discounts report
|
||
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to=
|
||
// @access Private/Supervisor
|
||
const listRedemptions = async (req, res) => {
|
||
try {
|
||
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
|
||
const from = req.query.from ? new Date(req.query.from) : null;
|
||
const to = req.query.to ? new Date(req.query.to) : null;
|
||
const where = {
|
||
releasedAt: null,
|
||
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}) },
|
||
...(from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}),
|
||
};
|
||
const rows = await prisma.couponRedemption.findMany({
|
||
where,
|
||
orderBy: { createdAt: 'desc' },
|
||
include: {
|
||
coupon: { select: { id: true, code: true, type: true, value: true } },
|
||
user: { select: { id: true, name: true, email: true } },
|
||
appliedBy: { select: { id: true, name: true } },
|
||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||
},
|
||
});
|
||
// Bundle deals, in the same shape. Their rows are rebuilt whenever a registration's
|
||
// discounts are recalculated, so dates go by when the registration was made.
|
||
const regDate = from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {};
|
||
const bundleRows = await prisma.registrationBundle.findMany({
|
||
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate } },
|
||
include: {
|
||
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
|
||
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
|
||
},
|
||
});
|
||
res.json([
|
||
...rows.map(r => ({ ...r, kind: 'coupon', uses: 1, summary: describeCoupon(r.coupon) })),
|
||
...bundleRows.map(b => ({
|
||
id: b.id,
|
||
kind: 'bundle',
|
||
uses: b.count,
|
||
discountAmount: b.discount,
|
||
createdAt: b.registration.createdAt,
|
||
summary: describeBundle(b.bundle),
|
||
coupon: { id: b.bundleId, code: b.bundle.name },
|
||
user: b.registration.user,
|
||
appliedBy: null,
|
||
registration: { id: b.registration.id, status: b.registration.status, event: b.registration.event },
|
||
})),
|
||
]);
|
||
} catch (error) {
|
||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||
}
|
||
};
|
||
|
||
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions, basketBuyer, priceBasket };
|