458 lines
13 KiB
TypeScript
458 lines
13 KiB
TypeScript
|
|
import { z } from 'zod'
|
||
|
|
import { TRPCError } from '@trpc/server'
|
||
|
|
import { Prisma } from '@prisma/client'
|
||
|
|
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||
|
|
import { logAudit } from '@/server/utils/audit'
|
||
|
|
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
|
||
|
|
import {
|
||
|
|
openWindow,
|
||
|
|
closeWindow,
|
||
|
|
lockWindow,
|
||
|
|
checkDeadlinePolicy,
|
||
|
|
validateSubmission,
|
||
|
|
getVisibleWindows,
|
||
|
|
} from '../services/submission-manager'
|
||
|
|
|
||
|
|
const roundTypeEnum = z.enum([
|
||
|
|
'INTAKE',
|
||
|
|
'FILTERING',
|
||
|
|
'EVALUATION',
|
||
|
|
'SUBMISSION',
|
||
|
|
'MENTORING',
|
||
|
|
'LIVE_FINAL',
|
||
|
|
'DELIBERATION',
|
||
|
|
])
|
||
|
|
|
||
|
|
export const roundRouter = router({
|
||
|
|
/**
|
||
|
|
* Create a new round within a competition
|
||
|
|
*/
|
||
|
|
create: adminProcedure
|
||
|
|
.input(
|
||
|
|
z.object({
|
||
|
|
competitionId: z.string(),
|
||
|
|
name: z.string().min(1).max(255),
|
||
|
|
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||
|
|
roundType: roundTypeEnum,
|
||
|
|
sortOrder: z.number().int().nonnegative(),
|
||
|
|
configJson: z.record(z.unknown()).optional(),
|
||
|
|
windowOpenAt: z.date().nullable().optional(),
|
||
|
|
windowCloseAt: z.date().nullable().optional(),
|
||
|
|
juryGroupId: z.string().nullable().optional(),
|
||
|
|
submissionWindowId: z.string().nullable().optional(),
|
||
|
|
purposeKey: z.string().nullable().optional(),
|
||
|
|
})
|
||
|
|
)
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
// Verify competition exists
|
||
|
|
await ctx.prisma.competition.findUniqueOrThrow({
|
||
|
|
where: { id: input.competitionId },
|
||
|
|
})
|
||
|
|
|
||
|
|
// Validate configJson against the Zod schema for this roundType
|
||
|
|
const config = input.configJson
|
||
|
|
? validateRoundConfig(input.roundType, input.configJson)
|
||
|
|
: defaultRoundConfig(input.roundType)
|
||
|
|
|
||
|
|
const round = await ctx.prisma.$transaction(async (tx) => {
|
||
|
|
const created = await tx.round.create({
|
||
|
|
data: {
|
||
|
|
competitionId: input.competitionId,
|
||
|
|
name: input.name,
|
||
|
|
slug: input.slug,
|
||
|
|
roundType: input.roundType,
|
||
|
|
sortOrder: input.sortOrder,
|
||
|
|
configJson: config as unknown as Prisma.InputJsonValue,
|
||
|
|
windowOpenAt: input.windowOpenAt ?? undefined,
|
||
|
|
windowCloseAt: input.windowCloseAt ?? undefined,
|
||
|
|
juryGroupId: input.juryGroupId ?? undefined,
|
||
|
|
submissionWindowId: input.submissionWindowId ?? undefined,
|
||
|
|
purposeKey: input.purposeKey ?? undefined,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
await logAudit({
|
||
|
|
prisma: tx,
|
||
|
|
userId: ctx.user.id,
|
||
|
|
action: 'CREATE',
|
||
|
|
entityType: 'Round',
|
||
|
|
entityId: created.id,
|
||
|
|
detailsJson: {
|
||
|
|
name: input.name,
|
||
|
|
roundType: input.roundType,
|
||
|
|
competitionId: input.competitionId,
|
||
|
|
},
|
||
|
|
ipAddress: ctx.ip,
|
||
|
|
userAgent: ctx.userAgent,
|
||
|
|
})
|
||
|
|
|
||
|
|
return created
|
||
|
|
})
|
||
|
|
|
||
|
|
return round
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get round by ID with all relations
|
||
|
|
*/
|
||
|
|
getById: protectedProcedure
|
||
|
|
.input(z.object({ id: z.string() }))
|
||
|
|
.query(async ({ ctx, input }) => {
|
||
|
|
const round = await ctx.prisma.round.findUnique({
|
||
|
|
where: { id: input.id },
|
||
|
|
include: {
|
||
|
|
juryGroup: {
|
||
|
|
include: { members: true },
|
||
|
|
},
|
||
|
|
submissionWindow: {
|
||
|
|
include: { fileRequirements: true },
|
||
|
|
},
|
||
|
|
advancementRules: { orderBy: { sortOrder: 'asc' } },
|
||
|
|
visibleSubmissionWindows: {
|
||
|
|
include: { submissionWindow: true },
|
||
|
|
},
|
||
|
|
_count: {
|
||
|
|
select: { projectRoundStates: true },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
if (!round) {
|
||
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' })
|
||
|
|
}
|
||
|
|
|
||
|
|
return round
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update round settings/config
|
||
|
|
*/
|
||
|
|
update: adminProcedure
|
||
|
|
.input(
|
||
|
|
z.object({
|
||
|
|
id: z.string(),
|
||
|
|
name: z.string().min(1).max(255).optional(),
|
||
|
|
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
|
||
|
|
status: z.enum(['ROUND_DRAFT', 'ROUND_ACTIVE', 'ROUND_CLOSED', 'ROUND_ARCHIVED']).optional(),
|
||
|
|
configJson: z.record(z.unknown()).optional(),
|
||
|
|
windowOpenAt: z.date().nullable().optional(),
|
||
|
|
windowCloseAt: z.date().nullable().optional(),
|
||
|
|
juryGroupId: z.string().nullable().optional(),
|
||
|
|
submissionWindowId: z.string().nullable().optional(),
|
||
|
|
purposeKey: z.string().nullable().optional(),
|
||
|
|
})
|
||
|
|
)
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
const { id, configJson, ...data } = input
|
||
|
|
|
||
|
|
const round = await ctx.prisma.$transaction(async (tx) => {
|
||
|
|
const existing = await tx.round.findUniqueOrThrow({ where: { id } })
|
||
|
|
|
||
|
|
// If configJson provided, validate it against the round type
|
||
|
|
let validatedConfig: Prisma.InputJsonValue | undefined
|
||
|
|
if (configJson) {
|
||
|
|
const parsed = validateRoundConfig(existing.roundType, configJson)
|
||
|
|
validatedConfig = parsed as unknown as Prisma.InputJsonValue
|
||
|
|
}
|
||
|
|
|
||
|
|
const updated = await tx.round.update({
|
||
|
|
where: { id },
|
||
|
|
data: {
|
||
|
|
...data,
|
||
|
|
...(validatedConfig !== undefined ? { configJson: validatedConfig } : {}),
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
await logAudit({
|
||
|
|
prisma: tx,
|
||
|
|
userId: ctx.user.id,
|
||
|
|
action: 'UPDATE',
|
||
|
|
entityType: 'Round',
|
||
|
|
entityId: id,
|
||
|
|
detailsJson: {
|
||
|
|
changes: input,
|
||
|
|
previous: {
|
||
|
|
name: existing.name,
|
||
|
|
status: existing.status,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
ipAddress: ctx.ip,
|
||
|
|
userAgent: ctx.userAgent,
|
||
|
|
})
|
||
|
|
|
||
|
|
return updated
|
||
|
|
})
|
||
|
|
|
||
|
|
return round
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Reorder rounds within a competition
|
||
|
|
*/
|
||
|
|
updateOrder: adminProcedure
|
||
|
|
.input(
|
||
|
|
z.object({
|
||
|
|
competitionId: z.string(),
|
||
|
|
roundIds: z.array(z.string()),
|
||
|
|
})
|
||
|
|
)
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
return ctx.prisma.$transaction(
|
||
|
|
input.roundIds.map((roundId, index) =>
|
||
|
|
ctx.prisma.round.update({
|
||
|
|
where: { id: roundId },
|
||
|
|
data: { sortOrder: index },
|
||
|
|
})
|
||
|
|
)
|
||
|
|
)
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Delete a round
|
||
|
|
*/
|
||
|
|
delete: adminProcedure
|
||
|
|
.input(z.object({ id: z.string() }))
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
const round = await ctx.prisma.$transaction(async (tx) => {
|
||
|
|
const existing = await tx.round.findUniqueOrThrow({ where: { id: input.id } })
|
||
|
|
|
||
|
|
await tx.round.delete({ where: { id: input.id } })
|
||
|
|
|
||
|
|
await logAudit({
|
||
|
|
prisma: tx,
|
||
|
|
userId: ctx.user.id,
|
||
|
|
action: 'DELETE',
|
||
|
|
entityType: 'Round',
|
||
|
|
entityId: input.id,
|
||
|
|
detailsJson: {
|
||
|
|
name: existing.name,
|
||
|
|
roundType: existing.roundType,
|
||
|
|
competitionId: existing.competitionId,
|
||
|
|
},
|
||
|
|
ipAddress: ctx.ip,
|
||
|
|
userAgent: ctx.userAgent,
|
||
|
|
})
|
||
|
|
|
||
|
|
return existing
|
||
|
|
})
|
||
|
|
|
||
|
|
return round
|
||
|
|
}),
|
||
|
|
|
||
|
|
// =========================================================================
|
||
|
|
// Submission Window Management
|
||
|
|
// =========================================================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a submission window for a round
|
||
|
|
*/
|
||
|
|
createSubmissionWindow: adminProcedure
|
||
|
|
.input(
|
||
|
|
z.object({
|
||
|
|
competitionId: z.string(),
|
||
|
|
name: z.string().min(1).max(255),
|
||
|
|
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||
|
|
roundNumber: z.number().int().min(1),
|
||
|
|
windowOpenAt: z.date().optional(),
|
||
|
|
windowCloseAt: z.date().optional(),
|
||
|
|
deadlinePolicy: z.enum(['HARD_DEADLINE', 'FLAG', 'GRACE']).default('HARD_DEADLINE'),
|
||
|
|
graceHours: z.number().int().min(0).optional(),
|
||
|
|
lockOnClose: z.boolean().default(true),
|
||
|
|
})
|
||
|
|
)
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
const window = await ctx.prisma.$transaction(async (tx) => {
|
||
|
|
const created = await tx.submissionWindow.create({
|
||
|
|
data: {
|
||
|
|
competitionId: input.competitionId,
|
||
|
|
name: input.name,
|
||
|
|
slug: input.slug,
|
||
|
|
roundNumber: input.roundNumber,
|
||
|
|
windowOpenAt: input.windowOpenAt,
|
||
|
|
windowCloseAt: input.windowCloseAt,
|
||
|
|
deadlinePolicy: input.deadlinePolicy,
|
||
|
|
graceHours: input.graceHours,
|
||
|
|
lockOnClose: input.lockOnClose,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
await logAudit({
|
||
|
|
prisma: tx,
|
||
|
|
userId: ctx.user.id,
|
||
|
|
action: 'CREATE',
|
||
|
|
entityType: 'SubmissionWindow',
|
||
|
|
entityId: created.id,
|
||
|
|
detailsJson: { name: input.name, competitionId: input.competitionId },
|
||
|
|
ipAddress: ctx.ip,
|
||
|
|
userAgent: ctx.userAgent,
|
||
|
|
})
|
||
|
|
|
||
|
|
return created
|
||
|
|
})
|
||
|
|
|
||
|
|
return window
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Open a submission window
|
||
|
|
*/
|
||
|
|
openSubmissionWindow: adminProcedure
|
||
|
|
.input(z.object({ windowId: z.string() }))
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
const result = await openWindow(input.windowId, ctx.user.id, ctx.prisma)
|
||
|
|
if (!result.success) {
|
||
|
|
throw new TRPCError({
|
||
|
|
code: 'BAD_REQUEST',
|
||
|
|
message: result.errors?.join('; ') ?? 'Failed to open window',
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Close a submission window
|
||
|
|
*/
|
||
|
|
closeSubmissionWindow: adminProcedure
|
||
|
|
.input(z.object({ windowId: z.string() }))
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
const result = await closeWindow(input.windowId, ctx.user.id, ctx.prisma)
|
||
|
|
if (!result.success) {
|
||
|
|
throw new TRPCError({
|
||
|
|
code: 'BAD_REQUEST',
|
||
|
|
message: result.errors?.join('; ') ?? 'Failed to close window',
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Lock a submission window
|
||
|
|
*/
|
||
|
|
lockSubmissionWindow: adminProcedure
|
||
|
|
.input(z.object({ windowId: z.string() }))
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
const result = await lockWindow(input.windowId, ctx.user.id, ctx.prisma)
|
||
|
|
if (!result.success) {
|
||
|
|
throw new TRPCError({
|
||
|
|
code: 'BAD_REQUEST',
|
||
|
|
message: result.errors?.join('; ') ?? 'Failed to lock window',
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check deadline status of a window
|
||
|
|
*/
|
||
|
|
checkDeadline: protectedProcedure
|
||
|
|
.input(z.object({ windowId: z.string() }))
|
||
|
|
.query(async ({ ctx, input }) => {
|
||
|
|
return checkDeadlinePolicy(input.windowId, ctx.prisma)
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate files against window requirements
|
||
|
|
*/
|
||
|
|
validateSubmission: protectedProcedure
|
||
|
|
.input(
|
||
|
|
z.object({
|
||
|
|
projectId: z.string(),
|
||
|
|
windowId: z.string(),
|
||
|
|
files: z.array(
|
||
|
|
z.object({
|
||
|
|
mimeType: z.string(),
|
||
|
|
size: z.number(),
|
||
|
|
requirementId: z.string().optional(),
|
||
|
|
})
|
||
|
|
),
|
||
|
|
})
|
||
|
|
)
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
return validateSubmission(input.projectId, input.windowId, input.files, ctx.prisma)
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get visible submission windows for a round
|
||
|
|
*/
|
||
|
|
getVisibleWindows: protectedProcedure
|
||
|
|
.input(z.object({ roundId: z.string() }))
|
||
|
|
.query(async ({ ctx, input }) => {
|
||
|
|
return getVisibleWindows(input.roundId, ctx.prisma)
|
||
|
|
}),
|
||
|
|
|
||
|
|
// =========================================================================
|
||
|
|
// File Requirements Management
|
||
|
|
// =========================================================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a file requirement for a submission window
|
||
|
|
*/
|
||
|
|
createFileRequirement: adminProcedure
|
||
|
|
.input(
|
||
|
|
z.object({
|
||
|
|
submissionWindowId: z.string(),
|
||
|
|
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||
|
|
label: z.string().min(1).max(255),
|
||
|
|
description: z.string().max(2000).optional(),
|
||
|
|
mimeTypes: z.array(z.string()).default([]),
|
||
|
|
maxSizeMb: z.number().int().min(0).optional(),
|
||
|
|
required: z.boolean().default(false),
|
||
|
|
sortOrder: z.number().int().default(0),
|
||
|
|
})
|
||
|
|
)
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
return ctx.prisma.submissionFileRequirement.create({
|
||
|
|
data: input,
|
||
|
|
})
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update a file requirement
|
||
|
|
*/
|
||
|
|
updateFileRequirement: adminProcedure
|
||
|
|
.input(
|
||
|
|
z.object({
|
||
|
|
id: z.string(),
|
||
|
|
label: z.string().min(1).max(255).optional(),
|
||
|
|
description: z.string().max(2000).optional().nullable(),
|
||
|
|
mimeTypes: z.array(z.string()).optional(),
|
||
|
|
maxSizeMb: z.number().min(0).optional().nullable(),
|
||
|
|
required: z.boolean().optional(),
|
||
|
|
sortOrder: z.number().int().optional(),
|
||
|
|
})
|
||
|
|
)
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
const { id, ...data } = input
|
||
|
|
return ctx.prisma.submissionFileRequirement.update({
|
||
|
|
where: { id },
|
||
|
|
data,
|
||
|
|
})
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Delete a file requirement
|
||
|
|
*/
|
||
|
|
deleteFileRequirement: adminProcedure
|
||
|
|
.input(z.object({ id: z.string() }))
|
||
|
|
.mutation(async ({ ctx, input }) => {
|
||
|
|
return ctx.prisma.submissionFileRequirement.delete({
|
||
|
|
where: { id: input.id },
|
||
|
|
})
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get submission windows for applicants in a competition
|
||
|
|
*/
|
||
|
|
getApplicantWindows: protectedProcedure
|
||
|
|
.input(z.object({ competitionId: z.string() }))
|
||
|
|
.query(async ({ ctx, input }) => {
|
||
|
|
return ctx.prisma.submissionWindow.findMany({
|
||
|
|
where: { competitionId: input.competitionId },
|
||
|
|
include: {
|
||
|
|
fileRequirements: { orderBy: { sortOrder: 'asc' } },
|
||
|
|
},
|
||
|
|
orderBy: { sortOrder: 'asc' },
|
||
|
|
})
|
||
|
|
}),
|
||
|
|
})
|