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