Reconciling a Yoco payment and sending payment links blocked the HTTP response on ticket-PDF generation and email/WhatsApp sends; they now run in the background like the other payment flows already did. Registration/payment option loops (pricing, stock checks, ticket generation) now resolve concurrently instead of sequentially. The Payments page dropped a per-registration N+1 fetch and now refreshes its lists in parallel after each action. Added missing indexes for dashboard stats and donation-leg lookups, and made GET /api/registrations optionally paginated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
148 lines
6.1 KiB
JavaScript
148 lines
6.1 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
|
|
/**
|
|
* Generate tickets for a registration when payment status is "paid".
|
|
*
|
|
* Rules:
|
|
* - Exactly ONE ticket per registrationOption (keyed by eventOptionId).
|
|
* - If duplicate registrationOptions exist for the same eventOptionId (old data),
|
|
* consolidate them: merge quantities, keep the one with scan history, delete the rest.
|
|
* - If duplicate ticket records exist for the same registrationOption (old data),
|
|
* keep the primary (scanned one, or oldest), update its quantity, delete the rest.
|
|
*
|
|
* @param {string} registrationId
|
|
* @returns {Promise<Array>} newly-created ticket records (empty when all already existed)
|
|
*/
|
|
const generateTicketsForRegistration = async (registrationId) => {
|
|
try {
|
|
const registration = await prisma.registration.findUnique({
|
|
where: { id: registrationId },
|
|
include: {
|
|
registrationOptions: {
|
|
include: {
|
|
eventOption: true,
|
|
tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } }
|
|
}
|
|
},
|
|
event: true,
|
|
user: true
|
|
}
|
|
});
|
|
|
|
if (!registration) throw new Error('Registration not found');
|
|
if (registration.status !== 'paid') return [];
|
|
|
|
// If event has a required form, ensure sufficient responses exist
|
|
try {
|
|
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } });
|
|
if (form && form.isRequired) {
|
|
const requiredCount = (registration.registrationOptions || [])
|
|
.filter(ro => ro.eventOption?.isMainTicket)
|
|
.reduce((s, ro) => s + (ro.quantity || 0), 0);
|
|
const responsesCount = await prisma.formResponse.count({ where: { registrationId } });
|
|
if (responsesCount < requiredCount) return [];
|
|
}
|
|
} catch (_) { /* form models unavailable — don't block */ }
|
|
|
|
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId) ──
|
|
// Key on both fields so that different variants of the same option are never merged
|
|
const byOption = new Map();
|
|
for (const ro of registration.registrationOptions) {
|
|
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
|
|
if (!byOption.has(key)) byOption.set(key, []);
|
|
byOption.get(key).push(ro);
|
|
}
|
|
|
|
// For each group with duplicates, merge into the one that has tickets (or the first).
|
|
// Different (eventOptionId, variantId) groups touch disjoint rows, so process groups
|
|
// concurrently instead of one at a time.
|
|
await Promise.all(Array.from(byOption.values()).map(async (group) => {
|
|
if (group.length <= 1) return;
|
|
|
|
// Prefer the option that already has tickets
|
|
const withTickets = group.filter(ro => (ro.tickets || []).length > 0);
|
|
const primary = withTickets.length > 0 ? withTickets[0] : group[0];
|
|
const duplicates = group.filter(ro => ro.id !== primary.id);
|
|
|
|
// Move all tickets from duplicates to primary (independent rows — safe to parallelize)
|
|
await Promise.all(duplicates.map(dup => Promise.all(
|
|
(dup.tickets || []).map(t =>
|
|
prisma.ticket.update({ where: { id: t.id }, data: { registrationOptionId: primary.id, updatedAt: new Date() } })
|
|
)
|
|
)));
|
|
|
|
// Single write of the merged quantity, then delete the now-empty duplicate options
|
|
const totalMergedQty = duplicates.reduce((s, d) => s + (d.quantity || 0), 0);
|
|
await prisma.registrationOption.update({
|
|
where: { id: primary.id },
|
|
data: { quantity: (primary.quantity || 0) + totalMergedQty }
|
|
});
|
|
await Promise.all(duplicates.map(dup => prisma.registrationOption.delete({ where: { id: dup.id } })));
|
|
|
|
// Re-load the primary's current quantity + tickets after merge
|
|
const updated = await prisma.registrationOption.findUnique({ where: { id: primary.id } });
|
|
primary.quantity = updated?.quantity ?? primary.quantity;
|
|
primary.tickets = await prisma.ticket.findMany({
|
|
where: { registrationOptionId: primary.id },
|
|
include: { usages: true },
|
|
orderBy: { createdAt: 'asc' }
|
|
});
|
|
}));
|
|
|
|
// ── Step 2: For each unique option, ensure exactly one ticket with correct qty ──
|
|
// Re-read fresh list (some options may have been deleted above)
|
|
const freshOptions = await prisma.registrationOption.findMany({
|
|
where: { registrationId },
|
|
include: {
|
|
tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } }
|
|
}
|
|
});
|
|
|
|
// Each option owns disjoint tickets, so resolve them concurrently instead of one at a time.
|
|
const perOptionResults = await Promise.all(freshOptions.map(async (option) => {
|
|
const targetQty = option.quantity || 1;
|
|
const existingTickets = option.tickets || [];
|
|
|
|
if (existingTickets.length === 0) {
|
|
// Create one ticket
|
|
return prisma.ticket.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
qrCode: uuidv4(),
|
|
registrationOptionId: option.id,
|
|
userId: registration.userId,
|
|
eventId: registration.eventId,
|
|
quantity: targetQty,
|
|
updatedAt: new Date()
|
|
}
|
|
});
|
|
}
|
|
|
|
// Pick primary: prefer scanned, otherwise oldest
|
|
const withUsages = existingTickets.filter(t => (t.usages || []).length > 0);
|
|
const primary = withUsages.length > 0 ? withUsages[0] : existingTickets[0];
|
|
|
|
const updates = [];
|
|
// Update quantity on primary if needed
|
|
if (primary.quantity !== targetQty) {
|
|
updates.push(prisma.ticket.update({ where: { id: primary.id }, data: { quantity: targetQty, updatedAt: new Date() } }));
|
|
}
|
|
|
|
// Delete unscanned duplicates
|
|
const dups = existingTickets.filter(t => t.id !== primary.id && (t.usages || []).length === 0);
|
|
if (dups.length > 0) {
|
|
updates.push(prisma.ticket.deleteMany({ where: { id: { in: dups.map(t => t.id) } } }));
|
|
}
|
|
if (updates.length > 0) await Promise.all(updates);
|
|
return null;
|
|
}));
|
|
|
|
return perOptionResults.filter(Boolean);
|
|
} catch (error) {
|
|
console.error('Error generating tickets:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
module.exports = { generateTicketsForRegistration }; |