MOPC-App/docs/claude-architecture-redesign/12-jury-groups.md

961 lines
40 KiB
Markdown
Raw Permalink Normal View History

# 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.01.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<CompetitionCategory, number>
): { 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<JuryGroupMember[]> {
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<boolean> {
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<void> {
// 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<void> {
await prisma.juryGroup.update({
where: { id: juryGroupId },
data: { isActive: true },
});
}
// Jury group locks when evaluation/voting begins
async function lockJuryGroup(juryGroupId: string): Promise<void> {
// 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<string, { avg: number; min: number; max: number }>;
};
};
```
---
## 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<JuryGroupConfig>
): Promise<JuryGroup>;
/** 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<GroupStats>;
/** Propagate COI across all jury groups for a user */
export async function propagateCOI(
userId: string,
projectId: string,
competitionId: string,
reason: string
): Promise<void>;
/** Get all active members (excluding observers) for assignment */
export async function getAssignableMembers(
juryGroupId: string
): Promise<JuryGroupMember[]>;
/** 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).