961 lines
40 KiB
Markdown
961 lines
40 KiB
Markdown
# 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<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).
|