203 lines
6.1 KiB
TypeScript
203 lines
6.1 KiB
TypeScript
/**
|
|||
|
|
* 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<number> =>
|
||
|
|
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;
|
||
|
|
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 },
|
||
|
|
});
|
||
|
|
|
||
|
|
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<void> {
|
||
|
|
const eventIds = new Set<string>();
|
||
|
|
|
||
|
|
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).`,
|
||
|
|
);
|
||
|
|
}
|