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