1300 lines
53 KiB
Markdown
1300 lines
53 KiB
Markdown
|
|
# Round: Confirmation — Winner Agreement & Result Freezing
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
The **CONFIRMATION** round (Round 8 in the standard 8-step flow) is the final step of the competition. It transforms live-finals scores into official, immutable results through a multi-party digital agreement process.
|
||
|
|
|
||
|
|
### Purpose
|
||
|
|
|
||
|
|
After Jury 3 completes live scoring in Round 7 (LIVE_FINAL), the platform generates a **WinnerProposal** — a ranked list of projects per category. This proposal must be ratified by the jury members who scored it, confirmed by the admin, and then **frozen** so that official results cannot be tampered with.
|
||
|
|
|
||
|
|
### Key Principles
|
||
|
|
|
||
|
|
| Principle | Implementation |
|
||
|
|
|-----------|---------------|
|
||
|
|
| **Unanimous by default** | Every jury member on the finals jury must individually approve |
|
||
|
|
| **Admin override available** | Admin can force-majority or outright select winners |
|
||
|
|
| **Immutable once frozen** | Frozen results cannot be changed — new proposal required |
|
||
|
|
| **Full audit trail** | Every approval, rejection, override, and freeze is logged |
|
||
|
|
| **Per-category** | Startups and Concepts can be confirmed independently |
|
||
|
|
| **Special awards included** | Award winners are part of the confirmation package |
|
||
|
|
|
||
|
|
### Flow Summary
|
||
|
|
|
||
|
|
```
|
||
|
|
Live Finals Complete
|
||
|
|
│
|
||
|
|
▼
|
||
|
|
┌─────────────────────────────┐
|
||
|
|
│ WinnerProposal Generated │ ← Auto-generated from scores, or admin-created
|
||
|
|
│ Status: PENDING │
|
||
|
|
└─────────────┬───────────────┘
|
||
|
|
│
|
||
|
|
▼
|
||
|
|
┌─────────────────────────────┐
|
||
|
|
│ Jury Approval Requests │ ← Each jury member gets notification
|
||
|
|
│ Sent to all Jury 3 members │
|
||
|
|
└─────────────┬───────────────┘
|
||
|
|
│
|
||
|
|
┌─────────┼──────────┐
|
||
|
|
▼ ▼ ▼
|
||
|
|
Juror A Juror B Juror C ... each reviews & votes
|
||
|
|
APPROVE APPROVE REJECT
|
||
|
|
│ │ │
|
||
|
|
└─────────┼──────────┘
|
||
|
|
│
|
||
|
|
▼
|
||
|
|
┌───────────┐
|
||
|
|
│ All voted? │
|
||
|
|
└─────┬─────┘
|
||
|
|
│
|
||
|
|
┌────────┴────────┐
|
||
|
|
▼ ▼
|
||
|
|
Unanimous? Not Unanimous
|
||
|
|
│ │
|
||
|
|
▼ ▼
|
||
|
|
Status: Admin Override
|
||
|
|
APPROVED Decision Point
|
||
|
|
│ │
|
||
|
|
▼ ┌────┴──────┐
|
||
|
|
Auto-freeze │ │
|
||
|
|
(if enabled) ▼ ▼
|
||
|
|
FORCE_MAJORITY ADMIN_DECISION
|
||
|
|
(Accept if (Admin picks
|
||
|
|
majority winners
|
||
|
|
approved) directly)
|
||
|
|
│ │
|
||
|
|
▼ ▼
|
||
|
|
Status: Status:
|
||
|
|
OVERRIDDEN OVERRIDDEN
|
||
|
|
│ │
|
||
|
|
└──────┬───────┘
|
||
|
|
▼
|
||
|
|
Admin Freeze
|
||
|
|
(manual trigger)
|
||
|
|
│
|
||
|
|
▼
|
||
|
|
Status: FROZEN
|
||
|
|
Results are official
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Current System
|
||
|
|
|
||
|
|
Today, there is **no confirmation step**. The pipeline flow is:
|
||
|
|
|
||
|
|
```
|
||
|
|
INTAKE → FILTER → EVALUATION → SELECTION → LIVE_FINAL → RESULTS
|
||
|
|
```
|
||
|
|
|
||
|
|
The `RESULTS` stage type simply displays computed results from LIVE_FINAL. There is:
|
||
|
|
- No jury sign-off on results
|
||
|
|
- No admin confirmation step
|
||
|
|
- No freeze/lock mechanism
|
||
|
|
- No override system for disputed results
|
||
|
|
- No multi-party agreement
|
||
|
|
|
||
|
|
**Impact:** Results can theoretically be changed at any time, there's no evidence trail of who agreed to the outcome, and no formal "cementing" of winners.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Redesigned Confirmation Round
|
||
|
|
|
||
|
|
### ConfirmationConfig
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
type ConfirmationConfig = {
|
||
|
|
// ── Approval Requirements ──────────────────────────────
|
||
|
|
requireAllJuryApproval: boolean; // true = unanimous, false = majority
|
||
|
|
juryGroupId: string | null; // Which jury must approve (default: Jury 3)
|
||
|
|
minimumApprovalThreshold?: number; // For non-unanimous: minimum % (e.g., 0.67 = 2/3)
|
||
|
|
|
||
|
|
// ── Admin Override ──────────────────────────────────────
|
||
|
|
adminOverrideEnabled: boolean; // Admin can force result
|
||
|
|
overrideModes: OverrideMode[]; // Available override options
|
||
|
|
|
||
|
|
// ── Freeze Behavior ─────────────────────────────────────
|
||
|
|
autoFreezeOnApproval: boolean; // Lock results immediately when all approve
|
||
|
|
requireExplicitFreeze: boolean; // Even after approval, admin must click "Freeze"
|
||
|
|
|
||
|
|
// ── Per-category ────────────────────────────────────────
|
||
|
|
perCategory: boolean; // Separate proposals per STARTUP vs CONCEPT
|
||
|
|
|
||
|
|
// ── Special Awards ──────────────────────────────────────
|
||
|
|
includeSpecialAwards: boolean; // Include award winners in confirmation
|
||
|
|
|
||
|
|
// ── Deadline ────────────────────────────────────────────
|
||
|
|
approvalDeadlineDays: number; // Days jury has to respond (default: 7)
|
||
|
|
reminderSchedule: number[]; // Days before deadline to remind (e.g., [3, 1])
|
||
|
|
|
||
|
|
// ── Notification ────────────────────────────────────────
|
||
|
|
notifyOnApproval: boolean; // Notify admin when each juror approves
|
||
|
|
notifyOnRejection: boolean; // Notify admin immediately on any rejection
|
||
|
|
notifyOnFreeze: boolean; // Notify all parties when results frozen
|
||
|
|
};
|
||
|
|
|
||
|
|
type OverrideMode = 'FORCE_MAJORITY' | 'ADMIN_DECISION';
|
||
|
|
```
|
||
|
|
|
||
|
|
### Field Behavior Reference
|
||
|
|
|
||
|
|
| Field | Default | Description |
|
||
|
|
|-------|---------|-------------|
|
||
|
|
| `requireAllJuryApproval` | `true` | When true, ALL jury members must approve. When false, only `minimumApprovalThreshold` % needed |
|
||
|
|
| `juryGroupId` | Finals jury | Links to the JuryGroup that must confirm. Usually the same jury that scored live finals |
|
||
|
|
| `minimumApprovalThreshold` | `1.0` | Only used when `requireAllJuryApproval=false`. E.g., 0.67 = two-thirds majority |
|
||
|
|
| `adminOverrideEnabled` | `true` | Whether admin can bypass jury deadlock |
|
||
|
|
| `overrideModes` | `['FORCE_MAJORITY', 'ADMIN_DECISION']` | FORCE_MAJORITY: accept if >50% approved. ADMIN_DECISION: admin picks winners directly |
|
||
|
|
| `autoFreezeOnApproval` | `true` | When all approvals received, auto-freeze results |
|
||
|
|
| `requireExplicitFreeze` | `false` | If true, even after unanimous approval, admin must click "Freeze" to finalize |
|
||
|
|
| `perCategory` | `true` | Separate WinnerProposal per category |
|
||
|
|
| `includeSpecialAwards` | `true` | Include special award winners in confirmation package |
|
||
|
|
| `approvalDeadlineDays` | `7` | Jury has this many days to approve |
|
||
|
|
| `reminderSchedule` | `[3, 1]` | Reminder emails N days before deadline |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## WinnerProposal Lifecycle
|
||
|
|
|
||
|
|
### States
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
enum WinnerProposalStatus {
|
||
|
|
PENDING = 'PENDING', // Waiting for jury approvals
|
||
|
|
APPROVED = 'APPROVED', // All required approvals received
|
||
|
|
REJECTED = 'REJECTED', // Failed — needs new proposal or override
|
||
|
|
OVERRIDDEN = 'OVERRIDDEN', // Admin used override power
|
||
|
|
FROZEN = 'FROZEN', // Locked — official, immutable results
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### State Transitions
|
||
|
|
|
||
|
|
```
|
||
|
|
┌───────────────┐
|
||
|
|
│ PENDING │◄──── Created from scores
|
||
|
|
└───────┬───────┘ or admin draft
|
||
|
|
│
|
||
|
|
┌─────────────┼─────────────┐
|
||
|
|
▼ │ ▼
|
||
|
|
All approved Some rejected Admin override
|
||
|
|
│ │ │
|
||
|
|
▼ ▼ ▼
|
||
|
|
┌──────────┐ ┌──────────┐ ┌────────────┐
|
||
|
|
│ APPROVED │ │ REJECTED │ │ OVERRIDDEN │
|
||
|
|
└────┬─────┘ └────┬─────┘ └─────┬──────┘
|
||
|
|
│ │ │
|
||
|
|
│ Create new │
|
||
|
|
│ proposal or │
|
||
|
|
│ admin override │
|
||
|
|
▼ ▼
|
||
|
|
┌──────────────────────────────────┐
|
||
|
|
│ FROZEN │◄── Immutable
|
||
|
|
└──────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
### Validity Rules
|
||
|
|
|
||
|
|
| Transition | From | To | Condition |
|
||
|
|
|------------|------|----|-----------|
|
||
|
|
| Auto-approve | PENDING | APPROVED | All required approvals received |
|
||
|
|
| Reject | PENDING | REJECTED | Any jury member rejects (if unanimous required) |
|
||
|
|
| Override: Force Majority | PENDING/REJECTED | OVERRIDDEN | Admin triggers; >50% approved |
|
||
|
|
| Override: Admin Decision | PENDING/REJECTED | OVERRIDDEN | Admin provides new rankings |
|
||
|
|
| Freeze | APPROVED/OVERRIDDEN | FROZEN | Admin triggers (or auto on approval) |
|
||
|
|
| Revoke freeze | *never* | — | Cannot unfreeze. Must create new proposal |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Proposal Generation
|
||
|
|
|
||
|
|
### Automatic Generation
|
||
|
|
|
||
|
|
When the LIVE_FINAL round completes (all projects scored), the system auto-generates a WinnerProposal:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
async function generateWinnerProposal(
|
||
|
|
competitionId: string,
|
||
|
|
sourceRoundId: string,
|
||
|
|
category: CompetitionCategory,
|
||
|
|
proposedById: string // system or admin user ID
|
||
|
|
): Promise<WinnerProposal> {
|
||
|
|
// 1. Fetch all scored projects in this category from the live final round
|
||
|
|
const scores = await getAggregatedScores(sourceRoundId, category);
|
||
|
|
|
||
|
|
// 2. Rank by final score (jury weight + audience weight)
|
||
|
|
const ranked = scores
|
||
|
|
.sort((a, b) => b.finalScore - a.finalScore)
|
||
|
|
.map(s => s.projectId);
|
||
|
|
|
||
|
|
// 3. Build selection basis (evidence trail)
|
||
|
|
const selectionBasis = {
|
||
|
|
method: 'SCORE_RANKING',
|
||
|
|
sourceRound: sourceRoundId,
|
||
|
|
scoreBreakdown: scores.map(s => ({
|
||
|
|
projectId: s.projectId,
|
||
|
|
juryScore: s.juryScore,
|
||
|
|
audienceScore: s.audienceScore,
|
||
|
|
finalScore: s.finalScore,
|
||
|
|
rank: s.rank,
|
||
|
|
})),
|
||
|
|
generatedAt: new Date().toISOString(),
|
||
|
|
};
|
||
|
|
|
||
|
|
// 4. Create proposal
|
||
|
|
const proposal = await prisma.winnerProposal.create({
|
||
|
|
data: {
|
||
|
|
competitionId,
|
||
|
|
category,
|
||
|
|
status: 'PENDING',
|
||
|
|
rankedProjectIds: ranked,
|
||
|
|
selectionBasis,
|
||
|
|
sourceRoundId,
|
||
|
|
proposedById,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// 5. Create approval records for each jury member
|
||
|
|
const juryMembers = await getConfirmationJuryMembers(competitionId);
|
||
|
|
await prisma.winnerApproval.createMany({
|
||
|
|
data: juryMembers.map(member => ({
|
||
|
|
winnerProposalId: proposal.id,
|
||
|
|
userId: member.userId,
|
||
|
|
role: 'JURY_MEMBER',
|
||
|
|
})),
|
||
|
|
});
|
||
|
|
|
||
|
|
// 6. Send approval request notifications
|
||
|
|
await notifyJuryForConfirmation(proposal.id, juryMembers);
|
||
|
|
|
||
|
|
return proposal;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Manual Proposal Creation
|
||
|
|
|
||
|
|
Admins can also create proposals manually (e.g., after an admin-decision override):
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
async function createManualProposal(input: {
|
||
|
|
competitionId: string;
|
||
|
|
category: CompetitionCategory;
|
||
|
|
rankedProjectIds: string[];
|
||
|
|
justification: string;
|
||
|
|
adminId: string;
|
||
|
|
}): Promise<WinnerProposal> {
|
||
|
|
return prisma.winnerProposal.create({
|
||
|
|
data: {
|
||
|
|
competitionId: input.competitionId,
|
||
|
|
category: input.category,
|
||
|
|
status: 'PENDING',
|
||
|
|
rankedProjectIds: input.rankedProjectIds,
|
||
|
|
selectionBasis: {
|
||
|
|
method: 'ADMIN_MANUAL',
|
||
|
|
justification: input.justification,
|
||
|
|
createdBy: input.adminId,
|
||
|
|
createdAt: new Date().toISOString(),
|
||
|
|
},
|
||
|
|
sourceRoundId: await getLatestRoundId(input.competitionId),
|
||
|
|
proposedById: input.adminId,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Jury Approval Process
|
||
|
|
|
||
|
|
### Approval Flow
|
||
|
|
|
||
|
|
When a WinnerProposal is created, each jury member on the confirmation jury receives a notification:
|
||
|
|
|
||
|
|
1. **Notification** — Email + in-app alert: "Winner proposal for STARTUP category is ready for your review"
|
||
|
|
2. **Review** — Jury member opens the confirmation page, sees:
|
||
|
|
- Ranked list of winners (1st, 2nd, 3rd...)
|
||
|
|
- Score breakdown per project
|
||
|
|
- Access to all submitted documents
|
||
|
|
- Comments from other jury members (if any have responded)
|
||
|
|
3. **Decision** — Jury member clicks APPROVE or REJECT
|
||
|
|
- If APPROVE: records timestamp, optional comment
|
||
|
|
- If REJECT: must provide a reason (required text field)
|
||
|
|
4. **Progress tracking** — Admin can see real-time progress: "3/5 jury members approved"
|
||
|
|
|
||
|
|
### Jury Confirmation UI
|
||
|
|
|
||
|
|
```
|
||
|
|
┌──────────────────────────────────────────────────────────────────┐
|
||
|
|
│ 🏆 Winner Confirmation — STARTUP Category │
|
||
|
|
│ │
|
||
|
|
│ Proposal Status: PENDING (2/5 approved) │
|
||
|
|
│ Deadline: February 22, 2026 (5 days remaining) │
|
||
|
|
├──────────────────────────────────────────────────────────────────┤
|
||
|
|
│ │
|
||
|
|
│ Proposed Rankings: │
|
||
|
|
│ │
|
||
|
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||
|
|
│ │ 🥇 1st Place: OceanClean AI │ │
|
||
|
|
│ │ Final Score: 92.4 (Jury: 94.0 | Audience: 86.0) │ │
|
||
|
|
│ │ [View Submissions] [View Evaluations] │ │
|
||
|
|
│ ├──────────────────────────────────────────────────────────┤ │
|
||
|
|
│ │ 🥈 2nd Place: DeepReef Monitoring │ │
|
||
|
|
│ │ Final Score: 88.7 (Jury: 87.0 | Audience: 95.0) │ │
|
||
|
|
│ │ [View Submissions] [View Evaluations] │ │
|
||
|
|
│ ├──────────────────────────────────────────────────────────┤ │
|
||
|
|
│ │ 🥉 3rd Place: CoralGuard │ │
|
||
|
|
│ │ Final Score: 85.1 (Jury: 86.5 | Audience: 79.0) │ │
|
||
|
|
│ │ [View Submissions] [View Evaluations] │ │
|
||
|
|
│ └──────────────────────────────────────────────────────────┘ │
|
||
|
|
│ │
|
||
|
|
│ Score Methodology: Jury 70% / Audience 30% │
|
||
|
|
│ Source: Round 7 — Live Finals (completed Feb 15, 2026) │
|
||
|
|
│ │
|
||
|
|
│ ── Your Decision ────────────────────────────────────────── │
|
||
|
|
│ │
|
||
|
|
│ Comments (optional for approve, required for reject): │
|
||
|
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||
|
|
│ │ │ │
|
||
|
|
│ └──────────────────────────────────────────────────────────┘ │
|
||
|
|
│ │
|
||
|
|
│ [ ✓ Approve Rankings ] [ ✗ Reject — Request Revision ] │
|
||
|
|
│ │
|
||
|
|
│ ── Other Jury Responses ─────────────────────────────────── │
|
||
|
|
│ ✓ Juror A — Approved (Feb 16) │
|
||
|
|
│ ✓ Juror B — Approved (Feb 16) "Great selections" │
|
||
|
|
│ ○ Juror C — Pending │
|
||
|
|
│ ○ Juror D — Pending │
|
||
|
|
│ ○ Juror E — Pending │
|
||
|
|
└──────────────────────────────────────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
### Approval Logic
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
async function processApproval(
|
||
|
|
proposalId: string,
|
||
|
|
userId: string,
|
||
|
|
approved: boolean,
|
||
|
|
comments?: string
|
||
|
|
): Promise<{ proposal: WinnerProposal; isComplete: boolean }> {
|
||
|
|
// 1. Record the approval/rejection
|
||
|
|
await prisma.winnerApproval.update({
|
||
|
|
where: { winnerProposalId_userId: { winnerProposalId: proposalId, userId } },
|
||
|
|
data: {
|
||
|
|
approved,
|
||
|
|
comments,
|
||
|
|
respondedAt: new Date(),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// 2. Log to audit trail
|
||
|
|
await prisma.decisionAuditLog.create({
|
||
|
|
data: {
|
||
|
|
entityType: 'WINNER_PROPOSAL',
|
||
|
|
entityId: proposalId,
|
||
|
|
action: approved ? 'JURY_APPROVED' : 'JURY_REJECTED',
|
||
|
|
userId,
|
||
|
|
details: { comments },
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// 3. Check if all required approvals are in
|
||
|
|
const config = await getConfirmationConfig(proposalId);
|
||
|
|
const allApprovals = await prisma.winnerApproval.findMany({
|
||
|
|
where: { winnerProposalId: proposalId },
|
||
|
|
});
|
||
|
|
|
||
|
|
const responded = allApprovals.filter(a => a.respondedAt !== null);
|
||
|
|
const approvedCount = responded.filter(a => a.approved === true).length;
|
||
|
|
const rejectedCount = responded.filter(a => a.approved === false).length;
|
||
|
|
const totalRequired = allApprovals.length;
|
||
|
|
|
||
|
|
let isComplete = false;
|
||
|
|
let newStatus: WinnerProposalStatus | null = null;
|
||
|
|
|
||
|
|
if (config.requireAllJuryApproval) {
|
||
|
|
// Unanimous mode
|
||
|
|
if (rejectedCount > 0) {
|
||
|
|
newStatus = 'REJECTED';
|
||
|
|
isComplete = true;
|
||
|
|
} else if (approvedCount === totalRequired) {
|
||
|
|
newStatus = 'APPROVED';
|
||
|
|
isComplete = true;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Threshold mode
|
||
|
|
const threshold = config.minimumApprovalThreshold ?? 0.5;
|
||
|
|
if (responded.length === totalRequired) {
|
||
|
|
if (approvedCount / totalRequired >= threshold) {
|
||
|
|
newStatus = 'APPROVED';
|
||
|
|
} else {
|
||
|
|
newStatus = 'REJECTED';
|
||
|
|
}
|
||
|
|
isComplete = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let proposal: WinnerProposal;
|
||
|
|
if (newStatus) {
|
||
|
|
proposal = await prisma.winnerProposal.update({
|
||
|
|
where: { id: proposalId },
|
||
|
|
data: { status: newStatus },
|
||
|
|
});
|
||
|
|
|
||
|
|
// Auto-freeze if configured and approved
|
||
|
|
if (newStatus === 'APPROVED' && config.autoFreezeOnApproval) {
|
||
|
|
proposal = await freezeProposal(proposalId, 'SYSTEM');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Notify admin of completion
|
||
|
|
await notifyAdminProposalComplete(proposalId, newStatus);
|
||
|
|
} else {
|
||
|
|
proposal = await prisma.winnerProposal.findUniqueOrThrow({
|
||
|
|
where: { id: proposalId },
|
||
|
|
});
|
||
|
|
// Notify admin of progress
|
||
|
|
if (config.notifyOnApproval && approved) {
|
||
|
|
await notifyAdminApprovalProgress(proposalId, approvedCount, totalRequired);
|
||
|
|
}
|
||
|
|
if (config.notifyOnRejection && !approved) {
|
||
|
|
await notifyAdminRejection(proposalId, userId, comments);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { proposal, isComplete };
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Admin Override System
|
||
|
|
|
||
|
|
The admin override exists for when jury consensus fails or when extraordinary circumstances require admin intervention.
|
||
|
|
|
||
|
|
### Override Modes
|
||
|
|
|
||
|
|
#### FORCE_MAJORITY
|
||
|
|
|
||
|
|
Accept the proposal if more than 50% of jury members approved, even though unanimous approval was required.
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
async function overrideForceMajority(
|
||
|
|
proposalId: string,
|
||
|
|
adminId: string,
|
||
|
|
reason: string
|
||
|
|
): Promise<WinnerProposal> {
|
||
|
|
const approvals = await prisma.winnerApproval.findMany({
|
||
|
|
where: { winnerProposalId: proposalId },
|
||
|
|
});
|
||
|
|
|
||
|
|
const approvedCount = approvals.filter(a => a.approved === true).length;
|
||
|
|
const total = approvals.length;
|
||
|
|
|
||
|
|
if (approvedCount <= total / 2) {
|
||
|
|
throw new TRPCError({
|
||
|
|
code: 'BAD_REQUEST',
|
||
|
|
message: `Cannot force majority: only ${approvedCount}/${total} approved (need >50%)`,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const proposal = await prisma.winnerProposal.update({
|
||
|
|
where: { id: proposalId },
|
||
|
|
data: {
|
||
|
|
status: 'OVERRIDDEN',
|
||
|
|
overrideById: adminId,
|
||
|
|
overrideReason: reason,
|
||
|
|
overrideAt: new Date(),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await prisma.decisionAuditLog.create({
|
||
|
|
data: {
|
||
|
|
entityType: 'WINNER_PROPOSAL',
|
||
|
|
entityId: proposalId,
|
||
|
|
action: 'ADMIN_FORCE_MAJORITY',
|
||
|
|
userId: adminId,
|
||
|
|
details: {
|
||
|
|
reason,
|
||
|
|
approvedCount,
|
||
|
|
totalJurors: total,
|
||
|
|
rejections: approvals
|
||
|
|
.filter(a => a.approved === false)
|
||
|
|
.map(a => ({ userId: a.userId, comments: a.comments })),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
return proposal;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### ADMIN_DECISION
|
||
|
|
|
||
|
|
Admin directly selects winners, bypassing jury vote entirely. This is the nuclear option for deadlocked juries or exceptional circumstances.
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
async function overrideAdminDecision(
|
||
|
|
proposalId: string,
|
||
|
|
adminId: string,
|
||
|
|
newRankedProjectIds: string[],
|
||
|
|
reason: string
|
||
|
|
): Promise<WinnerProposal> {
|
||
|
|
// Store original rankings for audit
|
||
|
|
const original = await prisma.winnerProposal.findUniqueOrThrow({
|
||
|
|
where: { id: proposalId },
|
||
|
|
});
|
||
|
|
|
||
|
|
const proposal = await prisma.winnerProposal.update({
|
||
|
|
where: { id: proposalId },
|
||
|
|
data: {
|
||
|
|
status: 'OVERRIDDEN',
|
||
|
|
rankedProjectIds: newRankedProjectIds,
|
||
|
|
overrideById: adminId,
|
||
|
|
overrideReason: reason,
|
||
|
|
overrideAt: new Date(),
|
||
|
|
selectionBasis: {
|
||
|
|
...((original.selectionBasis as Record<string, unknown>) ?? {}),
|
||
|
|
overrideHistory: {
|
||
|
|
originalRanking: original.rankedProjectIds,
|
||
|
|
newRanking: newRankedProjectIds,
|
||
|
|
reason,
|
||
|
|
overriddenBy: adminId,
|
||
|
|
overriddenAt: new Date().toISOString(),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await prisma.decisionAuditLog.create({
|
||
|
|
data: {
|
||
|
|
entityType: 'WINNER_PROPOSAL',
|
||
|
|
entityId: proposalId,
|
||
|
|
action: 'ADMIN_DECISION_OVERRIDE',
|
||
|
|
userId: adminId,
|
||
|
|
details: {
|
||
|
|
reason,
|
||
|
|
originalRanking: original.rankedProjectIds,
|
||
|
|
newRanking: newRankedProjectIds,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
return proposal;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Admin Override UI
|
||
|
|
|
||
|
|
```
|
||
|
|
┌──────────────────────────────────────────────────────────────────┐
|
||
|
|
│ ⚠️ Override Winner Proposal — STARTUP Category │
|
||
|
|
│ │
|
||
|
|
│ Current Status: REJECTED │
|
||
|
|
│ Jury Votes: 3 Approved / 2 Rejected │
|
||
|
|
│ │
|
||
|
|
│ ── Override Options ─────────────────────────────────────── │
|
||
|
|
│ │
|
||
|
|
│ ○ Force Majority │
|
||
|
|
│ Accept current rankings since 3/5 (60%) approved. │
|
||
|
|
│ Rankings remain as originally proposed. │
|
||
|
|
│ │
|
||
|
|
│ ○ Admin Decision │
|
||
|
|
│ Override rankings entirely. You will set the final order. │
|
||
|
|
│ │
|
||
|
|
│ ── Override Reason (required) ───────────────────────────── │
|
||
|
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||
|
|
│ │ Two jurors had scheduling conflicts and could not review │ │
|
||
|
|
│ │ all presentations. Accepting majority recommendation. │ │
|
||
|
|
│ └──────────────────────────────────────────────────────────┘ │
|
||
|
|
│ │
|
||
|
|
│ ⚠ This action is logged and cannot be undone. │
|
||
|
|
│ │
|
||
|
|
│ [ Cancel ] [ Apply Override ] │
|
||
|
|
└──────────────────────────────────────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
### Admin Decision — Reorder UI
|
||
|
|
|
||
|
|
When ADMIN_DECISION mode is selected:
|
||
|
|
|
||
|
|
```
|
||
|
|
┌──────────────────────────────────────────────────────────────────┐
|
||
|
|
│ Admin Decision — Set Final Rankings │
|
||
|
|
│ │
|
||
|
|
│ Drag to reorder. This becomes the official result. │
|
||
|
|
│ │
|
||
|
|
│ ┌────┬──────────────────────────────┬──────────┬────────────┐ │
|
||
|
|
│ │ # │ Project │ Score │ Actions │ │
|
||
|
|
│ ├────┼──────────────────────────────┼──────────┼────────────┤ │
|
||
|
|
│ │ 1 │ ≡ OceanClean AI │ 92.4 │ ↑ ↓ │ │
|
||
|
|
│ │ 2 │ ≡ DeepReef Monitoring │ 88.7 │ ↑ ↓ │ │
|
||
|
|
│ │ 3 │ ≡ CoralGuard │ 85.1 │ ↑ ↓ │ │
|
||
|
|
│ │ 4 │ ≡ WaveEnergy Solutions │ 82.3 │ ↑ ↓ │ │
|
||
|
|
│ │ 5 │ ≡ PlasticHarvest │ 79.8 │ ↑ ↓ │ │
|
||
|
|
│ └────┴──────────────────────────────┴──────────┴────────────┘ │
|
||
|
|
│ │
|
||
|
|
│ Why are you overriding the score-based ranking? │
|
||
|
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||
|
|
│ │ │ │
|
||
|
|
│ └──────────────────────────────────────────────────────────┘ │
|
||
|
|
│ │
|
||
|
|
│ [ Cancel ] [ Confirm Final Rankings ] │
|
||
|
|
└──────────────────────────────────────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Result Freezing
|
||
|
|
|
||
|
|
### What Freeze Does
|
||
|
|
|
||
|
|
Freezing a WinnerProposal makes it **immutable** — the official, permanent record of the competition results.
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
async function freezeProposal(
|
||
|
|
proposalId: string,
|
||
|
|
triggeredBy: string // userId or 'SYSTEM' for auto-freeze
|
||
|
|
): Promise<WinnerProposal> {
|
||
|
|
const proposal = await prisma.winnerProposal.findUniqueOrThrow({
|
||
|
|
where: { id: proposalId },
|
||
|
|
});
|
||
|
|
|
||
|
|
if (proposal.status !== 'APPROVED' && proposal.status !== 'OVERRIDDEN') {
|
||
|
|
throw new TRPCError({
|
||
|
|
code: 'BAD_REQUEST',
|
||
|
|
message: `Cannot freeze proposal in ${proposal.status} status. Must be APPROVED or OVERRIDDEN.`,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const frozen = await prisma.winnerProposal.update({
|
||
|
|
where: { id: proposalId },
|
||
|
|
data: {
|
||
|
|
status: 'FROZEN',
|
||
|
|
frozenById: triggeredBy === 'SYSTEM' ? null : triggeredBy,
|
||
|
|
frozenAt: new Date(),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// Log the freeze
|
||
|
|
await prisma.decisionAuditLog.create({
|
||
|
|
data: {
|
||
|
|
entityType: 'WINNER_PROPOSAL',
|
||
|
|
entityId: proposalId,
|
||
|
|
action: 'RESULTS_FROZEN',
|
||
|
|
userId: triggeredBy === 'SYSTEM' ? undefined : triggeredBy,
|
||
|
|
details: {
|
||
|
|
category: proposal.category,
|
||
|
|
rankedProjectIds: proposal.rankedProjectIds,
|
||
|
|
frozenAt: new Date().toISOString(),
|
||
|
|
method: triggeredBy === 'SYSTEM' ? 'AUTO_FREEZE' : 'MANUAL_FREEZE',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// Update round status to COMPLETED
|
||
|
|
const config = await getConfirmationConfig(proposalId);
|
||
|
|
if (config.perCategory) {
|
||
|
|
// Check if ALL categories are frozen before completing the round
|
||
|
|
const allProposals = await prisma.winnerProposal.findMany({
|
||
|
|
where: { competitionId: proposal.competitionId },
|
||
|
|
});
|
||
|
|
const allFrozen = allProposals.every(p => p.status === 'FROZEN');
|
||
|
|
if (allFrozen) {
|
||
|
|
await completeConfirmationRound(proposal.competitionId);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
await completeConfirmationRound(proposal.competitionId);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Send freeze notifications
|
||
|
|
await notifyResultsFrozen(proposalId);
|
||
|
|
|
||
|
|
return frozen;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Freeze Immutability
|
||
|
|
|
||
|
|
Once frozen, a WinnerProposal **cannot be modified**. The following are enforced:
|
||
|
|
|
||
|
|
| Operation | Allowed on FROZEN? | Alternative |
|
||
|
|
|-----------|--------------------|-------------|
|
||
|
|
| Change rankings | No | Create a new WinnerProposal |
|
||
|
|
| Change status | No | — |
|
||
|
|
| Add approvals | No | — |
|
||
|
|
| Delete proposal | No | — |
|
||
|
|
| Create new proposal | Yes | New proposal supersedes (admin must mark old as "superseded" in notes) |
|
||
|
|
|
||
|
|
### Freeze Guard Middleware
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Applied to all mutation procedures on WinnerProposal
|
||
|
|
function assertNotFrozen(proposalId: string) {
|
||
|
|
const proposal = await prisma.winnerProposal.findUniqueOrThrow({
|
||
|
|
where: { id: proposalId },
|
||
|
|
});
|
||
|
|
if (proposal.status === 'FROZEN') {
|
||
|
|
throw new TRPCError({
|
||
|
|
code: 'FORBIDDEN',
|
||
|
|
message: 'This winner proposal is frozen and cannot be modified. Create a new proposal if changes are needed.',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Special Awards in Confirmation
|
||
|
|
|
||
|
|
When `includeSpecialAwards: true`, the confirmation round also covers special award winners.
|
||
|
|
|
||
|
|
### Special Award Winner Proposals
|
||
|
|
|
||
|
|
Each special award generates its own mini-proposal, but the confirmation process bundles them together:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
type ConfirmationPackage = {
|
||
|
|
// Main competition winners
|
||
|
|
mainProposals: {
|
||
|
|
startup: WinnerProposal;
|
||
|
|
concept: WinnerProposal;
|
||
|
|
};
|
||
|
|
|
||
|
|
// Special award winners
|
||
|
|
awardProposals: {
|
||
|
|
awardId: string;
|
||
|
|
awardName: string;
|
||
|
|
winnerId: string; // project ID
|
||
|
|
method: 'JURY_VOTE' | 'AUDIENCE_VOTE' | 'COMBINED_VOTE';
|
||
|
|
score: number;
|
||
|
|
}[];
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### Confirmation UI with Awards
|
||
|
|
|
||
|
|
```
|
||
|
|
┌──────────────────────────────────────────────────────────────────┐
|
||
|
|
│ Winner Confirmation — Full Package │
|
||
|
|
│ │
|
||
|
|
│ ═══ STARTUP Category ════════════════════════════════════════ │
|
||
|
|
│ Status: APPROVED ✓ [Freeze Results] │
|
||
|
|
│ 1st: OceanClean AI (92.4) │
|
||
|
|
│ 2nd: DeepReef Monitoring (88.7) │
|
||
|
|
│ 3rd: CoralGuard (85.1) │
|
||
|
|
│ │
|
||
|
|
│ ═══ CONCEPT Category ════════════════════════════════════════ │
|
||
|
|
│ Status: PENDING (4/5 approved) │
|
||
|
|
│ 1st: BlueTide Analytics (89.2) │
|
||
|
|
│ 2nd: MarineData Hub (84.6) │
|
||
|
|
│ 3rd: SeaWatch Network (81.3) │
|
||
|
|
│ │
|
||
|
|
│ ═══ Special Awards ══════════════════════════════════════════ │
|
||
|
|
│ Innovation Award: OceanClean AI — Jury Vote (Score: 9.2) │
|
||
|
|
│ Impact Award: BlueTide Analytics — Combined (Score: 8.8) │
|
||
|
|
│ Community Award: SeaWatch Network — Audience Vote │
|
||
|
|
│ │
|
||
|
|
│ ── Actions ───────────────────────────────────────────────── │
|
||
|
|
│ [ Freeze All Approved ] [ Override Pending ] [ Export PDF ] │
|
||
|
|
└──────────────────────────────────────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Admin Confirmation Dashboard
|
||
|
|
|
||
|
|
### Full Dashboard Layout
|
||
|
|
|
||
|
|
```
|
||
|
|
┌──────────────────────────────────────────────────────────────────┐
|
||
|
|
│ MOPC 2026 — Winner Confirmation │
|
||
|
|
│ Round 8 of 8 | Status: ACTIVE │
|
||
|
|
├──────────────────────────────────────────────────────────────────┤
|
||
|
|
│ │
|
||
|
|
│ ┌───────────────────────────────────────────────────────────┐ │
|
||
|
|
│ │ Progress Summary │ │
|
||
|
|
│ │ ┌──────────┬──────────┬──────────┬───────────────────┐ │ │
|
||
|
|
│ │ │ Category │ Status │ Approvals│ Deadline │ │ │
|
||
|
|
│ │ ├──────────┼──────────┼──────────┼───────────────────┤ │ │
|
||
|
|
│ │ │ STARTUP │ APPROVED │ 5/5 ✓ │ — (complete) │ │ │
|
||
|
|
│ │ │ CONCEPT │ PENDING │ 4/5 │ Feb 22 (5 days) │ │ │
|
||
|
|
│ │ └──────────┴──────────┴──────────┴───────────────────┘ │ │
|
||
|
|
│ └───────────────────────────────────────────────────────────┘ │
|
||
|
|
│ │
|
||
|
|
│ ┌───────────────────────────────────────────────────────────┐ │
|
||
|
|
│ │ Jury Approval Timeline │ │
|
||
|
|
│ │ │ │
|
||
|
|
│ │ Feb 15 ●────────────────────────────── Proposal sent │ │
|
||
|
|
│ │ Feb 16 ●── Juror A approved ✓ │ │
|
||
|
|
│ │ Feb 16 ●── Juror B approved ✓ │ │
|
||
|
|
│ │ Feb 17 ●── Juror C approved ✓ │ │
|
||
|
|
│ │ Feb 17 ●── Juror D approved ✓ │ │
|
||
|
|
│ │ Feb ?? ○── Juror E — awaiting response │ │
|
||
|
|
│ │ │ │
|
||
|
|
│ │ [Send Reminder to Juror E] │ │
|
||
|
|
│ └───────────────────────────────────────────────────────────┘ │
|
||
|
|
│ │
|
||
|
|
│ ┌───────────────────────────────────────────────────────────┐ │
|
||
|
|
│ │ Audit Trail │ │
|
||
|
|
│ │ Feb 17 14:32 — Juror D approved CONCEPT proposal │ │
|
||
|
|
│ │ Feb 17 14:30 — Juror D approved STARTUP proposal │ │
|
||
|
|
│ │ Feb 17 10:15 — Juror C approved CONCEPT proposal │ │
|
||
|
|
│ │ Feb 16 09:22 — Juror B approved with comment │ │
|
||
|
|
│ │ Feb 16 09:10 — Juror A approved both categories │ │
|
||
|
|
│ │ Feb 15 18:00 — Proposals auto-generated from Round 7 │ │
|
||
|
|
│ │ [View Full Audit Log] │ │
|
||
|
|
│ └───────────────────────────────────────────────────────────┘ │
|
||
|
|
│ │
|
||
|
|
│ Actions: │
|
||
|
|
│ [ Freeze STARTUP Results ] [ Override CONCEPT ] │
|
||
|
|
│ [ Send Reminder ] [ Export Results PDF ] [ Create New Proposal]│
|
||
|
|
└──────────────────────────────────────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Notification Flow
|
||
|
|
|
||
|
|
### Notification Events
|
||
|
|
|
||
|
|
| Event | Recipients | Channel | Timing |
|
||
|
|
|-------|-----------|---------|--------|
|
||
|
|
| Proposal created | All confirmation jury | Email + In-app | Immediate |
|
||
|
|
| Jury member approves | Admin | In-app | Immediate |
|
||
|
|
| Jury member rejects | Admin | Email + In-app | Immediate |
|
||
|
|
| All approved (unanimous) | Admin + All jury | Email | Immediate |
|
||
|
|
| Proposal rejected (failed) | Admin | Email + In-app | Immediate |
|
||
|
|
| Admin override applied | All jury | Email | Immediate |
|
||
|
|
| Results frozen | All jury + Admin | Email + In-app | Immediate |
|
||
|
|
| Approval deadline approaching | Pending jury members | Email | Per reminderSchedule |
|
||
|
|
| Approval deadline passed | Admin | Email + In-app | Immediate |
|
||
|
|
|
||
|
|
### Notification Templates
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const CONFIRMATION_NOTIFICATIONS = {
|
||
|
|
PROPOSAL_CREATED: {
|
||
|
|
subject: 'MOPC 2026 — Winner Confirmation Required',
|
||
|
|
body: `The winner proposal for {{category}} category is ready for your review.
|
||
|
|
Please log in and confirm or reject the proposed rankings by {{deadline}}.
|
||
|
|
|
||
|
|
Proposed Winners:
|
||
|
|
{{#each rankedProjects}}
|
||
|
|
{{rank}}. {{name}} (Score: {{score}})
|
||
|
|
{{/each}}`,
|
||
|
|
},
|
||
|
|
|
||
|
|
APPROVAL_RECEIVED: {
|
||
|
|
subject: 'MOPC 2026 — Confirmation Progress Update',
|
||
|
|
body: `{{jurorName}} has {{approved ? "approved" : "rejected"}} the {{category}} proposal.
|
||
|
|
Progress: {{approvedCount}}/{{totalJurors}} approved.
|
||
|
|
{{#if comments}}Comment: "{{comments}}"{{/if}}`,
|
||
|
|
},
|
||
|
|
|
||
|
|
RESULTS_FROZEN: {
|
||
|
|
subject: 'MOPC 2026 — Official Results Confirmed',
|
||
|
|
body: `The {{category}} category results have been officially confirmed and frozen.
|
||
|
|
|
||
|
|
Official Winners:
|
||
|
|
{{#each rankedProjects}}
|
||
|
|
{{rank}}. {{name}}
|
||
|
|
{{/each}}
|
||
|
|
|
||
|
|
These results are now immutable and represent the official outcome.`,
|
||
|
|
},
|
||
|
|
|
||
|
|
DEADLINE_REMINDER: {
|
||
|
|
subject: 'MOPC 2026 — Confirmation Deadline in {{daysRemaining}} days',
|
||
|
|
body: `You have not yet responded to the {{category}} winner proposal.
|
||
|
|
Please review and respond by {{deadline}}.
|
||
|
|
|
||
|
|
[Review Proposal →]`,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## API Changes
|
||
|
|
|
||
|
|
### New tRPC Procedures
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// src/server/routers/winner-confirmation.ts
|
||
|
|
|
||
|
|
export const winnerConfirmationRouter = router({
|
||
|
|
// ── Proposals ──────────────────────────────────────────
|
||
|
|
|
||
|
|
/** Generate proposals from live final scores */
|
||
|
|
generateProposals: adminProcedure
|
||
|
|
.input(z.object({
|
||
|
|
competitionId: z.string(),
|
||
|
|
sourceRoundId: z.string(),
|
||
|
|
}))
|
||
|
|
.mutation(async ({ input, ctx }) => { ... }),
|
||
|
|
|
||
|
|
/** Create manual proposal (admin override scenario) */
|
||
|
|
createManualProposal: adminProcedure
|
||
|
|
.input(z.object({
|
||
|
|
competitionId: z.string(),
|
||
|
|
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']),
|
||
|
|
rankedProjectIds: z.array(z.string()).min(1),
|
||
|
|
justification: z.string().min(10),
|
||
|
|
}))
|
||
|
|
.mutation(async ({ input, ctx }) => { ... }),
|
||
|
|
|
||
|
|
/** Get proposal with approvals */
|
||
|
|
getProposal: protectedProcedure
|
||
|
|
.input(z.object({ proposalId: z.string() }))
|
||
|
|
.query(async ({ input }) => { ... }),
|
||
|
|
|
||
|
|
/** List all proposals for a competition */
|
||
|
|
listProposals: protectedProcedure
|
||
|
|
.input(z.object({ competitionId: z.string() }))
|
||
|
|
.query(async ({ input }) => { ... }),
|
||
|
|
|
||
|
|
/** Get the full confirmation package (main + awards) */
|
||
|
|
getConfirmationPackage: adminProcedure
|
||
|
|
.input(z.object({ competitionId: z.string() }))
|
||
|
|
.query(async ({ input }) => { ... }),
|
||
|
|
|
||
|
|
// ── Approvals ──────────────────────────────────────────
|
||
|
|
|
||
|
|
/** Jury member approves or rejects */
|
||
|
|
submitApproval: juryProcedure
|
||
|
|
.input(z.object({
|
||
|
|
proposalId: z.string(),
|
||
|
|
approved: z.boolean(),
|
||
|
|
comments: z.string().optional(),
|
||
|
|
}))
|
||
|
|
.mutation(async ({ input, ctx }) => { ... }),
|
||
|
|
|
||
|
|
/** Get my pending approvals */
|
||
|
|
getMyPendingApprovals: juryProcedure
|
||
|
|
.query(async ({ ctx }) => { ... }),
|
||
|
|
|
||
|
|
/** Get approval progress for a proposal */
|
||
|
|
getApprovalProgress: protectedProcedure
|
||
|
|
.input(z.object({ proposalId: z.string() }))
|
||
|
|
.query(async ({ input }) => { ... }),
|
||
|
|
|
||
|
|
// ── Admin Overrides ────────────────────────────────────
|
||
|
|
|
||
|
|
/** Force majority acceptance */
|
||
|
|
overrideForceMajority: adminProcedure
|
||
|
|
.input(z.object({
|
||
|
|
proposalId: z.string(),
|
||
|
|
reason: z.string().min(10),
|
||
|
|
}))
|
||
|
|
.mutation(async ({ input, ctx }) => { ... }),
|
||
|
|
|
||
|
|
/** Admin directly sets winners */
|
||
|
|
overrideAdminDecision: adminProcedure
|
||
|
|
.input(z.object({
|
||
|
|
proposalId: z.string(),
|
||
|
|
newRankedProjectIds: z.array(z.string()).min(1),
|
||
|
|
reason: z.string().min(10),
|
||
|
|
}))
|
||
|
|
.mutation(async ({ input, ctx }) => { ... }),
|
||
|
|
|
||
|
|
// ── Freeze ─────────────────────────────────────────────
|
||
|
|
|
||
|
|
/** Freeze a proposal (make results official) */
|
||
|
|
freezeProposal: adminProcedure
|
||
|
|
.input(z.object({ proposalId: z.string() }))
|
||
|
|
.mutation(async ({ input, ctx }) => { ... }),
|
||
|
|
|
||
|
|
/** Freeze all approved proposals at once */
|
||
|
|
freezeAll: adminProcedure
|
||
|
|
.input(z.object({ competitionId: z.string() }))
|
||
|
|
.mutation(async ({ input, ctx }) => { ... }),
|
||
|
|
|
||
|
|
// ── Notifications ──────────────────────────────────────
|
||
|
|
|
||
|
|
/** Send reminder to pending jury members */
|
||
|
|
sendReminder: adminProcedure
|
||
|
|
.input(z.object({
|
||
|
|
proposalId: z.string(),
|
||
|
|
userIds: z.array(z.string()).optional(), // specific jurors, or all pending
|
||
|
|
}))
|
||
|
|
.mutation(async ({ input }) => { ... }),
|
||
|
|
|
||
|
|
// ── Export ─────────────────────────────────────────────
|
||
|
|
|
||
|
|
/** Export results as PDF */
|
||
|
|
exportResultsPdf: adminProcedure
|
||
|
|
.input(z.object({ competitionId: z.string() }))
|
||
|
|
.mutation(async ({ input }) => { ... }),
|
||
|
|
|
||
|
|
/** Export results as structured JSON */
|
||
|
|
exportResultsJson: adminProcedure
|
||
|
|
.input(z.object({ competitionId: z.string() }))
|
||
|
|
.query(async ({ input }) => { ... }),
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Service Functions
|
||
|
|
|
||
|
|
### winner-confirmation.ts
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// src/server/services/winner-confirmation.ts
|
||
|
|
|
||
|
|
/** Generate proposals for all categories from live final scores */
|
||
|
|
export async function generateProposalsFromScores(
|
||
|
|
competitionId: string,
|
||
|
|
sourceRoundId: string,
|
||
|
|
triggeredBy: string
|
||
|
|
): Promise<WinnerProposal[]>;
|
||
|
|
|
||
|
|
/** Create a manual proposal with admin-specified rankings */
|
||
|
|
export async function createManualProposal(
|
||
|
|
competitionId: string,
|
||
|
|
category: CompetitionCategory,
|
||
|
|
rankedProjectIds: string[],
|
||
|
|
justification: string,
|
||
|
|
adminId: string
|
||
|
|
): Promise<WinnerProposal>;
|
||
|
|
|
||
|
|
/** Process a jury member's approval/rejection */
|
||
|
|
export async function processApproval(
|
||
|
|
proposalId: string,
|
||
|
|
userId: string,
|
||
|
|
approved: boolean,
|
||
|
|
comments?: string
|
||
|
|
): Promise<{ proposal: WinnerProposal; isComplete: boolean }>;
|
||
|
|
|
||
|
|
/** Override with force-majority rule */
|
||
|
|
export async function overrideForceMajority(
|
||
|
|
proposalId: string,
|
||
|
|
adminId: string,
|
||
|
|
reason: string
|
||
|
|
): Promise<WinnerProposal>;
|
||
|
|
|
||
|
|
/** Override with admin-selected rankings */
|
||
|
|
export async function overrideAdminDecision(
|
||
|
|
proposalId: string,
|
||
|
|
adminId: string,
|
||
|
|
newRankedProjectIds: string[],
|
||
|
|
reason: string
|
||
|
|
): Promise<WinnerProposal>;
|
||
|
|
|
||
|
|
/** Freeze a proposal — makes results immutable */
|
||
|
|
export async function freezeProposal(
|
||
|
|
proposalId: string,
|
||
|
|
triggeredBy: string
|
||
|
|
): Promise<WinnerProposal>;
|
||
|
|
|
||
|
|
/** Freeze all approved/overridden proposals for a competition */
|
||
|
|
export async function freezeAllApproved(
|
||
|
|
competitionId: string,
|
||
|
|
adminId: string
|
||
|
|
): Promise<WinnerProposal[]>;
|
||
|
|
|
||
|
|
/** Get the full confirmation package (main winners + special awards) */
|
||
|
|
export async function getConfirmationPackage(
|
||
|
|
competitionId: string
|
||
|
|
): Promise<ConfirmationPackage>;
|
||
|
|
|
||
|
|
/** Check if all categories are frozen for a competition */
|
||
|
|
export async function isFullyFrozen(
|
||
|
|
competitionId: string
|
||
|
|
): Promise<boolean>;
|
||
|
|
|
||
|
|
/** Send reminder notifications to pending jury members */
|
||
|
|
export async function sendApprovalReminder(
|
||
|
|
proposalId: string,
|
||
|
|
specificUserIds?: string[]
|
||
|
|
): Promise<void>;
|
||
|
|
|
||
|
|
/** Get aggregated scores from the source round */
|
||
|
|
export async function getAggregatedScores(
|
||
|
|
roundId: string,
|
||
|
|
category: CompetitionCategory
|
||
|
|
): Promise<AggregatedScore[]>;
|
||
|
|
|
||
|
|
/** Export frozen results as a structured document */
|
||
|
|
export async function exportResults(
|
||
|
|
competitionId: string,
|
||
|
|
format: 'json' | 'pdf'
|
||
|
|
): Promise<Buffer | Record<string, unknown>>;
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Edge Cases
|
||
|
|
|
||
|
|
| Scenario | Handling |
|
||
|
|
|----------|----------|
|
||
|
|
| **Jury member doesn't respond by deadline** | Admin notified. Can send reminders or use override |
|
||
|
|
| **All jurors reject** | Status = REJECTED. Admin must override or create new proposal |
|
||
|
|
| **Juror tries to change vote after submitting** | Not allowed. Must contact admin to reset vote |
|
||
|
|
| **Admin resets a juror's vote** | Audit logged. Juror re-notified to vote again |
|
||
|
|
| **Tie in live finals scores** | Proposal lists tied projects at same rank. Admin resolves via override |
|
||
|
|
| **Proposal frozen, then error discovered** | Cannot unfreeze. Admin creates new proposal with explanation |
|
||
|
|
| **Juror is on multiple categories** | Separate approval records per proposal (per-category) |
|
||
|
|
| **Competition has no special awards** | `includeSpecialAwards` is false; only main proposals |
|
||
|
|
| **Admin freezes before all categories confirmed** | Allowed — each category freezes independently |
|
||
|
|
| **Network failure during approval** | Optimistic UI with retry. Server-side idempotency via unique constraint |
|
||
|
|
| **Juror removed from jury group after proposal sent** | Approval record remains but marked as N/A. Threshold recalculated |
|
||
|
|
| **Multiple proposals for same category** | Only latest non-frozen proposal is active. Previous ones archived |
|
||
|
|
| **Live finals not yet complete** | Cannot generate proposal — round must be in COMPLETED status |
|
||
|
|
| **Admin tries to freeze PENDING proposal** | Blocked — must be APPROVED or OVERRIDDEN first |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Integration Points
|
||
|
|
|
||
|
|
### Inbound (from other rounds/systems)
|
||
|
|
|
||
|
|
| Source | Data | Purpose |
|
||
|
|
|--------|------|---------|
|
||
|
|
| Round 7 (LIVE_FINAL) | Aggregated scores, rankings | Auto-generate WinnerProposal |
|
||
|
|
| JuryGroup (Jury 3) | Member list | Determine who must approve |
|
||
|
|
| SpecialAward system | Award winners | Include in confirmation package |
|
||
|
|
| Competition settings | Category config | Per-category proposals |
|
||
|
|
|
||
|
|
### Outbound (to other systems)
|
||
|
|
|
||
|
|
| Target | Data | Purpose |
|
||
|
|
|--------|------|---------|
|
||
|
|
| DecisionAuditLog | All actions | Full audit trail |
|
||
|
|
| Notification system | Events | Email + in-app alerts |
|
||
|
|
| Export service | Frozen results | PDF/JSON export for records |
|
||
|
|
| Admin dashboard | Progress metrics | Real-time status display |
|
||
|
|
| Competition status | COMPLETED flag | Mark competition as finished |
|
||
|
|
|
||
|
|
### Confirmation → Competition Completion
|
||
|
|
|
||
|
|
When all proposals are frozen:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
async function completeConfirmationRound(competitionId: string): Promise<void> {
|
||
|
|
// 1. Mark the confirmation round as COMPLETED
|
||
|
|
const confirmationRound = await prisma.round.findFirst({
|
||
|
|
where: { competitionId, roundType: 'CONFIRMATION' },
|
||
|
|
});
|
||
|
|
|
||
|
|
if (confirmationRound) {
|
||
|
|
await prisma.round.update({
|
||
|
|
where: { id: confirmationRound.id },
|
||
|
|
data: { status: 'ROUND_COMPLETED' },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. Update competition status
|
||
|
|
await prisma.competition.update({
|
||
|
|
where: { id: competitionId },
|
||
|
|
data: { status: 'CLOSED' },
|
||
|
|
});
|
||
|
|
|
||
|
|
// 3. Log competition completion
|
||
|
|
await prisma.decisionAuditLog.create({
|
||
|
|
data: {
|
||
|
|
entityType: 'COMPETITION',
|
||
|
|
entityId: competitionId,
|
||
|
|
action: 'COMPETITION_COMPLETED',
|
||
|
|
details: {
|
||
|
|
completedAt: new Date().toISOString(),
|
||
|
|
reason: 'All winner proposals frozen',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// 4. Send competition-complete notifications
|
||
|
|
await notifyCompetitionComplete(competitionId);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Security Considerations
|
||
|
|
|
||
|
|
| Concern | Mitigation |
|
||
|
|
|---------|------------|
|
||
|
|
| **Jury member votes for wrong proposal** | Proposals are category-specific; UI shows only relevant proposal |
|
||
|
|
| **Admin forges jury approvals** | Approvals tied to authenticated session; audit log captures userId |
|
||
|
|
| **Results tampered after freeze** | FROZEN status enforced at database level; no UPDATE allowed |
|
||
|
|
| **Unauthorized freeze** | Only SUPER_ADMIN and PROGRAM_ADMIN can freeze (adminProcedure) |
|
||
|
|
| **Replay attack on approval** | Unique constraint on [winnerProposalId, userId] prevents double-voting |
|
||
|
|
| **Override without justification** | `reason` field is required (min 10 chars) on all overrides |
|
||
|
|
| **PDF export tampering** | Export includes cryptographic hash of frozen proposal data |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Results Export Format
|
||
|
|
|
||
|
|
### JSON Export
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
type ExportedResults = {
|
||
|
|
competition: {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
programYear: number;
|
||
|
|
};
|
||
|
|
exportedAt: string;
|
||
|
|
exportedBy: string;
|
||
|
|
|
||
|
|
categories: {
|
||
|
|
category: string;
|
||
|
|
proposalId: string;
|
||
|
|
status: WinnerProposalStatus;
|
||
|
|
frozenAt: string;
|
||
|
|
|
||
|
|
winners: {
|
||
|
|
rank: number;
|
||
|
|
project: {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
teamName: string;
|
||
|
|
category: string;
|
||
|
|
};
|
||
|
|
scores: {
|
||
|
|
juryScore: number;
|
||
|
|
audienceScore: number;
|
||
|
|
finalScore: number;
|
||
|
|
};
|
||
|
|
}[];
|
||
|
|
|
||
|
|
approvals: {
|
||
|
|
jurorName: string;
|
||
|
|
approved: boolean;
|
||
|
|
respondedAt: string;
|
||
|
|
comments?: string;
|
||
|
|
}[];
|
||
|
|
|
||
|
|
override?: {
|
||
|
|
type: string;
|
||
|
|
reason: string;
|
||
|
|
overriddenBy: string;
|
||
|
|
overriddenAt: string;
|
||
|
|
};
|
||
|
|
}[];
|
||
|
|
|
||
|
|
specialAwards: {
|
||
|
|
awardName: string;
|
||
|
|
winnerProject: string;
|
||
|
|
method: string;
|
||
|
|
score: number;
|
||
|
|
}[];
|
||
|
|
|
||
|
|
// Integrity hash
|
||
|
|
integrityHash: string;
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### PDF Export Structure
|
||
|
|
|
||
|
|
The PDF export contains:
|
||
|
|
1. **Cover page** — Competition name, year, date of confirmation
|
||
|
|
2. **Main winners** — Per category: ranked list with scores, team info
|
||
|
|
3. **Special awards** — Award name, winner, scoring method
|
||
|
|
4. **Jury confirmations** — List of all approvals with timestamps
|
||
|
|
5. **Override record** — If any override was used, full details
|
||
|
|
6. **Audit summary** — Key events from the audit trail
|
||
|
|
7. **Integrity hash** — SHA-256 of all frozen proposal data
|