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

961 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).