diff --git a/CHANGELOG.md b/CHANGELOG.md index c47ed59..9b26825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Performance + +- Reconciling a Yoco card payment or sending a payment link on the supervisor Payments page no longer blocks the response on ticket-PDF generation and email/WhatsApp sends — these now run in the background, matching how manual payments already worked. +- Registration creation and payment capture now resolve per-option pricing, stock checks, and ticket generation concurrently instead of one option at a time. +- The supervisor Payments page no longer fires one request per registration to compute outstanding balances (that data was already included in the registrations response); post-action refreshes also run in parallel instead of sequentially. +- Added indexes for `Registration(createdAt, status)`, `Ticket(createdAt)`, and `Payment(originalPaymentId, isDonation)` to speed up dashboard stats and donation-leg lookups. `GET /api/registrations` now supports optional `page`/`limit` pagination. + ## [1.5.4] - 2026-08-07 ### Fixed diff --git a/backend/prisma/migrations/20260807215056_add_perf_indexes/migration.sql b/backend/prisma/migrations/20260807215056_add_perf_indexes/migration.sql new file mode 100644 index 0000000..c5cba12 --- /dev/null +++ b/backend/prisma/migrations/20260807215056_add_perf_indexes/migration.sql @@ -0,0 +1,11 @@ +-- DropIndex +DROP INDEX "Payment_originalPaymentId_idx"; + +-- CreateIndex +CREATE INDEX "Payment_originalPaymentId_isDonation_idx" ON "Payment"("originalPaymentId", "isDonation"); + +-- CreateIndex +CREATE INDEX "Registration_createdAt_status_idx" ON "Registration"("createdAt", "status"); + +-- CreateIndex +CREATE INDEX "Ticket_createdAt_idx" ON "Ticket"("createdAt"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9f4f75a..c6924d7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -201,6 +201,7 @@ model Registration { @@index([eventId]) @@index([userId, status]) @@index([eventId, status]) + @@index([createdAt, status]) } model RegistrationOption { @@ -249,7 +250,7 @@ model Payment { @@index([registrationId]) @@index([eventId]) @@index([createdAt]) - @@index([originalPaymentId]) + @@index([originalPaymentId, isDonation]) } model Ticket { @@ -271,6 +272,7 @@ model Ticket { @@index([eventId]) @@index([userId]) @@index([registrationOptionId]) + @@index([createdAt]) } model TicketUsage { diff --git a/backend/src/controllers/paymentController.js b/backend/src/controllers/paymentController.js index 6a8220f..bc2534f 100644 --- a/backend/src/controllers/paymentController.js +++ b/backend/src/controllers/paymentController.js @@ -1057,20 +1057,22 @@ const sendPaymentLink = async (req, res) => { const { user, event } = registration; const message = `Hi ${user.name || ''}, here's your payment link for ${event?.title || 'your registration'}: ${redirectUrl}`; + // Validation is synchronous (fast, no network); the actual send is backgrounded since + // SMTP/WAWP round trips shouldn't block this request. if (channel === 'email') { if (!user.email) { res.status(400); throw new Error('This user has no email address on file'); } const { sendMail } = require('../utils/email'); - await sendMail({ + sendMail({ to: user.email, subject: `Payment link — ${event?.title || 'Registration'}`, text: message, html: `
Hi ${user.name || ''},
Here's your payment link for ${event?.title || 'your registration'}:
` - }); + }).catch(e => console.error('Failed to send payment link email:', e)); } else { const { isValidZAPhone } = require('../utils/whatsapp'); if (!isValidZAPhone(user.phoneNumber)) { res.status(400); throw new Error('This user has no valid WhatsApp number on file'); } const { waTextAny } = require('../utils/notify'); - await waTextAny(user, message); + waTextAny(user, message).catch(e => console.error('Failed to send payment link via WhatsApp:', e)); } return res.status(200).json({ sent: true, channel }); diff --git a/backend/src/controllers/registrationController.js b/backend/src/controllers/registrationController.js index ed783a2..e21b738 100644 --- a/backend/src/controllers/registrationController.js +++ b/backend/src/controllers/registrationController.js @@ -136,16 +136,16 @@ const createRegistration = async (req, res) => { throw new Error('At least one option must be selected'); } - // Validate each option and resolve prices / check stock - const resolvedOptions = []; - for (const option of options) { + // Validate each option and resolve prices / check stock. + // Options are independent of each other, so resolve them concurrently. Errors are marked + // with `.isStockError` rather than relying on shared `res.statusCode` (which would race + // across concurrent iterations) — the outer catch always responds 400 regardless. + const resolvedOptions = await Promise.all(options.map(async (option) => { const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId); if (!eventOption) { - res.status(400); throw new Error(`Option with ID ${option.eventOptionId} not found for this event`); } if (!option.quantity || option.quantity < 1) { - res.status(400); throw new Error('Quantity must be at least 1'); } @@ -155,11 +155,12 @@ const createRegistration = async (req, res) => { try { const stockCheck = await checkOptionStock(eventOption, qty); if (!stockCheck.available) { - res.status(400); - throw new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`); + const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`); + err.isStockError = true; + throw err; } } catch (e) { - if (res.statusCode !== 200) throw e; // propagate stock errors + if (e.isStockError) throw e; // propagate stock errors // If stock check function fails (pre-migration), continue without stock check } @@ -169,17 +170,17 @@ const createRegistration = async (req, res) => { if (variantId && canIncludeVariants) { const variant = (eventOption.variants || []).find(v => v.id === variantId); if (!variant) { - res.status(400); throw new Error(`Variant not found for option "${eventOption.name}"`); } try { const vStock = await checkVariantStock(variant, qty); if (!vStock.available) { - res.status(400); - throw new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`); + const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`); + err.isStockError = true; + throw err; } } catch (e) { - if (res.statusCode !== 200) throw e; + if (e.isStockError) throw e; } variantPrice = variant.price; // null = use option price } @@ -213,8 +214,8 @@ const createRegistration = async (req, res) => { } if (priceSnapshot === null) priceSnapshot = eventOption.price; - resolvedOptions.push({ ...option, variantId, appliedTierId, priceSnapshot }); - } + return { ...option, variantId, appliedTierId, priceSnapshot }; + })); // Check for existing non-cancelled registration for this user+event → merge instead const existingReg = await prisma.registration.findFirst({ @@ -378,28 +379,43 @@ const createRegistration = async (req, res) => { // @access Private/Admin const getRegistrations = async (req, res) => { try { - const registrations = await prisma.registration.findMany({ - include: { - payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, - registrationOptions: { - include: { - eventOption: { include: { earlyBirdTiers: true } }, - variant: { select: { id: true, name: true, price: true } }, - } - }, - event: true, - user: { - select: { - id: true, - name: true, - email: true, - phoneNumber: true - } + const include = { + payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, + registrationOptions: { + include: { + eventOption: { include: { earlyBirdTiers: true } }, + variant: { select: { id: true, name: true, price: true } }, + } + }, + event: true, + user: { + select: { + id: true, + name: true, + email: true, + phoneNumber: true } } - }); + }; - res.json(registrations); + // Pagination is opt-in via ?page/?limit to keep existing callers (which expect a plain + // array of every registration) working unchanged; callers that pass either param get back + // the { data, total, page, limit, pages } shape used by /api/payments and /api/users. + if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') { + const registrations = await prisma.registration.findMany({ include }); + return res.json(registrations); + } + + const page = Math.max(1, parseInt(req.query.page) || 1); + const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100)); + const skip = (page - 1) * limit; + + const [registrations, total] = await prisma.$transaction([ + prisma.registration.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }), + prisma.registration.count() + ]); + + res.json({ data: registrations, total, page, limit, pages: Math.ceil(total / limit) }); } catch (error) { res.status(400).json({ message: error.message }); } diff --git a/backend/src/controllers/yocoTransactionsController.js b/backend/src/controllers/yocoTransactionsController.js index dab5d71..618add6 100644 --- a/backend/src/controllers/yocoTransactionsController.js +++ b/backend/src/controllers/yocoTransactionsController.js @@ -164,7 +164,10 @@ const reconcileYocoTransaction = async (req, res) => { const createdAt = ytx.createdDate || ytx.createdAt || new Date(); const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } }); - // Optionally update registration status when applicable and generate/email tickets if paid + // Optionally update registration status when applicable and generate tickets if paid. + // Ticket generation stays synchronous so `generatedTickets` can be included in the response; + // emailing/WhatsApp-ing the tickets and payment confirmation are backgrounded below since + // they involve slow PDF rendering + SMTP/WAWP round trips that shouldn't block this request. let generatedTickets = []; if (registration) { try { @@ -172,15 +175,6 @@ const reconcileYocoTransaction = async (req, res) => { if (updatedReg && updatedReg.status === 'paid') { try { generatedTickets = await generateTicketsForRegistration(registration.id); - if (Array.isArray(generatedTickets) && generatedTickets.length > 0) { - try { - const mockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } }; - const mockRes = { status: () => mockRes, json: () => {} }; - await emailTickets(mockReq, mockRes); - } catch (emailErr) { - console.error('Error emailing tickets after Yoco reconciliation:', emailErr); - } - } } catch (genErr) { console.error('Error generating tickets after Yoco reconciliation:', genErr); } @@ -191,13 +185,20 @@ const reconcileYocoTransaction = async (req, res) => { } } - // Send emails for the reconciled payment - try { - const { sendPaymentEmails } = require('../utils/notifications'); - await sendPaymentEmails(payment.id); - } catch (e) { - console.error('Failed to send payment emails after reconciliation:', e); - } + // Fire-and-forget: payment confirmation email, then ticket email/WhatsApp (guarantees order) + const { sendPaymentEmails } = require('../utils/notifications'); + const _rxPaymentId = payment.id; + const _rxUserId = registration?.userId; + const _rxRegId = registration?.id; + const _rxShouldEmailTickets = Array.isArray(generatedTickets) && generatedTickets.length > 0; + (async () => { + try { await sendPaymentEmails(_rxPaymentId); } catch (e) { console.error('Failed to send payment emails after reconciliation:', e); } + if (_rxShouldEmailTickets && _rxUserId) { + const mockReq = { user: { id: _rxUserId }, body: { registrationId: _rxRegId } }; + const mockRes = { status: () => mockRes, json: () => {} }; + try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets after Yoco reconciliation:', e); } + } + })(); // Update yoco transaction as reconciled const updatedTx = await Yoco.update({ diff --git a/backend/src/utils/pricing.js b/backend/src/utils/pricing.js index 8724c4d..ee891c3 100644 --- a/backend/src/utils/pricing.js +++ b/backend/src/utils/pricing.js @@ -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) }; } /** diff --git a/backend/src/utils/ticketUtils.js b/backend/src/utils/ticketUtils.js index 2da9ef0..0d22cd5 100644 --- a/backend/src/utils/ticketUtils.js +++ b/backend/src/utils/ticketUtils.js @@ -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; diff --git a/frontend/src/app/dashboard/supervisor/payments/page.tsx b/frontend/src/app/dashboard/supervisor/payments/page.tsx index a9261dc..21a61b0 100644 --- a/frontend/src/app/dashboard/supervisor/payments/page.tsx +++ b/frontend/src/app/dashboard/supervisor/payments/page.tsx @@ -187,42 +187,24 @@ function PaymentsContent() { return (price >= 0) ? price : base; }; - // Load registrations for dropdowns + // Load registrations for dropdowns. + // GET /api/registrations already embeds each registration's `payments`, so outstanding + // balances are computed from that in one pass — no per-registration follow-up requests. const loadRegistrations = async () => { if (!token) return; try { setLoadingRegs(true); const regs = await apiFetch