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:
17
src/app/api/cron/finalist-confirmations/route.ts
Normal file
17
src/app/api/cron/finalist-confirmations/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { expirePendingPastDeadline } from '@/server/services/finalist-confirmation'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||||
|
const cronSecret = request.headers.get('x-cron-secret')
|
||||||
|
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await expirePendingPastDeadline(prisma)
|
||||||
|
return NextResponse.json({ ok: true, ...result })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Cron] finalist-confirmations failed:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -363,4 +363,223 @@ export const finalistRouter = router({
|
|||||||
|
|
||||||
return { ok: true }
|
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 }
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
|
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
|
||||||
import { signFinalistToken } from '@/lib/finalist-token'
|
import { signFinalistToken } from '@/lib/finalist-token'
|
||||||
import { sendFinalistConfirmationEmail } from '@/lib/email'
|
import { sendFinalistConfirmationEmail } from '@/lib/email'
|
||||||
|
import { logAudit } from '@/server/utils/audit'
|
||||||
|
|
||||||
type AnyPrisma = Pick<PrismaClient, 'finalistConfirmation' | 'waitlistEntry' | 'project'>
|
type AnyPrisma = Pick<PrismaClient, 'finalistConfirmation' | 'waitlistEntry' | 'project'>
|
||||||
|
|
||||||
@@ -105,3 +106,48 @@ export async function promoteNextWaitlistEntry(
|
|||||||
|
|
||||||
return { promoted: true, entryId: entry.id, confirmationId }
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -521,6 +521,138 @@ describe('finalist.confirm and decline (public)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('expirePendingPastDeadline marks expired confirmations and promotes next waitlist entry', async () => {
|
||||||
|
const { expirePendingPastDeadline } = await import(
|
||||||
|
'../../src/server/services/finalist-confirmation'
|
||||||
|
)
|
||||||
|
const { program, project } = await setupPendingConfirmation(`expire-${uid()}`)
|
||||||
|
// Create the original PENDING confirmation with a past deadline
|
||||||
|
const originalId = `cmfc_exp_${uid()}`
|
||||||
|
const expiredExp = Math.floor(Date.now() / 1000) - 60
|
||||||
|
const { signFinalistToken } = await import('../../src/lib/finalist-token')
|
||||||
|
const originalToken = signFinalistToken({ confirmationId: originalId, exp: expiredExp })
|
||||||
|
await prisma.finalistConfirmation.create({
|
||||||
|
data: {
|
||||||
|
id: originalId,
|
||||||
|
projectId: project.id,
|
||||||
|
category: 'STARTUP',
|
||||||
|
status: 'PENDING',
|
||||||
|
deadline: new Date(Date.now() - 60_000),
|
||||||
|
token: originalToken,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// And a waitlist entry to promote
|
||||||
|
const backupProject = await createTestProject(program.id, {
|
||||||
|
title: 'Cron Backup',
|
||||||
|
competitionCategory: 'STARTUP',
|
||||||
|
})
|
||||||
|
const backupLead = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: uid('user'),
|
||||||
|
email: `cronlead_${uid()}@test.local`,
|
||||||
|
name: 'Cron Lead',
|
||||||
|
role: 'APPLICANT',
|
||||||
|
roles: ['APPLICANT'],
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
userIds.push(backupLead.id)
|
||||||
|
await prisma.teamMember.create({
|
||||||
|
data: { projectId: backupProject.id, userId: backupLead.id, role: 'LEAD' },
|
||||||
|
})
|
||||||
|
const waitlistEntry = await prisma.waitlistEntry.create({
|
||||||
|
data: {
|
||||||
|
programId: program.id,
|
||||||
|
projectId: backupProject.id,
|
||||||
|
category: 'STARTUP',
|
||||||
|
rank: 1,
|
||||||
|
status: 'WAITING',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await expirePendingPastDeadline(prisma)
|
||||||
|
expect(result.expired).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(result.promoted).toBeGreaterThanOrEqual(1)
|
||||||
|
|
||||||
|
const updated = await prisma.finalistConfirmation.findUniqueOrThrow({
|
||||||
|
where: { id: originalId },
|
||||||
|
})
|
||||||
|
expect(updated.status).toBe('EXPIRED')
|
||||||
|
expect(updated.expiredAt).not.toBeNull()
|
||||||
|
|
||||||
|
const promoted = await prisma.finalistConfirmation.findUnique({
|
||||||
|
where: { projectId: backupProject.id },
|
||||||
|
})
|
||||||
|
expect(promoted).not.toBeNull()
|
||||||
|
expect(promoted?.status).toBe('PENDING')
|
||||||
|
|
||||||
|
const updatedEntry = await prisma.waitlistEntry.findUniqueOrThrow({
|
||||||
|
where: { id: waitlistEntry.id },
|
||||||
|
})
|
||||||
|
expect(updatedEntry.status).toBe('PROMOTED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('manualPromote bypasses rank order and audit-logs the override', async () => {
|
||||||
|
const { program } = await setupPendingConfirmation(`manual-${uid()}`)
|
||||||
|
const admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
userIds.push(admin.id)
|
||||||
|
|
||||||
|
// Three ranked waitlist entries
|
||||||
|
const entries = []
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const p = await createTestProject(program.id, {
|
||||||
|
title: `Rank ${i + 1}`,
|
||||||
|
competitionCategory: 'STARTUP',
|
||||||
|
})
|
||||||
|
const lead = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: uid('user'),
|
||||||
|
email: `rank${i}_${uid()}@test.local`,
|
||||||
|
name: `Rank ${i + 1} Lead`,
|
||||||
|
role: 'APPLICANT',
|
||||||
|
roles: ['APPLICANT'],
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
userIds.push(lead.id)
|
||||||
|
await prisma.teamMember.create({
|
||||||
|
data: { projectId: p.id, userId: lead.id, role: 'LEAD' },
|
||||||
|
})
|
||||||
|
const entry = await prisma.waitlistEntry.create({
|
||||||
|
data: {
|
||||||
|
programId: program.id,
|
||||||
|
projectId: p.id,
|
||||||
|
category: 'STARTUP',
|
||||||
|
rank: i + 1,
|
||||||
|
status: 'WAITING',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
entries.push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manually promote rank #3 (out of order)
|
||||||
|
const adminCaller = createCaller(finalistRouter, {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
role: 'SUPER_ADMIN',
|
||||||
|
})
|
||||||
|
await adminCaller.manualPromote({ waitlistEntryId: entries[2].id, windowHours: 24 })
|
||||||
|
|
||||||
|
const promoted = await prisma.waitlistEntry.findUniqueOrThrow({ where: { id: entries[2].id } })
|
||||||
|
expect(promoted.status).toBe('PROMOTED')
|
||||||
|
const stillWaiting = await prisma.waitlistEntry.findUniqueOrThrow({
|
||||||
|
where: { id: entries[0].id },
|
||||||
|
})
|
||||||
|
expect(stillWaiting.status).toBe('WAITING')
|
||||||
|
|
||||||
|
// Confirmation row exists
|
||||||
|
const confirmation = await prisma.finalistConfirmation.findUnique({
|
||||||
|
where: { projectId: entries[2].projectId },
|
||||||
|
})
|
||||||
|
expect(confirmation).not.toBeNull()
|
||||||
|
expect(confirmation?.promotedFromWaitlistEntryId).toBe(entries[2].id)
|
||||||
|
})
|
||||||
|
|
||||||
it('getByToken rejects expired tokens', async () => {
|
it('getByToken rejects expired tokens', async () => {
|
||||||
const { program, project } = await setupPendingConfirmation(`confirm-expired-${uid()}`)
|
const { program, project } = await setupPendingConfirmation(`confirm-expired-${uid()}`)
|
||||||
// Manually create a confirmation with a past deadline + signed-expired token
|
// Manually create a confirmation with a past deadline + signed-expired token
|
||||||
|
|||||||
Reference in New Issue
Block a user