Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
Replace Pipeline/Stage system with Competition/Round architecture. New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy, ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow. New services: round-engine, round-assignment, deliberation, result-lock, submission-manager, competition-context, ai-prompt-guard. Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with structured prompts, retry logic, and injection detection. All legacy pipeline/stage code removed. 4 new migrations + seed aligned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
348
src/server/routers/juryGroup.ts
Normal file
348
src/server/routers/juryGroup.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
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'
|
||||
|
||||
const capModeEnum = z.enum(['HARD', 'SOFT', 'NONE'])
|
||||
|
||||
export const juryGroupRouter = router({
|
||||
/**
|
||||
* Create a new jury group for 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-]+$/),
|
||||
description: z.string().optional(),
|
||||
sortOrder: z.number().int().nonnegative().default(0),
|
||||
defaultMaxAssignments: z.number().int().positive().default(20),
|
||||
defaultCapMode: capModeEnum.default('SOFT'),
|
||||
softCapBuffer: z.number().int().nonnegative().default(2),
|
||||
categoryQuotasEnabled: z.boolean().default(false),
|
||||
defaultCategoryQuotas: z
|
||||
.record(z.object({ min: z.number().int().nonnegative(), max: z.number().int().positive() }))
|
||||
.optional(),
|
||||
allowJurorCapAdjustment: z.boolean().default(false),
|
||||
allowJurorRatioAdjustment: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.competition.findUniqueOrThrow({
|
||||
where: { id: input.competitionId },
|
||||
})
|
||||
|
||||
const { defaultCategoryQuotas, ...rest } = input
|
||||
|
||||
const juryGroup = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.juryGroup.create({
|
||||
data: {
|
||||
...rest,
|
||||
defaultCategoryQuotas: defaultCategoryQuotas ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'JuryGroup',
|
||||
entityId: created.id,
|
||||
detailsJson: { name: input.name, competitionId: input.competitionId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return juryGroup
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get jury group by ID with members
|
||||
*/
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const group = await ctx.prisma.juryGroup.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true, email: true, role: true },
|
||||
},
|
||||
},
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!group) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Jury group not found' })
|
||||
}
|
||||
|
||||
return group
|
||||
}),
|
||||
|
||||
/**
|
||||
* List jury groups for a competition
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(z.object({ competitionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.juryGroup.findMany({
|
||||
where: { competitionId: input.competitionId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
_count: { select: { members: true, assignments: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update jury group settings
|
||||
*/
|
||||
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(),
|
||||
description: z.string().optional(),
|
||||
sortOrder: z.number().int().nonnegative().optional(),
|
||||
defaultMaxAssignments: z.number().int().positive().optional(),
|
||||
defaultCapMode: capModeEnum.optional(),
|
||||
softCapBuffer: z.number().int().nonnegative().optional(),
|
||||
categoryQuotasEnabled: z.boolean().optional(),
|
||||
defaultCategoryQuotas: z
|
||||
.record(z.object({ min: z.number().int().nonnegative(), max: z.number().int().positive() }))
|
||||
.nullable()
|
||||
.optional(),
|
||||
allowJurorCapAdjustment: z.boolean().optional(),
|
||||
allowJurorRatioAdjustment: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, defaultCategoryQuotas, ...rest } = input
|
||||
|
||||
return ctx.prisma.juryGroup.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(defaultCategoryQuotas !== undefined
|
||||
? {
|
||||
defaultCategoryQuotas:
|
||||
defaultCategoryQuotas === null
|
||||
? Prisma.JsonNull
|
||||
: (defaultCategoryQuotas as Prisma.InputJsonValue),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Add a member to a jury group
|
||||
*/
|
||||
addMember: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
juryGroupId: z.string(),
|
||||
userId: z.string(),
|
||||
role: z.enum(['CHAIR', 'MEMBER', 'OBSERVER']).default('MEMBER'),
|
||||
maxAssignmentsOverride: z.number().int().positive().nullable().optional(),
|
||||
capModeOverride: capModeEnum.nullable().optional(),
|
||||
categoryQuotasOverride: z
|
||||
.record(z.object({ min: z.number().int().nonnegative(), max: z.number().int().positive() }))
|
||||
.nullable()
|
||||
.optional(),
|
||||
preferredStartupRatio: z.number().min(0).max(1).nullable().optional(),
|
||||
availabilityNotes: z.string().nullable().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify the user exists
|
||||
await ctx.prisma.user.findUniqueOrThrow({
|
||||
where: { id: input.userId },
|
||||
})
|
||||
|
||||
// Check if already a member
|
||||
const existing = await ctx.prisma.juryGroupMember.findUnique({
|
||||
where: {
|
||||
juryGroupId_userId: {
|
||||
juryGroupId: input.juryGroupId,
|
||||
userId: input.userId,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'User is already a member of this jury group',
|
||||
})
|
||||
}
|
||||
|
||||
const member = await ctx.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.juryGroupMember.create({
|
||||
data: {
|
||||
juryGroupId: input.juryGroupId,
|
||||
userId: input.userId,
|
||||
role: input.role,
|
||||
maxAssignmentsOverride: input.maxAssignmentsOverride ?? undefined,
|
||||
capModeOverride: input.capModeOverride ?? undefined,
|
||||
categoryQuotasOverride: input.categoryQuotasOverride ?? undefined,
|
||||
preferredStartupRatio: input.preferredStartupRatio ?? undefined,
|
||||
availabilityNotes: input.availabilityNotes ?? undefined,
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, role: true } },
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'JuryGroupMember',
|
||||
entityId: created.id,
|
||||
detailsJson: {
|
||||
juryGroupId: input.juryGroupId,
|
||||
addedUserId: input.userId,
|
||||
role: input.role,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return member
|
||||
}),
|
||||
|
||||
/**
|
||||
* Remove a member from a jury group
|
||||
*/
|
||||
removeMember: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.juryGroupMember.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
})
|
||||
|
||||
await tx.juryGroupMember.delete({ where: { id: input.id } })
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'JuryGroupMember',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
juryGroupId: existing.juryGroupId,
|
||||
removedUserId: existing.userId,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return existing
|
||||
})
|
||||
|
||||
return member
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a jury group member's role/overrides
|
||||
*/
|
||||
updateMember: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
role: z.enum(['CHAIR', 'MEMBER', 'OBSERVER']).optional(),
|
||||
maxAssignmentsOverride: z.number().int().positive().nullable().optional(),
|
||||
capModeOverride: capModeEnum.nullable().optional(),
|
||||
categoryQuotasOverride: z
|
||||
.record(z.object({ min: z.number().int().nonnegative(), max: z.number().int().positive() }))
|
||||
.nullable()
|
||||
.optional(),
|
||||
preferredStartupRatio: z.number().min(0).max(1).nullable().optional(),
|
||||
availabilityNotes: z.string().nullable().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, categoryQuotasOverride, ...rest } = input
|
||||
|
||||
return ctx.prisma.juryGroupMember.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(categoryQuotasOverride !== undefined
|
||||
? {
|
||||
categoryQuotasOverride:
|
||||
categoryQuotasOverride === null
|
||||
? Prisma.JsonNull
|
||||
: (categoryQuotasOverride as Prisma.InputJsonValue),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, role: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Review self-service values set by jurors during onboarding.
|
||||
* Returns members who have self-service cap or ratio adjustments.
|
||||
*/
|
||||
reviewSelfServiceValues: adminProcedure
|
||||
.input(z.object({ juryGroupId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const group = await ctx.prisma.juryGroup.findUniqueOrThrow({
|
||||
where: { id: input.juryGroupId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
defaultMaxAssignments: true,
|
||||
allowJurorCapAdjustment: true,
|
||||
allowJurorRatioAdjustment: true,
|
||||
},
|
||||
})
|
||||
|
||||
const members = await ctx.prisma.juryGroupMember.findMany({
|
||||
where: {
|
||||
juryGroupId: input.juryGroupId,
|
||||
OR: [
|
||||
{ selfServiceCap: { not: null } },
|
||||
{ selfServiceRatio: { not: null } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
})
|
||||
|
||||
return {
|
||||
group,
|
||||
members: members.map((m) => ({
|
||||
id: m.id,
|
||||
userId: m.userId,
|
||||
userName: m.user.name,
|
||||
userEmail: m.user.email,
|
||||
role: m.role,
|
||||
adminCap: m.maxAssignmentsOverride ?? group.defaultMaxAssignments,
|
||||
selfServiceCap: m.selfServiceCap,
|
||||
selfServiceRatio: m.selfServiceRatio,
|
||||
preferredStartupRatio: m.preferredStartupRatio,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user