fix: FIFO queue for waitlisting; send email for application form; EU logo in contact page in light mode
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 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).`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user