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:
@@ -363,4 +363,223 @@ export const finalistRouter = router({
|
||||
|
||||
return { ok: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Add a project to the waitlist at a specific rank. Existing entries at
|
||||
* rank >= input.rank shift down by one to make room.
|
||||
*/
|
||||
addToWaitlist: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
category: z.nativeEnum(CompetitionCategory),
|
||||
projectId: z.string(),
|
||||
rank: z.number().int().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||
where: { id: input.projectId },
|
||||
select: { competitionCategory: true, programId: true },
|
||||
})
|
||||
if (project.programId !== input.programId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Project does not belong to this program',
|
||||
})
|
||||
}
|
||||
if (project.competitionCategory !== input.category) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Project is in ${project.competitionCategory}, not ${input.category}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Use a transaction: shift existing entries first, then insert.
|
||||
const entry = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Shift entries at >= input.rank down by 1 in reverse rank order to
|
||||
// avoid violating the unique constraint mid-update.
|
||||
const toShift = await tx.waitlistEntry.findMany({
|
||||
where: {
|
||||
programId: input.programId,
|
||||
category: input.category,
|
||||
rank: { gte: input.rank },
|
||||
},
|
||||
orderBy: { rank: 'desc' },
|
||||
select: { id: true, rank: true },
|
||||
})
|
||||
for (const e of toShift) {
|
||||
await tx.waitlistEntry.update({
|
||||
where: { id: e.id },
|
||||
data: { rank: e.rank + 1 },
|
||||
})
|
||||
}
|
||||
return tx.waitlistEntry.create({
|
||||
data: {
|
||||
programId: input.programId,
|
||||
category: input.category,
|
||||
projectId: input.projectId,
|
||||
rank: input.rank,
|
||||
status: 'WAITING',
|
||||
},
|
||||
})
|
||||
})
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'WAITLIST_ADD',
|
||||
entityType: 'WaitlistEntry',
|
||||
entityId: entry.id,
|
||||
detailsJson: {
|
||||
programId: input.programId,
|
||||
category: input.category,
|
||||
projectId: input.projectId,
|
||||
rank: input.rank,
|
||||
},
|
||||
})
|
||||
return entry
|
||||
}),
|
||||
|
||||
/**
|
||||
* Replace the rank order for a category's waitlist with the given list.
|
||||
* orderedProjectIds[0] becomes rank 1, etc.
|
||||
*/
|
||||
reorderWaitlist: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
category: z.nativeEnum(CompetitionCategory),
|
||||
orderedProjectIds: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
// Move each entry to a temporary very-large rank to avoid unique
|
||||
// constraint conflicts during the in-place rewrite.
|
||||
const TEMP_OFFSET = 100_000
|
||||
for (let i = 0; i < input.orderedProjectIds.length; i++) {
|
||||
await tx.waitlistEntry.updateMany({
|
||||
where: {
|
||||
programId: input.programId,
|
||||
category: input.category,
|
||||
projectId: input.orderedProjectIds[i],
|
||||
},
|
||||
data: { rank: TEMP_OFFSET + i + 1 },
|
||||
})
|
||||
}
|
||||
// Now write the final ranks
|
||||
for (let i = 0; i < input.orderedProjectIds.length; i++) {
|
||||
await tx.waitlistEntry.updateMany({
|
||||
where: {
|
||||
programId: input.programId,
|
||||
category: input.category,
|
||||
projectId: input.orderedProjectIds[i],
|
||||
},
|
||||
data: { rank: i + 1 },
|
||||
})
|
||||
}
|
||||
})
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'WAITLIST_REORDER',
|
||||
entityType: 'Program',
|
||||
entityId: input.programId,
|
||||
detailsJson: {
|
||||
category: input.category,
|
||||
orderedProjectIds: input.orderedProjectIds,
|
||||
},
|
||||
})
|
||||
return { ok: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Manually promote a specific waitlist entry out of rank order. Sends a
|
||||
* fresh confirmation email + audit-logs the override (separate from
|
||||
* automatic cascade).
|
||||
*/
|
||||
manualPromote: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
waitlistEntryId: z.string(),
|
||||
windowHours: z.number().int().min(1).max(168).default(24),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const entry = await ctx.prisma.waitlistEntry.findUniqueOrThrow({
|
||||
where: { id: input.waitlistEntryId },
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
category: true,
|
||||
status: true,
|
||||
programId: true,
|
||||
},
|
||||
})
|
||||
if (entry.status !== 'WAITING') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Waitlist entry is ${entry.status}, not WAITING`,
|
||||
})
|
||||
}
|
||||
await ctx.prisma.waitlistEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { status: 'PROMOTED' },
|
||||
})
|
||||
const { id: confirmationId, token, deadline } = await createPendingConfirmation(
|
||||
ctx.prisma,
|
||||
{
|
||||
projectId: entry.projectId,
|
||||
category: entry.category,
|
||||
windowHours: input.windowHours,
|
||||
promotedFromWaitlistEntryId: entry.id,
|
||||
},
|
||||
)
|
||||
// Email send (best-effort)
|
||||
const project = await ctx.prisma.project.findUnique({
|
||||
where: { id: entry.projectId },
|
||||
select: {
|
||||
title: true,
|
||||
teamMembers: {
|
||||
where: { role: 'LEAD' },
|
||||
take: 1,
|
||||
select: { user: { select: { email: true, name: true } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
const lead = project?.teamMembers[0]?.user
|
||||
if (lead?.email && project) {
|
||||
const baseUrl = (process.env.NEXTAUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
|
||||
const confirmUrl = `${baseUrl}/finalist/confirm/${token}`
|
||||
try {
|
||||
await sendFinalistConfirmationEmail(
|
||||
lead.email,
|
||||
lead.name ?? null,
|
||||
project.title,
|
||||
deadline,
|
||||
confirmUrl,
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[finalist.manualPromote] failed to send email for project ${entry.projectId}:`,
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'FINALIST_MANUAL_PROMOTE',
|
||||
entityType: 'WaitlistEntry',
|
||||
entityId: entry.id,
|
||||
detailsJson: {
|
||||
programId: entry.programId,
|
||||
category: entry.category,
|
||||
projectId: entry.projectId,
|
||||
confirmationId,
|
||||
windowHours: input.windowHours,
|
||||
},
|
||||
})
|
||||
return { confirmationId }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user