Files
MOPC-Portal/src/server/routers/round.ts

1278 lines
40 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
import { Prisma, type PrismaClient } 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'
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
import { createBulkNotifications } from '../services/in-app-notification'
import {
getAdvancementNotificationTemplate,
getRejectionNotificationTemplate,
sendStyledNotificationEmail,
sendInvitationEmail,
getBaseUrl,
} from '@/lib/email'
import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite'
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: {
include: {
user: { select: { id: true, name: true, email: 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(),
autoPassPending: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, targetRoundId, projectIds, autoPassPending } = input
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
// Get current round with competition context + status
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
select: { id: true, name: true, competitionId: true, sortOrder: true, status: true, configJson: true },
})
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
// Validate: current round must be ROUND_ACTIVE or ROUND_CLOSED
if (currentRound.status !== 'ROUND_ACTIVE' && currentRound.status !== 'ROUND_CLOSED') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Cannot advance from round with status ${currentRound.status}. Round must be ROUND_ACTIVE or ROUND_CLOSED.`,
})
}
// Determine target round
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
let targetRound: { id: string; name: string; competitionId: string; sortOrder: number; configJson: unknown }
if (targetRoundId) {
targetRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: targetRoundId },
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
select: { id: true, name: true, competitionId: true, sortOrder: true, configJson: true },
})
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
// Validate: target must be in same competition
if (targetRound.competitionId !== currentRound.competitionId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Target round must belong to the same competition as the source round.',
})
}
// Validate: target must be after current round
if (targetRound.sortOrder <= currentRound.sortOrder) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Target round must come after the current round (higher sortOrder).',
})
}
} 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' },
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
select: { id: true, name: true, competitionId: true, sortOrder: true, configJson: true },
})
if (!nextRound) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No subsequent round exists in this competition. Create the next round first.',
})
}
targetRound = nextRound
}
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
// Validate projectIds exist in current round if provided
if (projectIds && projectIds.length > 0) {
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
const existingStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, projectId: { in: projectIds } },
select: { projectId: true },
})
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
const existingIds = new Set(existingStates.map((s) => s.projectId))
const missing = projectIds.filter((id) => !existingIds.has(id))
if (missing.length > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Projects not found in current round: ${missing.join(', ')}`,
})
}
}
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
// Transaction: auto-pass + create entries in target round + mark current as COMPLETED
let autoPassedCount = 0
let idsToAdvance: string[]
await ctx.prisma.$transaction(async (tx) => {
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
// Auto-pass all PENDING projects first (for intake/bulk workflows) — inside tx
if (autoPassPending) {
const result = await tx.projectRoundState.updateMany({
where: { roundId, state: 'PENDING' },
data: { state: 'PASSED' },
})
autoPassedCount = result.count
}
// Determine which projects to advance
if (projectIds && projectIds.length > 0) {
idsToAdvance = projectIds
} else {
// Default: all PASSED projects in current round
const passedStates = await tx.projectRoundState.findMany({
where: { roundId, state: 'PASSED' },
select: { projectId: true },
})
idsToAdvance = passedStates.map((s) => s.projectId)
}
if (idsToAdvance.length === 0) return
// 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,
})),
})
})
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
// If nothing to advance (set inside tx), return early
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!idsToAdvance! || idsToAdvance!.length === 0) {
return { advancedCount: 0, autoPassedCount, targetRoundId: targetRound.id, targetRoundName: targetRound.name }
}
// 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,
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
projectCount: idsToAdvance!.length,
autoPassedCount,
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
projectIds: idsToAdvance!,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return {
Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX ## Critical Logic Fixes (Tier 1) - Fix requiredReviews config key mismatch (always defaulted to 3) - Fix double-email + stageName/roundName metadata mismatch in notifications - Fix snake_case config reads in peer review (peerReviewEnabled was always blocked) - Add server-side COI check to evaluation submit (was client-only) - Fix hard-coded feedbackText.min(10) — now uses config values - Fix binaryDecision corruption in non-binary scoring modes - Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx - Fix removeFromRound: now cleans up orphaned Assignment records - Fix 3-day reminder sending wrong email template (was using 24h template) ## High-Priority Logic Fixes (Tier 2) - Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED) - Scope AI assignment job to jury group members (was querying all JURY_MEMBERs) - Add COI awareness to AI assignment generation - Enforce requireAllCriteriaScored server-side - Fix expireIntentsForRound nested transaction (now uses caller's tx) - Implement notifyOnEntry for advancement path - Implement notifyOnAdvance (was dead config) - Fix checkRequirementsAndTransition for SubmissionFileRequirement model ## New Features (Tier 3) - Add Project to Round: dialog with "Create New" and "From Pool" tabs - Assignment "By Project" mode: select project → assign multiple jurors - Backend: project.createAndAssignToRound procedure ## UI/UX Improvements (Tier 4+5) - Add AlertDialog confirmation to header status dropdown - Replace native confirm() with AlertDialog in assignments table - Jury stats card now display-only with "Change" link - Assignments tab restructured into logical card groups - Inline-editable round name in header - Back button shows destination label - Readiness checklist: green check instead of strikethrough - Gate assignments tab when no jury group assigned - Relative time on window stats card - Toast feedback on date saves - Disable advance button when no target round - COI section shows placeholder when empty - Round position shown as "Round X of Y" - InlineMemberCap edit icon always visible - Status badge tooltip with description - Add REMINDER_3_DAYS email template - Fix maybeSendEmail to respect notification preferences - Optimize bulk notification email loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
advancedCount: idsToAdvance!.length,
autoPassedCount,
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' },
})
}),
/**
* Get the most recent SUBMISSION round config for a program.
* Used on the project edit page to show required document slots.
*/
getSubmissionRoundForProgram: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findFirst({
where: {
roundType: 'SUBMISSION',
competition: { programId: input.programId },
},
select: {
id: true,
name: true,
configJson: true,
},
orderBy: { sortOrder: 'desc' },
})
return round ?? null
}),
// =========================================================================
// Notification Procedures
// =========================================================================
previewAdvancementEmail: adminProcedure
.input(
z.object({
roundId: z.string(),
targetRoundId: z.string().optional(),
customMessage: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const { roundId, targetRoundId, customMessage } = input
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true, competition: { select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } } } },
})
// Determine target round name
const rounds = currentRound.competition.rounds
const currentIdx = rounds.findIndex((r) => r.id === roundId)
const targetRound = targetRoundId
? rounds.find((r) => r.id === targetRoundId)
: rounds[currentIdx + 1]
const toRoundName = targetRound?.name ?? 'Next Round'
// Count recipients: team members of PASSED or COMPLETED projects in this round
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: { in: ['PASSED', 'COMPLETED'] } },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
let recipientCount = 0
if (projectIds.length > 0) {
const teamMembers = await ctx.prisma.teamMember.findMany({
where: { projectId: { in: projectIds } },
select: { user: { select: { email: true } } },
})
const emails = new Set(teamMembers.map((tm) => tm.user.email).filter(Boolean))
// Also count submittedByEmail for projects without team member emails
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: { submittedByEmail: true, teamMembers: { select: { user: { select: { email: true } } } } },
})
for (const p of projects) {
const hasTeamEmail = p.teamMembers.some((tm) => tm.user.email)
if (!hasTeamEmail && p.submittedByEmail) {
emails.add(p.submittedByEmail)
}
}
recipientCount = emails.size
}
// Build preview HTML
const template = getAdvancementNotificationTemplate(
'Team Member',
'Your Project',
currentRound.name,
toRoundName,
customMessage || undefined
)
return { html: template.html, subject: template.subject, recipientCount }
}),
sendAdvancementNotifications: adminProcedure
.input(
z.object({
roundId: z.string(),
targetRoundId: z.string().optional(),
customMessage: z.string().optional(),
projectIds: z.array(z.string()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, targetRoundId, customMessage } = input
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true, competition: { select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } } } },
})
const rounds = currentRound.competition.rounds
const currentIdx = rounds.findIndex((r) => r.id === roundId)
const targetRound = targetRoundId
? rounds.find((r) => r.id === targetRoundId)
: rounds[currentIdx + 1]
const toRoundName = targetRound?.name ?? 'Next Round'
// Get target projects
let projectIds = input.projectIds
if (!projectIds) {
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: { in: ['PASSED', 'COMPLETED'] } },
select: { projectId: true },
})
projectIds = projectStates.map((ps) => ps.projectId)
}
if (projectIds.length === 0) {
return { sent: 0, failed: 0 }
}
// Fetch projects with team members
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: { user: { select: { id: true, email: true, name: true } } },
},
},
})
let sent = 0
let failed = 0
const allUserIds = new Set<string>()
for (const project of projects) {
const recipients = new Map<string, string | null>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, tm.user.name)
allUserIds.add(tm.user.id)
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
}
for (const [email, name] of recipients) {
try {
await sendStyledNotificationEmail(
email,
name || '',
'ADVANCEMENT_NOTIFICATION',
{
title: 'Your project has advanced!',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
fromRoundName: currentRound.name,
toRoundName,
customMessage: customMessage || undefined,
},
}
)
sent++
} catch (err) {
console.error(`[sendAdvancementNotifications] Failed for ${email}:`, err)
failed++
}
}
}
// Create in-app notifications
if (allUserIds.size > 0) {
void createBulkNotifications({
userIds: [...allUserIds],
type: 'project_advanced',
title: 'Your project has advanced!',
message: `Your project has advanced from "${currentRound.name}" to "${toRoundName}".`,
linkUrl: '/applicant',
linkLabel: 'View Dashboard',
icon: 'Trophy',
priority: 'high',
})
}
// Audit
await logAudit({
prisma: ctx.prisma,
userId: ctx.user?.id,
action: 'SEND_ADVANCEMENT_NOTIFICATIONS',
entityType: 'Round',
entityId: roundId,
detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, failed }
}),
previewRejectionEmail: adminProcedure
.input(
z.object({
roundId: z.string(),
customMessage: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const { roundId, customMessage } = input
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true },
})
// Count recipients: team members of REJECTED projects
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
let recipientCount = 0
if (projectIds.length > 0) {
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: { submittedByEmail: true, teamMembers: { select: { user: { select: { email: true } } } } },
})
const emails = new Set<string>()
for (const p of projects) {
const hasTeamEmail = p.teamMembers.some((tm) => tm.user.email)
if (hasTeamEmail) {
for (const tm of p.teamMembers) {
if (tm.user.email) emails.add(tm.user.email)
}
} else if (p.submittedByEmail) {
emails.add(p.submittedByEmail)
}
}
recipientCount = emails.size
}
const template = getRejectionNotificationTemplate(
'Team Member',
'Your Project',
round.name,
customMessage || undefined
)
return { html: template.html, subject: template.subject, recipientCount }
}),
sendRejectionNotifications: adminProcedure
.input(
z.object({
roundId: z.string(),
customMessage: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, customMessage } = input
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true },
})
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
if (projectIds.length === 0) {
return { sent: 0, failed: 0 }
}
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: { user: { select: { id: true, email: true, name: true } } },
},
},
})
let sent = 0
let failed = 0
const allUserIds = new Set<string>()
for (const project of projects) {
const recipients = new Map<string, string | null>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, tm.user.name)
allUserIds.add(tm.user.id)
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
}
for (const [email, name] of recipients) {
try {
await sendStyledNotificationEmail(
email,
name || '',
'REJECTION_NOTIFICATION',
{
title: 'Update on your application',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
roundName: round.name,
customMessage: customMessage || undefined,
},
}
)
sent++
} catch (err) {
console.error(`[sendRejectionNotifications] Failed for ${email}:`, err)
failed++
}
}
}
// In-app notifications
if (allUserIds.size > 0) {
void createBulkNotifications({
userIds: [...allUserIds],
type: 'NOT_SELECTED',
title: 'Update on your application',
message: `Your project was not selected to advance from "${round.name}".`,
linkUrl: '/applicant',
linkLabel: 'View Dashboard',
icon: 'Info',
})
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user?.id,
action: 'SEND_REJECTION_NOTIFICATIONS',
entityType: 'Round',
entityId: roundId,
detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, failed }
}),
getBulkInvitePreview: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const { roundId } = input
// Get all projects in this round
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
if (projectIds.length === 0) {
return { uninvitedCount: 0, totalTeamMembers: 0, alreadyInvitedCount: 0 }
}
// Get all team members for these projects
const teamMembers = await ctx.prisma.teamMember.findMany({
where: { projectId: { in: projectIds } },
select: { user: { select: { id: true, status: true } } },
})
// Deduplicate by user ID
const userMap = new Map<string, string>()
for (const tm of teamMembers) {
userMap.set(tm.user.id, tm.user.status)
}
let uninvitedCount = 0
let alreadyInvitedCount = 0
for (const [, status] of userMap) {
if (status === 'ACTIVE' || status === 'INVITED') {
alreadyInvitedCount++
} else {
uninvitedCount++
}
}
return {
uninvitedCount,
totalTeamMembers: userMap.size,
alreadyInvitedCount,
}
}),
bulkInviteTeamMembers: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const { roundId } = input
// Get all projects in this round
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
if (projectIds.length === 0) {
return { invited: 0, skipped: 0, failed: 0 }
}
// Get all team members with user details
const teamMembers = await ctx.prisma.teamMember.findMany({
where: { projectId: { in: projectIds } },
select: {
user: { select: { id: true, email: true, name: true, status: true, role: true } },
},
})
// Deduplicate by user ID
const users = new Map<string, { id: string; email: string; name: string | null; status: string; role: string }>()
for (const tm of teamMembers) {
if (tm.user.email && !users.has(tm.user.id)) {
users.set(tm.user.id, tm.user)
}
}
const baseUrl = getBaseUrl()
const expiryHours = await getInviteExpiryHours(ctx.prisma as unknown as import('@prisma/client').PrismaClient)
const expiryMs = expiryHours * 60 * 60 * 1000
let invited = 0
let skipped = 0
let failed = 0
for (const [, user] of users) {
if (user.status === 'ACTIVE' || user.status === 'INVITED') {
skipped++
continue
}
try {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
invited++
} catch (err) {
console.error(`[bulkInviteTeamMembers] Failed for ${user.email}:`, err)
failed++
}
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user?.id,
action: 'BULK_INVITE_TEAM_MEMBERS',
entityType: 'Round',
entityId: roundId,
detailsJson: { invited, skipped, failed, totalUsers: users.size },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { invited, skipped, failed }
}),
})