710 lines
21 KiB
TypeScript
710 lines
21 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 { generateShortlist } from '../services/ai-shortlist'
|
|
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.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: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'CREATE',
|
|
entityType: 'Round',
|
|
entityId: round.id,
|
|
detailsJson: {
|
|
name: input.name,
|
|
roundType: input.roundType,
|
|
competitionId: input.competitionId,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
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 existing = await ctx.prisma.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 round = await ctx.prisma.round.update({
|
|
where: { id },
|
|
data: {
|
|
...data,
|
|
...(validatedConfig !== undefined ? { configJson: validatedConfig } : {}),
|
|
},
|
|
})
|
|
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
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 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 existing = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.id } })
|
|
|
|
await ctx.prisma.round.delete({ where: { id: input.id } })
|
|
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
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
|
|
}),
|
|
|
|
// =========================================================================
|
|
// Project Advancement (Manual Only)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Advance PASSED projects from one round to the next.
|
|
* This is ALWAYS manual — no auto-advancement after AI filtering.
|
|
* Admin must explicitly trigger this after reviewing results.
|
|
*/
|
|
advanceProjects: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
targetRoundId: z.string().optional(),
|
|
projectIds: z.array(z.string()).optional(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const { roundId, targetRoundId, projectIds } = input
|
|
|
|
// Get current round with competition context
|
|
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: roundId },
|
|
select: { id: true, name: true, competitionId: true, sortOrder: true },
|
|
})
|
|
|
|
// Determine target round
|
|
let targetRound: { id: string; name: string }
|
|
if (targetRoundId) {
|
|
targetRound = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: targetRoundId },
|
|
select: { id: true, name: true },
|
|
})
|
|
} else {
|
|
// Find next round in same competition by sortOrder
|
|
const nextRound = await ctx.prisma.round.findFirst({
|
|
where: {
|
|
competitionId: currentRound.competitionId,
|
|
sortOrder: { gt: currentRound.sortOrder },
|
|
},
|
|
orderBy: { sortOrder: 'asc' },
|
|
select: { id: true, name: true },
|
|
})
|
|
if (!nextRound) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'No subsequent round exists in this competition. Create the next round first.',
|
|
})
|
|
}
|
|
targetRound = nextRound
|
|
}
|
|
|
|
// Determine which projects to advance
|
|
let idsToAdvance: string[]
|
|
if (projectIds && projectIds.length > 0) {
|
|
idsToAdvance = projectIds
|
|
} else {
|
|
// Default: all PASSED projects in current round
|
|
const passedStates = await ctx.prisma.projectRoundState.findMany({
|
|
where: { roundId, state: 'PASSED' },
|
|
select: { projectId: true },
|
|
})
|
|
idsToAdvance = passedStates.map((s) => s.projectId)
|
|
}
|
|
|
|
if (idsToAdvance.length === 0) {
|
|
return { advancedCount: 0, targetRoundId: targetRound.id, targetRoundName: targetRound.name }
|
|
}
|
|
|
|
// Transaction: create entries in target round + mark current as COMPLETED
|
|
await ctx.prisma.$transaction(async (tx) => {
|
|
// Create ProjectRoundState in target round
|
|
await tx.projectRoundState.createMany({
|
|
data: idsToAdvance.map((projectId) => ({
|
|
projectId,
|
|
roundId: targetRound.id,
|
|
})),
|
|
skipDuplicates: true,
|
|
})
|
|
|
|
// Mark current round states as COMPLETED
|
|
await tx.projectRoundState.updateMany({
|
|
where: {
|
|
roundId,
|
|
projectId: { in: idsToAdvance },
|
|
state: 'PASSED',
|
|
},
|
|
data: { state: 'COMPLETED' },
|
|
})
|
|
|
|
// Update project status to ASSIGNED
|
|
await tx.project.updateMany({
|
|
where: { id: { in: idsToAdvance } },
|
|
data: { status: 'ASSIGNED' },
|
|
})
|
|
|
|
// Status history
|
|
await tx.projectStatusHistory.createMany({
|
|
data: idsToAdvance.map((projectId) => ({
|
|
projectId,
|
|
status: 'ASSIGNED',
|
|
changedBy: ctx.user?.id,
|
|
})),
|
|
})
|
|
})
|
|
|
|
// Audit
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'ADVANCE_PROJECTS',
|
|
entityType: 'Round',
|
|
entityId: roundId,
|
|
detailsJson: {
|
|
fromRound: currentRound.name,
|
|
toRound: targetRound.name,
|
|
targetRoundId: targetRound.id,
|
|
projectCount: idsToAdvance.length,
|
|
projectIds: idsToAdvance,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return {
|
|
advancedCount: idsToAdvance.length,
|
|
targetRoundId: targetRound.id,
|
|
targetRoundName: targetRound.name,
|
|
}
|
|
}),
|
|
|
|
// =========================================================================
|
|
// AI Shortlist Recommendations
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Generate AI-powered shortlist recommendations for a round.
|
|
* Runs independently for STARTUP and BUSINESS_CONCEPT categories.
|
|
* Uses per-round config for advancement targets and file parsing.
|
|
*/
|
|
generateAIRecommendations: adminProcedure
|
|
.input(
|
|
z.object({
|
|
roundId: z.string(),
|
|
rubric: z.string().optional(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
competitionId: true,
|
|
configJson: true,
|
|
},
|
|
})
|
|
|
|
const config = (round.configJson as Record<string, unknown>) ?? {}
|
|
const startupTopN = (config.startupAdvanceCount as number) || 10
|
|
const conceptTopN = (config.conceptAdvanceCount as number) || 10
|
|
const aiParseFiles = !!config.aiParseFiles
|
|
|
|
const result = await generateShortlist(
|
|
{
|
|
roundId: input.roundId,
|
|
competitionId: round.competitionId,
|
|
startupTopN,
|
|
conceptTopN,
|
|
rubric: input.rubric,
|
|
aiParseFiles,
|
|
},
|
|
ctx.prisma,
|
|
)
|
|
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'AI_SHORTLIST',
|
|
entityType: 'Round',
|
|
entityId: input.roundId,
|
|
detailsJson: {
|
|
roundName: round.name,
|
|
startupTopN,
|
|
conceptTopN,
|
|
aiParseFiles,
|
|
success: result.success,
|
|
startupCount: result.recommendations.STARTUP.length,
|
|
conceptCount: result.recommendations.BUSINESS_CONCEPT.length,
|
|
tokensUsed: result.tokensUsed,
|
|
errors: result.errors,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return result
|
|
}),
|
|
|
|
// =========================================================================
|
|
// 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.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: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'CREATE',
|
|
entityType: 'SubmissionWindow',
|
|
entityId: window.id,
|
|
detailsJson: { name: input.name, competitionId: input.competitionId },
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return window
|
|
}),
|
|
|
|
/**
|
|
* Update an existing submission window
|
|
*/
|
|
updateSubmissionWindow: 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(),
|
|
roundNumber: z.number().int().min(1).optional(),
|
|
windowOpenAt: z.date().nullable().optional(),
|
|
windowCloseAt: z.date().nullable().optional(),
|
|
deadlinePolicy: z.enum(['HARD_DEADLINE', 'FLAG', 'GRACE']).optional(),
|
|
graceHours: z.number().int().min(0).nullable().optional(),
|
|
lockOnClose: z.boolean().optional(),
|
|
sortOrder: z.number().int().optional(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const { id, ...data } = input
|
|
const window = await ctx.prisma.submissionWindow.update({
|
|
where: { id },
|
|
data,
|
|
})
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'UPDATE',
|
|
entityType: 'SubmissionWindow',
|
|
entityId: id,
|
|
detailsJson: data,
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
return window
|
|
}),
|
|
|
|
/**
|
|
* Delete a submission window (only if no files uploaded)
|
|
*/
|
|
deleteSubmissionWindow: adminProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
// Check if window has uploaded files
|
|
const window = await ctx.prisma.submissionWindow.findUniqueOrThrow({
|
|
where: { id: input.id },
|
|
select: { id: true, name: true, _count: { select: { projectFiles: true } } },
|
|
})
|
|
if (window._count.projectFiles > 0) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: `Cannot delete window "${window.name}" — it has ${window._count.projectFiles} uploaded files. Remove files first.`,
|
|
})
|
|
}
|
|
await ctx.prisma.submissionWindow.delete({ where: { id: input.id } })
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'DELETE',
|
|
entityType: 'SubmissionWindow',
|
|
entityId: input.id,
|
|
detailsJson: { name: window.name },
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
return { success: true }
|
|
}),
|
|
|
|
/**
|
|
* 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' },
|
|
})
|
|
}),
|
|
})
|