Speed up payment/refund processing on the supervisor dashboard
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>
This commit is contained in:
@@ -161,18 +161,18 @@ async function refreshPricingForRegistration(registrationId) {
|
||||
});
|
||||
if (!registration) return { changed: false };
|
||||
|
||||
let anyChanged = false;
|
||||
|
||||
for (const ro of registration.registrationOptions) {
|
||||
// Each registrationOption is independent, so resolve/update them concurrently
|
||||
// instead of one at a time — this loop sits directly in the payment-capture path.
|
||||
const results = await Promise.all(registration.registrationOptions.map(async (ro) => {
|
||||
// Only refresh options that were priced via a tier
|
||||
if (!ro.appliedTierId) continue;
|
||||
if (!ro.appliedTierId) return false;
|
||||
|
||||
// Find the currently applied tier
|
||||
const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === ro.appliedTierId);
|
||||
|
||||
if (currentTier && new Date() < new Date(currentTier.deadline)) {
|
||||
// The tier's deadline is still in the future — honor the locked price.
|
||||
continue;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deadline has passed (or tier record missing) — resolve the next applicable tier
|
||||
@@ -191,11 +191,12 @@ async function refreshPricingForRegistration(registrationId) {
|
||||
appliedTierId: resolved.tierId
|
||||
}
|
||||
});
|
||||
anyChanged = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
|
||||
return { changed: anyChanged };
|
||||
return { changed: results.some(Boolean) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,42 +54,43 @@ const generateTicketsForRegistration = async (registrationId) => {
|
||||
byOption.get(key).push(ro);
|
||||
}
|
||||
|
||||
// For each group with duplicates, merge into the one that has tickets (or the first)
|
||||
for (const [, group] of byOption) {
|
||||
if (group.length <= 1) continue;
|
||||
// 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, then delete duplicate options
|
||||
for (const dup of duplicates) {
|
||||
for (const t of (dup.tickets || [])) {
|
||||
await prisma.ticket.update({ where: { id: t.id }, data: { registrationOptionId: primary.id, updatedAt: new Date() } });
|
||||
}
|
||||
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 prisma.registrationOption.delete({ where: { id: dup.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() } })
|
||||
)
|
||||
)));
|
||||
|
||||
// Re-load the primary's current quantity after merge
|
||||
// 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;
|
||||
// Reload tickets
|
||||
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 ──
|
||||
const generatedTickets = [];
|
||||
|
||||
// Re-read fresh list (some options may have been deleted above)
|
||||
const freshOptions = await prisma.registrationOption.findMany({
|
||||
where: { registrationId },
|
||||
@@ -98,13 +99,14 @@ const generateTicketsForRegistration = async (registrationId) => {
|
||||
}
|
||||
});
|
||||
|
||||
for (const option of freshOptions) {
|
||||
// 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
|
||||
const ticket = await prisma.ticket.create({
|
||||
return prisma.ticket.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
qrCode: uuidv4(),
|
||||
@@ -115,27 +117,28 @@ const generateTicketsForRegistration = async (registrationId) => {
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
generatedTickets.push(ticket);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
await prisma.ticket.update({ where: { id: primary.id }, data: { quantity: targetQty, updatedAt: new Date() } });
|
||||
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) {
|
||||
await prisma.ticket.deleteMany({ where: { id: { in: dups.map(t => t.id) } } });
|
||||
updates.push(prisma.ticket.deleteMany({ where: { id: { in: dups.map(t => t.id) } } }));
|
||||
}
|
||||
}
|
||||
if (updates.length > 0) await Promise.all(updates);
|
||||
return null;
|
||||
}));
|
||||
|
||||
return generatedTickets;
|
||||
return perOptionResults.filter(Boolean);
|
||||
} catch (error) {
|
||||
console.error('Error generating tickets:', error);
|
||||
throw error;
|
||||
|
||||
Reference in New Issue
Block a user