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:
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user