# Jury Groups — Multi-Jury Architecture ## Overview The **JuryGroup** model is the backbone of the redesigned jury system. Instead of implicit jury membership derived from per-stage assignments, juries are now **first-class named entities** — "Jury 1", "Jury 2", "Jury 3", "Innovation Award Jury" — with explicit membership, configurable assignment caps, and per-juror overrides. ### Why This Matters | Before (Current) | After (Redesigned) | |---|---| | Juries are implicit — "Jury 1" exists only in admin's head | JuryGroup is a named model with `id`, `name`, `description` | | Assignment caps are per-stage config | Caps are per-juror on JuryGroupMember (with group defaults) | | No concept of "which jury is this juror on" | JuryGroupMember links User to JuryGroup explicitly | | Same juror can't be on multiple juries (no grouping) | A User can belong to multiple JuryGroups | | Category quotas don't exist | Per-juror STARTUP/CONCEPT ratio preferences | | No juror onboarding preferences | JuryGroupMember stores language, expertise, preferences | ### Jury Groups in the 8-Step Flow ``` Round 1: INTAKE — no jury Round 2: FILTERING — no jury (AI-powered) Round 3: EVALUATION — ► Jury 1 (semi-finalist selection) Round 4: SUBMISSION — no jury Round 5: EVALUATION — ► Jury 2 (finalist selection) Round 6: MENTORING — no jury Round 7: LIVE_FINAL — ► Jury 3 (live finals scoring) Round 8: CONFIRMATION — ► Jury 3 (winner confirmation) Special Awards: Innovation Award — ► Innovation Jury (may overlap with Jury 2) Impact Award — ► Impact Jury (dedicated members) ``` --- ## Data Model ### JuryGroup ```prisma model JuryGroup { id String @id @default(cuid()) competitionId String name String // "Jury 1", "Jury 2", "Jury 3", "Innovation Award Jury" description String? // "Semi-finalist evaluation jury — reviews 60+ applications" isActive Boolean @default(true) // Default assignment configuration for this jury defaultMaxAssignments Int @default(20) defaultCapMode CapMode @default(SOFT) softCapBuffer Int @default(2) // Extra assignments above cap // Default category quotas (per juror) defaultCategoryQuotas Json? @db.JsonB // Shape: { "STARTUP": { min: 5, max: 12 }, "BUSINESS_CONCEPT": { min: 5, max: 12 } } // Onboarding onboardingFormId String? // Link to onboarding form (expertise, preferences) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations competition Competition @relation(...) members JuryGroupMember[] rounds Round[] // Rounds this jury is assigned to assignments Assignment[] // Assignments made through this jury group } ``` ### JuryGroupMember ```prisma model JuryGroupMember { id String @id @default(cuid()) juryGroupId String userId String role String @default("MEMBER") // "MEMBER" | "CHAIR" | "OBSERVER" // Per-juror overrides (null = use group defaults) maxAssignmentsOverride Int? capModeOverride CapMode? categoryQuotasOverride Json? @db.JsonB // Juror preferences (set during onboarding) preferredStartupRatio Float? // 0.0–1.0 (e.g., 0.6 = 60% startups) expertiseTags String[] // ["ocean-tech", "marine-biology", "finance"] languagePreferences String[] // ["en", "fr"] notes String? // Admin notes about this juror createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations juryGroup JuryGroup @relation(...) user User @relation(...) @@unique([juryGroupId, userId]) @@index([juryGroupId]) @@index([userId]) } ``` ### CapMode Enum ```prisma enum CapMode { HARD // Absolute maximum — algorithm cannot exceed under any circumstance SOFT // Target maximum — can exceed by softCapBuffer for load balancing NONE // No cap — unlimited assignments (use with caution) } ``` ### Cap Behavior | Mode | Max | Buffer | Effective Limit | Behavior | |------|-----|--------|-----------------|----------| | HARD | 20 | — | 20 | Algorithm stops at exactly 20. No exceptions. | | SOFT | 20 | 2 | 22 | Algorithm targets 20 but can go to 22 if needed for balanced distribution | | NONE | — | — | ∞ | No limit. Juror can be assigned any number of projects | ```typescript function getEffectiveCap(member: JuryGroupMember, group: JuryGroup): number | null { const capMode = member.capModeOverride ?? group.defaultCapMode; const maxAssignments = member.maxAssignmentsOverride ?? group.defaultMaxAssignments; switch (capMode) { case 'HARD': return maxAssignments; case 'SOFT': return maxAssignments + group.softCapBuffer; case 'NONE': return null; // no limit } } function canAssignMore( member: JuryGroupMember, group: JuryGroup, currentCount: number ): { allowed: boolean; reason?: string } { const cap = getEffectiveCap(member, group); if (cap === null) return { allowed: true }; if (currentCount >= cap) { return { allowed: false, reason: `Juror has reached ${capMode === 'HARD' ? 'hard' : 'soft'} cap of ${cap} assignments`, }; } return { allowed: true }; } ``` --- ## Category Quotas ### How Quotas Work Each jury group (and optionally each member) can define minimum and maximum assignments per competition category. This ensures balanced coverage: ```typescript type CategoryQuotas = { STARTUP: { min: number; max: number }; BUSINESS_CONCEPT: { min: number; max: number }; }; // Example: group default const defaultQuotas: CategoryQuotas = { STARTUP: { min: 5, max: 12 }, BUSINESS_CONCEPT: { min: 5, max: 12 }, }; ``` ### Quota Resolution Per-juror overrides take precedence over group defaults: ```typescript function getEffectiveQuotas( member: JuryGroupMember, group: JuryGroup ): CategoryQuotas | null { if (member.categoryQuotasOverride) { return member.categoryQuotasOverride as CategoryQuotas; } if (group.defaultCategoryQuotas) { return group.defaultCategoryQuotas as CategoryQuotas; } return null; // no quotas — assign freely } ``` ### Quota Enforcement During Assignment ```typescript function checkCategoryQuota( member: JuryGroupMember, group: JuryGroup, category: CompetitionCategory, currentCategoryCount: number ): { allowed: boolean; warning?: string } { const quotas = getEffectiveQuotas(member, group); if (!quotas) return { allowed: true }; const categoryQuota = quotas[category]; if (!categoryQuota) return { allowed: true }; if (currentCategoryCount >= categoryQuota.max) { return { allowed: false, warning: `Juror has reached max ${categoryQuota.max} for ${category}`, }; } return { allowed: true }; } function checkMinimumQuotasMet( member: JuryGroupMember, group: JuryGroup, categoryCounts: Record ): { met: boolean; deficits: string[] } { const quotas = getEffectiveQuotas(member, group); if (!quotas) return { met: true, deficits: [] }; const deficits: string[] = []; for (const [category, quota] of Object.entries(quotas)) { const count = categoryCounts[category as CompetitionCategory] ?? 0; if (count < quota.min) { deficits.push(`${category}: ${count}/${quota.min} minimum`); } } return { met: deficits.length === 0, deficits }; } ``` --- ## Preferred Startup Ratio Each juror can express a preference for what percentage of their assignments should be Startups vs Concepts. ```typescript // On JuryGroupMember: preferredStartupRatio: Float? // 0.0 to 1.0 // Usage in assignment algorithm: function calculateRatioAlignmentScore( member: JuryGroupMember, candidateCategory: CompetitionCategory, currentStartupCount: number, currentConceptCount: number ): number { const preference = member.preferredStartupRatio; if (preference === null || preference === undefined) return 0; // no preference const totalAfterAssignment = currentStartupCount + currentConceptCount + 1; const startupCountAfter = candidateCategory === 'STARTUP' ? currentStartupCount + 1 : currentStartupCount; const ratioAfter = startupCountAfter / totalAfterAssignment; // Score: how close does adding this assignment bring the ratio to preference? const deviation = Math.abs(ratioAfter - preference); // Scale: 0 deviation = 10pts, 0.5 deviation = 0pts return Math.max(0, 10 * (1 - deviation * 2)); } ``` This score feeds into the assignment algorithm alongside tag overlap, workload balance, and geo-diversity. --- ## Juror Roles Each JuryGroupMember has a `role` field: | Role | Capabilities | Description | |------|-------------|-------------| | `MEMBER` | Evaluate assigned projects, vote in live finals, confirm winners | Standard jury member | | `CHAIR` | All MEMBER capabilities + view all evaluations, moderate discussions, suggest assignments | Jury chairperson — has broader visibility | | `OBSERVER` | View evaluations (read-only), no scoring or voting | Observes the jury process without participating | ### Role-Based Visibility ```typescript function getJurorVisibility( role: string, ownAssignments: Assignment[] ): VisibilityScope { switch (role) { case 'CHAIR': return { canSeeAllEvaluations: true, canSeeAllAssignments: true, canModerateDiscussions: true, canSuggestReassignments: true, }; case 'MEMBER': return { canSeeAllEvaluations: false, // only their own canSeeAllAssignments: false, canModerateDiscussions: false, canSuggestReassignments: false, }; case 'OBSERVER': return { canSeeAllEvaluations: true, // read-only canSeeAllAssignments: true, canModerateDiscussions: false, canSuggestReassignments: false, }; } } ``` --- ## Multi-Jury Membership A single user can be on multiple jury groups. This is common for: - A juror on Jury 2 (finalist selection) AND Innovation Award Jury - A senior juror on Jury 1 AND Jury 3 (semi-finalist + live finals) ### Overlap Handling ```typescript // Get all jury groups for a user in a competition async function getUserJuryGroups( userId: string, competitionId: string ): Promise { return prisma.juryGroupMember.findMany({ where: { userId, juryGroup: { competitionId }, }, include: { juryGroup: true }, }); } // Check if user is on a specific jury async function isUserOnJury( userId: string, juryGroupId: string ): Promise { const member = await prisma.juryGroupMember.findUnique({ where: { juryGroupId_userId: { juryGroupId, userId } }, }); return member !== null; } ``` ### Cross-Jury COI Propagation When a juror declares a Conflict of Interest for a project in one jury group, it should propagate to all their jury memberships: ```typescript async function propagateCOI( userId: string, projectId: string, competitionId: string, reason: string ): Promise { // Find all jury groups this user is on const memberships = await getUserJuryGroups(userId, competitionId); for (const membership of memberships) { // Find assignments for this user+project in each jury group const assignments = await prisma.assignment.findMany({ where: { userId, projectId, juryGroupId: membership.juryGroupId, }, }); for (const assignment of assignments) { // Check if COI already declared const existing = await prisma.conflictOfInterest.findUnique({ where: { assignmentId: assignment.id }, }); if (!existing) { await prisma.conflictOfInterest.create({ data: { assignmentId: assignment.id, reason: `Auto-propagated from ${membership.juryGroup.name}: ${reason}`, declared: true, }, }); } } } } ``` --- ## Jury Group Lifecycle ### States ``` DRAFT → ACTIVE → LOCKED → ARCHIVED ``` | State | Description | Operations Allowed | |-------|-------------|-------------------| | DRAFT | Being configured. Members can be added/removed freely | Add/remove members, edit settings | | ACTIVE | Jury is in use. Assignments are being made or evaluations in progress | Add members (with warning), edit per-juror settings | | LOCKED | Evaluation or voting is in progress. No membership changes | Edit per-juror notes only | | ARCHIVED | Competition complete. Preserved for records | Read-only | ### State Transitions ```typescript // Jury group activates when its linked round starts async function activateJuryGroup(juryGroupId: string): Promise { await prisma.juryGroup.update({ where: { id: juryGroupId }, data: { isActive: true }, }); } // Jury group locks when evaluation/voting begins async function lockJuryGroup(juryGroupId: string): Promise { // Prevent membership changes during active evaluation await prisma.juryGroup.update({ where: { id: juryGroupId }, data: { isActive: false }, // Using isActive as soft-lock; could add separate locked field }); } ``` --- ## Onboarding ### Juror Onboarding Flow When a juror is added to a JuryGroup, they go through an onboarding process: 1. **Invitation** — Admin adds juror to group → juror receives email invitation 2. **Profile Setup** — Juror fills out expertise tags, language preferences, category preference 3. **COI Pre-declaration** — Juror reviews project list and declares any pre-existing conflicts 4. **Confirmation** — Juror confirms they understand their role and responsibilities ### Onboarding UI ``` ┌──────────────────────────────────────────────────────────────────┐ │ Welcome to Jury 1 — Semi-finalist Evaluation │ │ │ │ You've been selected to evaluate projects for the │ │ Monaco Ocean Protection Challenge 2026. │ │ │ │ Step 1 of 3: Your Expertise │ │ ───────────────────────────────────────────────────────────── │ │ │ │ Select your areas of expertise (used for matching): │ │ ☑ Marine Biology ☑ Ocean Technology │ │ ☐ Renewable Energy ☑ Environmental Policy │ │ ☐ Finance/Investment ☐ Social Impact │ │ ☐ Data Science ☐ Education │ │ │ │ Preferred languages: │ │ ☑ English ☑ French ☐ Other: [________] │ │ │ │ Category preference (what % Startups vs Concepts): │ │ Startups [====●=========] Concepts │ │ 60% / 40% │ │ │ │ [ Back ] [ Next Step → ] │ └──────────────────────────────────────────────────────────────────┘ ``` ``` ┌──────────────────────────────────────────────────────────────────┐ │ Step 2 of 3: Conflict of Interest Declaration │ │ ───────────────────────────────────────────────────────────── │ │ │ │ Please review the project list and declare any conflicts │ │ of interest. A COI exists if you have a personal, │ │ financial, or professional relationship with a project team. │ │ │ │ ┌──────────────────────────────────────┬──────────────────┐ │ │ │ Project │ COI? │ │ │ ├──────────────────────────────────────┼──────────────────┤ │ │ │ OceanClean AI │ ○ None │ │ │ │ DeepReef Monitoring │ ● COI Declared │ │ │ │ CoralGuard │ ○ None │ │ │ │ WaveEnergy Solutions │ ○ None │ │ │ │ ... (60 more projects) │ │ │ │ └──────────────────────────────────────┴──────────────────┘ │ │ │ │ COI Details for "DeepReef Monitoring": │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Former colleague of team lead. Worked together 2022-23. │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ [ Back ] [ Next Step → ] │ └──────────────────────────────────────────────────────────────────┘ ``` ``` ┌──────────────────────────────────────────────────────────────────┐ │ Step 3 of 3: Confirmation │ │ ───────────────────────────────────────────────────────────── │ │ │ │ By confirming, you agree to: │ │ ☑ Evaluate assigned projects fairly and impartially │ │ ☑ Complete evaluations by the deadline │ │ ☑ Maintain confidentiality of all submissions │ │ ☑ Report any additional conflicts of interest │ │ │ │ Your assignments: up to 20 projects │ │ Evaluation deadline: March 15, 2026 │ │ Category target: ~12 Startups / ~8 Concepts │ │ │ │ [ Back ] [ ✓ Confirm & Start ] │ └──────────────────────────────────────────────────────────────────┘ ``` --- ## Admin Jury Management ### Jury Group Dashboard ``` ┌──────────────────────────────────────────────────────────────────┐ │ Jury Groups — MOPC 2026 │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Jury 1 — Semi-finalist Selection [Edit]│ │ │ │ Members: 8 | Linked to: Round 3 | Status: ACTIVE │ │ │ │ Cap: 20 (SOFT +2) | Avg load: 15.3 projects │ │ │ │ ████████████████░░░░ 76% assignments complete │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Jury 2 — Finalist Selection [Edit]│ │ │ │ Members: 6 | Linked to: Round 5 | Status: DRAFT │ │ │ │ Cap: 15 (HARD) | Not yet assigned │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Jury 3 — Live Finals [Edit]│ │ │ │ Members: 5 | Linked to: Round 7, Round 8 | Status: DRAFT │ │ │ │ All finalists assigned to all jurors │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Innovation Award Jury [Edit]│ │ │ │ Members: 4 | Linked to: Innovation Award | Status: DRAFT │ │ │ │ Shares 2 members with Jury 2 │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ [ + Create Jury Group ] │ └──────────────────────────────────────────────────────────────────┘ ``` ### Member Management ``` ┌──────────────────────────────────────────────────────────────────┐ │ Jury 1 — Member Management │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ Group Defaults: Max 20 | SOFT cap (+2) | Quotas: S:5-12 C:5-12│ │ │ │ ┌──────┬──────────────┬──────┬─────┬──────┬──────┬──────────┐ │ │ │ Role │ Name │ Load │ Cap │ S/C │ Pref │ Actions │ │ │ ├──────┼──────────────┼──────┼─────┼──────┼──────┼──────────┤ │ │ │ CHAIR│ Dr. Martin │ 18 │ 20S │ 11/7 │ 60% │ [Edit] │ │ │ │ MEMBER│ Prof. Dubois│ 15 │ 20S │ 9/6 │ 50% │ [Edit] │ │ │ │ MEMBER│ Ms. Chen │ 20 │ 20H │ 12/8 │ 60% │ [Edit] │ │ │ │ MEMBER│ Dr. Patel │ 12 │ 15* │ 7/5 │ — │ [Edit] │ │ │ │ MEMBER│ Mr. Silva │ 16 │ 20S │ 10/6 │ 70% │ [Edit] │ │ │ │ MEMBER│ Dr. Yamada │ 19 │ 20S │ 11/8 │ 55% │ [Edit] │ │ │ │ MEMBER│ Ms. Hansen │ 14 │ 20S │ 8/6 │ — │ [Edit] │ │ │ │ OBS │ Mr. Berger │ — │ — │ — │ — │ [Edit] │ │ │ └──────┴──────────────┴──────┴─────┴──────┴──────┴──────────┘ │ │ │ │ * = per-juror override S = SOFT H = HARD │ │ S/C = Startup/Concept count Pref = preferred startup ratio │ │ │ │ [ + Add Member ] [ Import from CSV ] [ Run AI Assignment ] │ └──────────────────────────────────────────────────────────────────┘ ``` ### Per-Juror Override Sheet ``` ┌──────────────────────────────────────────────────────────────────┐ │ Edit Juror Settings — Dr. Patel │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ Role: [MEMBER ▼] │ │ │ │ ── Assignment Overrides ────────────────────────────────────── │ │ (Leave blank to use group defaults) │ │ │ │ Max assignments: [15 ] (group default: 20) │ │ Cap mode: [HARD ▼] (group default: SOFT) │ │ │ │ Category quotas: │ │ Startups: min [3 ] max [10] (group: 5-12) │ │ Concepts: min [3 ] max [8 ] (group: 5-12) │ │ │ │ ── Preferences ─────────────────────────────────────────────── │ │ │ │ Preferred startup ratio: [ ] % (blank = no preference) │ │ Expertise tags: [marine-biology, policy, ...] │ │ Language: [English, French] │ │ │ │ ── Notes ───────────────────────────────────────────────────── │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Dr. Patel requested reduced load due to conference │ │ │ │ schedule in March. Hard cap at 15. │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ [ Cancel ] [ Save Changes ] │ └──────────────────────────────────────────────────────────────────┘ ``` --- ## Integration with Assignment Algorithm The assignment algorithm (see `06-round-evaluation.md`) uses JuryGroup data at every step: ### Algorithm Input ```typescript type AssignmentInput = { roundId: string; juryGroupId: string; projects: Project[]; config: { requiredReviewsPerProject: number; }; }; ``` ### Algorithm Steps Using JuryGroup 1. **Load jury members** — Fetch all active JuryGroupMembers with role != OBSERVER 2. **Resolve effective limits** — For each member, compute effective cap and quotas 3. **Filter by COI** — Exclude members with declared COI for each project 4. **Score candidates** — For each (project, juror) pair, compute: - Tag overlap score (expertise alignment) - Workload balance score (prefer jurors with fewer assignments) - Category ratio alignment score (prefer assignment that brings ratio closer to preference) - Geo-diversity score 5. **Apply caps** — Skip jurors who have reached their effective cap 6. **Apply quotas** — Skip jurors who have reached category max 7. **Rank and assign** — Greedily assign top-scoring pairs 8. **Validate minimums** — Check if category minimums are met; warn admin if not ### Assignment Preview ```typescript type AssignmentPreview = { assignments: { userId: string; projectId: string; score: number; breakdown: { tagOverlap: number; workloadBalance: number; ratioAlignment: number; geoDiversity: number; }; }[]; warnings: { type: 'CAP_EXCEEDED' | 'QUOTA_UNMET' | 'COI_SKIP' | 'UNASSIGNED_PROJECT'; message: string; userId?: string; projectId?: string; }[]; stats: { totalAssignments: number; avgLoadPerJuror: number; minLoad: number; maxLoad: number; unassignedProjects: number; categoryDistribution: Record; }; }; ``` --- ## API Procedures ### New tRPC Router: jury-group.ts ```typescript export const juryGroupRouter = router({ // ── CRUD ─────────────────────────────────────────────── /** Create a new jury group */ create: adminProcedure .input(z.object({ competitionId: z.string(), name: z.string().min(1).max(100), description: z.string().optional(), defaultMaxAssignments: z.number().int().min(1).default(20), defaultCapMode: z.enum(['HARD', 'SOFT', 'NONE']).default('SOFT'), softCapBuffer: z.number().int().min(0).default(2), defaultCategoryQuotas: z.record(z.object({ min: z.number().int().min(0), max: z.number().int().min(0), })).optional(), })) .mutation(async ({ input }) => { ... }), /** Update jury group settings */ update: adminProcedure .input(z.object({ juryGroupId: z.string(), name: z.string().min(1).max(100).optional(), description: z.string().optional(), defaultMaxAssignments: z.number().int().min(1).optional(), defaultCapMode: z.enum(['HARD', 'SOFT', 'NONE']).optional(), softCapBuffer: z.number().int().min(0).optional(), defaultCategoryQuotas: z.record(z.object({ min: z.number().int().min(0), max: z.number().int().min(0), })).nullable().optional(), })) .mutation(async ({ input }) => { ... }), /** Delete jury group (only if DRAFT and no assignments) */ delete: adminProcedure .input(z.object({ juryGroupId: z.string() })) .mutation(async ({ input }) => { ... }), /** Get jury group with members */ getById: protectedProcedure .input(z.object({ juryGroupId: z.string() })) .query(async ({ input }) => { ... }), /** List all jury groups for a competition */ listByCompetition: protectedProcedure .input(z.object({ competitionId: z.string() })) .query(async ({ input }) => { ... }), // ── Members ──────────────────────────────────────────── /** Add a member to the jury group */ addMember: adminProcedure .input(z.object({ juryGroupId: z.string(), userId: z.string(), role: z.enum(['MEMBER', 'CHAIR', 'OBSERVER']).default('MEMBER'), })) .mutation(async ({ input }) => { ... }), /** Remove a member from the jury group */ removeMember: adminProcedure .input(z.object({ juryGroupId: z.string(), userId: z.string(), })) .mutation(async ({ input }) => { ... }), /** Batch add members (from CSV or user selection) */ addMembersBatch: adminProcedure .input(z.object({ juryGroupId: z.string(), members: z.array(z.object({ userId: z.string(), role: z.enum(['MEMBER', 'CHAIR', 'OBSERVER']).default('MEMBER'), })), })) .mutation(async ({ input }) => { ... }), /** Update member settings (overrides, preferences) */ updateMember: adminProcedure .input(z.object({ juryGroupId: z.string(), userId: z.string(), role: z.enum(['MEMBER', 'CHAIR', 'OBSERVER']).optional(), maxAssignmentsOverride: z.number().int().min(1).nullable().optional(), capModeOverride: z.enum(['HARD', 'SOFT', 'NONE']).nullable().optional(), categoryQuotasOverride: z.record(z.object({ min: z.number().int().min(0), max: z.number().int().min(0), })).nullable().optional(), preferredStartupRatio: z.number().min(0).max(1).nullable().optional(), expertiseTags: z.array(z.string()).optional(), languagePreferences: z.array(z.string()).optional(), notes: z.string().nullable().optional(), })) .mutation(async ({ input }) => { ... }), // ── Queries ──────────────────────────────────────────── /** Get all jury groups a user belongs to */ getMyJuryGroups: juryProcedure .query(async ({ ctx }) => { ... }), /** Get assignment stats for a jury group */ getAssignmentStats: adminProcedure .input(z.object({ juryGroupId: z.string() })) .query(async ({ input }) => { ... }), /** Check if a user can be added (no duplicate, role compatible) */ checkMemberEligibility: adminProcedure .input(z.object({ juryGroupId: z.string(), userId: z.string(), })) .query(async ({ input }) => { ... }), // ── Onboarding ───────────────────────────────────────── /** Get onboarding status for a juror */ getOnboardingStatus: juryProcedure .input(z.object({ juryGroupId: z.string() })) .query(async ({ ctx, input }) => { ... }), /** Submit onboarding form (preferences, COI declarations) */ submitOnboarding: juryProcedure .input(z.object({ juryGroupId: z.string(), expertiseTags: z.array(z.string()), languagePreferences: z.array(z.string()), preferredStartupRatio: z.number().min(0).max(1).optional(), coiDeclarations: z.array(z.object({ projectId: z.string(), reason: z.string(), })), })) .mutation(async ({ ctx, input }) => { ... }), }); ``` --- ## Service Functions ```typescript // src/server/services/jury-group.ts /** Create a jury group with defaults */ export async function createJuryGroup( competitionId: string, name: string, config?: Partial ): Promise; /** Get effective limits for a member (resolved overrides) */ export async function getEffectiveLimits( member: JuryGroupMember, group: JuryGroup ): Promise<{ maxAssignments: number | null; capMode: CapMode; quotas: CategoryQuotas | null }>; /** Check if a juror can receive more assignments */ export async function canAssignMore( userId: string, juryGroupId: string, category?: CompetitionCategory ): Promise<{ allowed: boolean; reason?: string }>; /** Get assignment statistics for the whole group */ export async function getGroupAssignmentStats( juryGroupId: string ): Promise; /** Propagate COI across all jury groups for a user */ export async function propagateCOI( userId: string, projectId: string, competitionId: string, reason: string ): Promise; /** Get all active members (excluding observers) for assignment */ export async function getAssignableMembers( juryGroupId: string ): Promise; /** Validate group readiness (enough members, all onboarded, etc.) */ export async function validateGroupReadiness( juryGroupId: string ): Promise<{ ready: boolean; issues: string[] }>; ``` --- ## Edge Cases | Scenario | Handling | |----------|----------| | **Juror added to group during active evaluation** | Allowed with admin warning. New juror gets no existing assignments (must run assignment again) | | **Juror removed from group during active evaluation** | Blocked if juror has pending evaluations. Must reassign first | | **All jurors at cap but projects remain unassigned** | Warning shown to admin. Suggest increasing caps or adding jurors | | **Category quota min not met for any juror** | Warning shown in assignment preview. Admin can proceed or adjust | | **Juror on 3+ jury groups** | Supported. Each membership independent. Cross-jury COI propagation ensures consistency | | **Jury Chair also has assignments** | Allowed. Chair is a regular evaluator with extra visibility | | **Observer tries to submit evaluation** | Blocked at procedure level (OBSERVER role excluded from evaluation mutations) | | **Admin deletes jury group with active assignments** | Blocked. Must complete or reassign all assignments first | | **Juror preference ratio impossible** | (e.g., 90% startups but only 20% projects are startups) — Warn in onboarding, treat as best-effort | | **Same user added twice to same group** | Blocked by unique constraint on [juryGroupId, userId] | --- ## Integration Points ### Inbound | Source | Data | Purpose | |--------|------|---------| | Competition setup wizard | Group config | Create jury groups during competition setup | | User management | User records | Add jurors as members | | COI declarations | Conflict records | Filter assignments, propagate across groups | ### Outbound | Target | Data | Purpose | |--------|------|---------| | Assignment algorithm | Members, caps, quotas | Generate assignments | | Evaluation rounds | Jury membership | Determine who evaluates what | | Live finals | Jury 3 members | Live voting access | | Confirmation round | Jury members | Who must approve winner proposal | | Special awards | Award jury members | Award evaluation access | | Notifications | Member list | Send round-specific emails to jury | ### JuryGroup → Round Linkage Each evaluation or live-final round links to exactly one JuryGroup: ```prisma model Round { // ... juryGroupId String? juryGroup JuryGroup? @relation(...) } ``` This means: - Round 3 (EVALUATION) → Jury 1 - Round 5 (EVALUATION) → Jury 2 - Round 7 (LIVE_FINAL) → Jury 3 - Round 8 (CONFIRMATION) → Jury 3 (same group, different round) A jury group can be linked to multiple rounds (e.g., Jury 3 handles both live finals and confirmation).