Competition/Round architecture: full platform rewrite (Phases 1-9)
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:
2026-02-15 23:04:15 +01:00
parent 9ab4717f96
commit 6ca39c976b
349 changed files with 69938 additions and 28767 deletions

View File

@@ -581,7 +581,19 @@ export const userRouter = router({
.array(
z.object({
projectId: z.string(),
stageId: z.string(),
roundId: z.string(),
})
)
.optional(),
// Competition architecture: optional jury group memberships
juryGroupIds: z.array(z.string()).optional(),
juryGroupRole: z.enum(['CHAIR', 'MEMBER', 'OBSERVER']).default('MEMBER'),
// Competition architecture: optional assignment intents
assignmentIntents: z
.array(
z.object({
roundId: z.string(),
projectId: z.string(),
})
)
.optional(),
@@ -633,11 +645,19 @@ export const userRouter = router({
return { created: 0, skipped }
}
const emailToAssignments = new Map<string, Array<{ projectId: string; stageId: string }>>()
const emailToAssignments = new Map<string, Array<{ projectId: string; roundId: string }>>()
const emailToJuryGroupIds = new Map<string, { ids: string[]; role: 'CHAIR' | 'MEMBER' | 'OBSERVER' }>()
const emailToIntents = new Map<string, Array<{ roundId: string; projectId: string }>>()
for (const u of newUsers) {
if (u.assignments && u.assignments.length > 0) {
emailToAssignments.set(u.email.toLowerCase(), u.assignments)
}
if (u.juryGroupIds && u.juryGroupIds.length > 0) {
emailToJuryGroupIds.set(u.email.toLowerCase(), { ids: u.juryGroupIds, role: u.juryGroupRole })
}
if (u.assignmentIntents && u.assignmentIntents.length > 0) {
emailToIntents.set(u.email.toLowerCase(), u.assignmentIntents)
}
}
const created = await ctx.prisma.user.createMany({
@@ -678,7 +698,7 @@ export const userRouter = router({
data: {
userId: user.id,
projectId: assignment.projectId,
stageId: assignment.stageId,
roundId: assignment.roundId,
method: 'MANUAL',
createdBy: ctx.user.id,
},
@@ -704,6 +724,79 @@ export const userRouter = router({
})
}
// Create JuryGroupMember records for users with juryGroupIds
let juryGroupMembershipsCreated = 0
let assignmentIntentsCreated = 0
for (const user of createdUsers) {
const groupInfo = emailToJuryGroupIds.get(user.email.toLowerCase())
if (groupInfo) {
for (const groupId of groupInfo.ids) {
try {
await ctx.prisma.juryGroupMember.create({
data: {
juryGroupId: groupId,
userId: user.id,
role: groupInfo.role,
},
})
juryGroupMembershipsCreated++
} catch {
// Skip if membership already exists
}
}
}
// Create AssignmentIntents for users who have them
const intents = emailToIntents.get(user.email.toLowerCase())
if (intents) {
for (const intent of intents) {
try {
// Look up the round's juryGroupId to find the matching JuryGroupMember
const round = await ctx.prisma.round.findUnique({
where: { id: intent.roundId },
select: { juryGroupId: true },
})
if (round?.juryGroupId) {
const member = await ctx.prisma.juryGroupMember.findUnique({
where: {
juryGroupId_userId: {
juryGroupId: round.juryGroupId,
userId: user.id,
},
},
})
if (member) {
await ctx.prisma.assignmentIntent.create({
data: {
juryGroupMemberId: member.id,
roundId: intent.roundId,
projectId: intent.projectId,
source: 'INVITE',
status: 'INTENT_PENDING',
},
})
assignmentIntentsCreated++
}
}
} catch {
// Skip duplicate intents
}
}
}
}
if (juryGroupMembershipsCreated > 0) {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_CREATE',
entityType: 'JuryGroupMember',
detailsJson: { count: juryGroupMembershipsCreated, context: 'invitation_jury_group_binding' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
}
// Send invitation emails if requested
let emailsSent = 0
const emailErrors: string[] = []
@@ -751,7 +844,7 @@ export const userRouter = router({
}
}
return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated, invitationSent: input.sendInvitation }
return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated, juryGroupMembershipsCreated, assignmentIntentsCreated, invitationSent: input.sendInvitation }
}),
/**
@@ -760,7 +853,7 @@ export const userRouter = router({
getJuryMembers: adminProcedure
.input(
z.object({
stageId: z.string().optional(),
roundId: z.string().optional(),
search: z.string().optional(),
})
)
@@ -791,8 +884,8 @@ export const userRouter = router({
profileImageProvider: true,
_count: {
select: {
assignments: input.stageId
? { where: { stageId: input.stageId } }
assignments: input.roundId
? { where: { roundId: input.roundId } }
: true,
},
},
@@ -816,7 +909,10 @@ export const userRouter = router({
* Send invitation email to a user
*/
sendInvitation: adminProcedure
.input(z.object({ userId: z.string() }))
.input(z.object({
userId: z.string(),
juryGroupId: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.userId },
@@ -829,6 +925,24 @@ export const userRouter = router({
})
}
// Bind to jury group if specified (upsert to be idempotent)
if (input.juryGroupId) {
await ctx.prisma.juryGroupMember.upsert({
where: {
juryGroupId_userId: {
juryGroupId: input.juryGroupId,
userId: user.id,
},
},
create: {
juryGroupId: input.juryGroupId,
userId: user.id,
role: 'MEMBER',
},
update: {}, // No-op if already exists
})
}
// Generate invite token, set status to INVITED, and store on user
const token = generateInviteToken()
await ctx.prisma.user.update({
@@ -961,6 +1075,16 @@ export const userRouter = router({
bio: z.string().max(500).optional(),
expertiseTags: z.array(z.string()).optional(),
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
// Competition architecture: jury self-service preferences
juryPreferences: z
.array(
z.object({
juryGroupMemberId: z.string(),
selfServiceCap: z.number().int().positive().optional(),
selfServiceRatio: z.number().min(0).max(1).optional(),
})
)
.optional(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -990,13 +1114,46 @@ export const userRouter = router({
},
})
// Process jury self-service preferences
if (input.juryPreferences && input.juryPreferences.length > 0) {
for (const pref of input.juryPreferences) {
// Security: verify this member belongs to the current user
const member = await tx.juryGroupMember.findUnique({
where: { id: pref.juryGroupMemberId },
include: { juryGroup: { select: { allowJurorCapAdjustment: true, allowJurorRatioAdjustment: true, defaultMaxAssignments: true } } },
})
if (!member || member.userId !== ctx.user.id) continue
const updateData: Record<string, unknown> = {}
// Only set selfServiceCap if group allows it
if (pref.selfServiceCap != null && member.juryGroup.allowJurorCapAdjustment) {
// Bound by admin max (override or group default)
const adminMax = member.maxAssignmentsOverride ?? member.juryGroup.defaultMaxAssignments
updateData.selfServiceCap = Math.min(pref.selfServiceCap, adminMax)
}
// Only set selfServiceRatio if group allows it
if (pref.selfServiceRatio != null && member.juryGroup.allowJurorRatioAdjustment) {
updateData.selfServiceRatio = pref.selfServiceRatio
}
if (Object.keys(updateData).length > 0) {
await tx.juryGroupMember.update({
where: { id: pref.juryGroupMemberId },
data: updateData,
})
}
}
}
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'COMPLETE_ONBOARDING',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: { name: input.name },
detailsJson: { name: input.name, juryPreferencesCount: input.juryPreferences?.length ?? 0 },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
@@ -1007,6 +1164,46 @@ export const userRouter = router({
return user
}),
/**
* Get onboarding context for the current user.
* Returns jury group memberships that allow self-service preferences.
*/
getOnboardingContext: protectedProcedure.query(async ({ ctx }) => {
const memberships = await ctx.prisma.juryGroupMember.findMany({
where: { userId: ctx.user.id },
include: {
juryGroup: {
select: {
id: true,
name: true,
defaultMaxAssignments: true,
allowJurorCapAdjustment: true,
allowJurorRatioAdjustment: true,
categoryQuotasEnabled: true,
defaultCategoryQuotas: true,
},
},
},
})
const selfServiceGroups = memberships.filter(
(m) => m.juryGroup.allowJurorCapAdjustment || m.juryGroup.allowJurorRatioAdjustment,
)
return {
hasSelfServiceOptions: selfServiceGroups.length > 0,
memberships: selfServiceGroups.map((m) => ({
juryGroupMemberId: m.id,
juryGroupName: m.juryGroup.name,
currentCap: m.maxAssignmentsOverride ?? m.juryGroup.defaultMaxAssignments,
allowCapAdjustment: m.juryGroup.allowJurorCapAdjustment,
allowRatioAdjustment: m.juryGroup.allowJurorRatioAdjustment,
selfServiceCap: m.selfServiceCap,
selfServiceRatio: m.selfServiceRatio,
})),
}
}),
/**
* Check if current user needs onboarding
*/