/** * waitlist-promotion — the cron task that honours the "we will email you if a * place becomes available" promise. * * Registrations are stamped `confirmed` OR `waitlisted` at signup * (event-registration/middlewares/sanitize-submission.ts). Nothing ever moved * a `waitlisted` row to `confirmed` when a seat freed up, so the waitlist was * a queue nobody drained. This job does it, first-come-first-served: * * 1. find every event that still has `waitlisted` registrations; * 2. for each, lock the event row (the same serialisation the registration * middleware uses, so a concurrent POST cannot race the count) and * compute how many seats are left below capacity; * 3. promote the OLDEST waitlisted registrations (`submittedAt` asc) into * those seats, sending the standard confirmation mail first and flipping * the row to `confirmed` ONLY after the mail was accepted. * * Semantics are at-least-once, like the reminder job: a row whose mail failed * stays `waitlisted` and is retried on the next run. A waitlisted row with no * email cannot be told anything, so it is left as-is for staff to deal with. * * Registered from src/index.ts only when `server.cron.enabled` is true. */ import type { Core } from '@strapi/strapi'; import { sendRegistrationConfirmation } from './event-mail'; const EVENT_UID = 'api::event.event'; const REGISTRATION_UID = 'api::event-registration.event-registration'; const LOG_PREFIX = '[waitlist-promotion]'; const PAGE_SIZE = 50; /** The subset of the event that decides how many seats can be promoted into. */ type EventCapacity = { documentId: string; capacity: number | null; }; /** How many places on this event are already taken, mirroring the middleware. */ const countConfirmed = async (strapi: Core.Strapi, documentId: string): Promise => strapi.db.query(REGISTRATION_UID).count({ where: { event: { documentId }, registrationState: 'confirmed', }, }); /** * Promote the oldest waiting registrants of ONE event into its free seats. * * The lock mirrors `sanitize-submission`: registering and promoting both * serialize on the event row, so a concurrent request cannot steal a seat the * promotion just counted. Count and updates run inside the same transaction. */ async function promoteEvent( strapi: Core.Strapi, eventDocumentId: string, ): Promise<{ promoted: number; failed: number }> { return strapi.db.transaction(async ({ trx }) => { await trx('events') .select('id') .where({ document_id: eventDocumentId }) .orderBy('id') .forUpdate() .first(); const event = (await strapi.documents(EVENT_UID).findFirst({ filters: { documentId: eventDocumentId }, status: 'published', })) as EventCapacity | null; if (!event) { return { promoted: 0, failed: 0 }; } const capacity = typeof event.capacity === 'number' && event.capacity > 0 ? event.capacity : null; // No finite capacity means there is never a seat to free up. if (capacity === null) { return { promoted: 0, failed: 0 }; } const taken = await countConfirmed(strapi, eventDocumentId); let slots = capacity - taken; if (slots <= 0) { return { promoted: 0, failed: 0 }; } // Oldest waiters first; the page size is a safety bound, the loop stops // at `slots` anyway. const registrations = await strapi .documents(REGISTRATION_UID) .findMany({ filters: { event: { documentId: eventDocumentId }, registrationState: { $eq: 'waitlisted' }, }, sort: 'submittedAt:asc', pagination: { page: 1, pageSize: PAGE_SIZE }, }); let promoted = 0; let failed = 0; for (const registration of registrations) { if (slots <= 0) break; const email = (registration as { email?: unknown }).email; const fullName = (registration as { fullName?: unknown }).fullName; if (typeof email !== 'string' || email.length === 0) { strapi.log.warn( `${LOG_PREFIX} registration ${registration.documentId} has no email; left waitlisted.`, ); continue; } const ok = await sendRegistrationConfirmation({ strapi, to: email, state: 'confirmed', eventReference: { documentId: eventDocumentId }, reservation: { id: registration.documentId as string, name: typeof fullName === 'string' ? fullName : undefined, }, }); if (!ok) { failed += 1; continue; } await strapi .documents(REGISTRATION_UID) .update({ documentId: registration.documentId as string, data: { registrationState: 'confirmed' }, }); promoted += 1; slots -= 1; } return { promoted, failed }; }); } /** * Run one pass over every event that still has waitlisted registrations. * One failing event never stops the batch; totals are logged. */ export async function runWaitlistPromotion({ strapi, }: { strapi: Core.Strapi; }): Promise { const eventIds = new Set(); let page = 1; while (true) { const registrations = await strapi .documents(REGISTRATION_UID) .findMany({ filters: { registrationState: { $eq: 'waitlisted' } }, populate: { event: true }, pagination: { page, pageSize: PAGE_SIZE }, }); for (const registration of registrations) { const event = (registration as { event?: { documentId?: unknown } }).event; if (typeof event?.documentId === 'string' && event.documentId.length > 0) { eventIds.add(event.documentId); } } if (registrations.length < PAGE_SIZE) break; page += 1; } if (eventIds.size === 0) { strapi.log.info(`${LOG_PREFIX} no events with a waitlist, skipping.`); return; } let promotedTotal = 0; let failedTotal = 0; for (const eventId of eventIds) { try { const { promoted, failed } = await promoteEvent(strapi, eventId); promotedTotal += promoted; failedTotal += failed; } catch (error) { failedTotal += 1; strapi.log.error( `${LOG_PREFIX} failed for event ${eventId}: ${String(error)}`, ); } } strapi.log.info( `${LOG_PREFIX} finished: ${promotedTotal} waitlisted registration(s) promoted, ${failedTotal} mail(s) failed (retried next run).`, ); }