feat: auto-cascade cron + admin waitlist management procedures
- expirePendingPastDeadline service: scans PENDING confirmations past deadline, marks each EXPIRED + audit-logs, then promotes the next waitlist entry per affected category (using each program's grand-final round configJson for windowHours). - /api/cron/finalist-confirmations: hourly cron entrypoint (CRON_SECRET header gate), wraps the service. - finalist.addToWaitlist: insert at a specific rank, shifting later entries down (transactional). - finalist.reorderWaitlist: rewrite a category's rank order in one go, using a temp-rank trick to avoid unique-constraint conflicts mid-update. - finalist.manualPromote: out-of-rank-order admin promote with audit log (FINALIST_MANUAL_PROMOTE) + fresh confirmation email. 2 new tests. Suite at 14/14 for finalist-confirmation.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
|
||||
import { signFinalistToken } from '@/lib/finalist-token'
|
||||
import { sendFinalistConfirmationEmail } from '@/lib/email'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
type AnyPrisma = Pick<PrismaClient, 'finalistConfirmation' | 'waitlistEntry' | 'project'>
|
||||
|
||||
@@ -105,3 +106,48 @@ export async function promoteNextWaitlistEntry(
|
||||
|
||||
return { promoted: true, entryId: entry.id, confirmationId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron entrypoint: find every PENDING confirmation past its deadline, mark
|
||||
* each EXPIRED, and promote the next waitlist entry per affected category.
|
||||
*/
|
||||
export async function expirePendingPastDeadline(
|
||||
prisma: PrismaClient,
|
||||
): Promise<{ expired: number; promoted: number }> {
|
||||
const expired = await prisma.finalistConfirmation.findMany({
|
||||
where: { status: 'PENDING', deadline: { lt: new Date() } },
|
||||
include: { project: { select: { programId: true } } },
|
||||
})
|
||||
let promoted = 0
|
||||
for (const c of expired) {
|
||||
await prisma.finalistConfirmation.update({
|
||||
where: { id: c.id },
|
||||
data: { status: 'EXPIRED', expiredAt: new Date() },
|
||||
})
|
||||
await logAudit({
|
||||
prisma,
|
||||
action: 'FINALIST_EXPIRED',
|
||||
entityType: 'FinalistConfirmation',
|
||||
entityId: c.id,
|
||||
detailsJson: { projectId: c.projectId, category: c.category },
|
||||
})
|
||||
// Resolve windowHours for this program's grand-finale round
|
||||
const round = await prisma.round.findFirst({
|
||||
where: {
|
||||
competition: { programId: c.project.programId },
|
||||
roundType: 'LIVE_FINAL',
|
||||
},
|
||||
orderBy: { sortOrder: 'desc' },
|
||||
select: { configJson: true },
|
||||
})
|
||||
const cfg = (round?.configJson ?? {}) as { confirmationWindowHours?: number }
|
||||
const windowHours = cfg.confirmationWindowHours ?? 24
|
||||
const result = await promoteNextWaitlistEntry(prisma, {
|
||||
programId: c.project.programId,
|
||||
category: c.category,
|
||||
windowHours,
|
||||
})
|
||||
if (result.promoted) promoted++
|
||||
}
|
||||
return { expired: expired.length, promoted }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user