Compare commits
27 Commits
0a96960ae2
...
f055926b6f
| Author | SHA1 | Date | |
|---|---|---|---|
| f055926b6f | |||
| a6f3945337 | |||
| 84031a4e04 | |||
| 6512e4ea2a | |||
| c851acae20 | |||
| 8f71527353 | |||
| 68422e6c26 | |||
| 7b407528f6 | |||
| c310631480 | |||
| d1d64cb6f7 | |||
| 4683bb8740 | |||
| 7c4dffaf84 | |||
| 890795edd9 | |||
| af9528dcfb | |||
| 91bc100559 | |||
| aa383f53f8 | |||
| 7193abd87b | |||
| 44946cb845 | |||
| 8cc86bae20 | |||
| c96f1b67a5 | |||
| 79bd4dbae7 | |||
| 2a61aa8e08 | |||
| a327962f04 | |||
| 6c97ce3ed9 | |||
| 0edb50cd3a | |||
| bf86eeee7f | |||
| 38658d2611 |
85
.planning/PROJECT.md
Normal file
85
.planning/PROJECT.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# MOPC — AI Ranking, Advancement & Mentoring During Rounds
|
||||
|
||||
## What This Is
|
||||
|
||||
An enhancement to the MOPC jury voting platform that adds AI-powered project ranking after evaluation rounds, an admin dashboard for reviewing/adjusting rankings and advancing projects to the next round, and the ability to assign mentors during non-mentoring rounds (e.g., during document submission or evaluation) with automatic carryover across rounds.
|
||||
|
||||
## Core Value
|
||||
|
||||
Admins can describe ranking criteria in natural language, the system interprets and ranks projects accordingly, and they can advance the top projects to the next round with one click — all with full override control.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Validated
|
||||
|
||||
<!-- Inferred from existing codebase -->
|
||||
|
||||
- ✓ Competition system with ordered rounds (INTAKE → FILTERING → EVALUATION → SUBMISSION → MENTORING → LIVE_FINAL → DELIBERATION) — existing
|
||||
- ✓ Jury evaluation with scoring forms and pass/fail criteria — existing
|
||||
- ✓ AdvancementRule model with configurable rule types (AUTO_ADVANCE, SCORE_THRESHOLD, TOP_N, ADMIN_SELECTION) — existing
|
||||
- ✓ ProjectRoundState tracking per project per round — existing
|
||||
- ✓ JuryGroup and JuryAssignment for panel management — existing
|
||||
- ✓ CompetitionCategory enum (STARTUP, BUSINESS_CONCEPT) with per-project categorization — existing
|
||||
- ✓ Email notification system with Nodemailer/Poste.io — existing
|
||||
- ✓ Mentor dashboard route group `(mentor)` — existing
|
||||
- ✓ Round engine state machine for round transitions — existing
|
||||
- ✓ AI services with anonymization layer — existing
|
||||
|
||||
### Active
|
||||
|
||||
<!-- Current scope. Building toward these. -->
|
||||
|
||||
- [ ] AI ranking engine that interprets natural-language criteria into ranking logic
|
||||
- [ ] Admin ranking dashboard with drag-and-drop reordering per competition category
|
||||
- [ ] Side panel detail view showing evaluation data for selected project in ranking list
|
||||
- [ ] "Advance top X" button to promote selected projects to next round
|
||||
- [ ] Admin choice per-batch: send advancement/rejection email OR update status silently
|
||||
- [ ] Admin-editable email templates with variable insertion ({{firstName}}, {{teamName}}, etc.)
|
||||
- [ ] AI criteria preview mode: admin sees parsed rules before applying
|
||||
- [ ] Quick rank mode: AI interprets and ranks directly, admin adjusts after
|
||||
- [ ] Mentor assignment during non-MENTORING rounds (evaluation, submission, etc.)
|
||||
- [ ] Auto-persist mentor assignments across rounds (unless project eliminated)
|
||||
- [ ] Admin override for mentor assignments at any time
|
||||
- [ ] AI-suggested mentor-to-project matching with admin confirmation
|
||||
- [ ] Notification awareness: warn admin if next round doesn't have auto-emails, so they know to send manually
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Award eligibility (Spotlight on Africa, etc.) — separate workflow, later milestone
|
||||
- Changes to the juror evaluation interface — already built
|
||||
- Real-time collaborative ranking (multi-admin simultaneous drag) — unnecessary complexity
|
||||
- Public-facing ranking results — admin-only feature
|
||||
|
||||
## Context
|
||||
|
||||
The competition is actively running. Evaluations for the first round are complete and the client needs to rank projects and advance semi-finalists urgently (by Monday). The ranking criteria were communicated in a mix of French and English by the organizers:
|
||||
|
||||
- 2 yes votes → semi-finalist
|
||||
- 2 no votes → not semi-finalist
|
||||
- 1 yes + 1 no with ≥6/10 overall → consider as semi-finalist (depending on total count)
|
||||
- Special attention to whether evaluations included at least 1 internal + 1 external juror
|
||||
|
||||
Categories are STARTUP and BUSINESS_CONCEPT — rankings and advancement happen per-category within a single competition.
|
||||
|
||||
The platform already has `AdvancementRule` with rule types but no AI interpretation layer. The `MentorAssignment` concept doesn't yet support cross-round persistence or assignment during non-mentoring rounds.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Timeline**: Semi-finalist notifications need to go out by Monday — ranking and advancement are highest priority
|
||||
- **Tech stack**: Must use existing stack (Next.js 15, tRPC, Prisma, OpenAI)
|
||||
- **Data model**: CompetitionCategory (STARTUP/BUSINESS_CONCEPT) is on the Project model, rankings must respect this split
|
||||
- **Security**: AI ranking criteria go through OpenAI — must anonymize project data before sending
|
||||
- **Existing patterns**: Follow tRPC router + Zod validation + service layer pattern
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Rationale | Outcome |
|
||||
|----------|-----------|---------|
|
||||
| AI interprets natural-language criteria rather than hardcoded rules | Client changes criteria between rounds; flexible system avoids code changes | — Pending |
|
||||
| Rankings per CompetitionCategory, not per JuryGroup | Categories (Startup vs Business Concept) are the meaningful split for advancement | — Pending |
|
||||
| Mentor assignments auto-persist across rounds | Reduces admin work; mentors build relationship with teams over time | — Pending |
|
||||
| Admin-editable email templates with variables | Client sends personalized emails in French/English; templates must be customizable | — Pending |
|
||||
| Side panel for ranking detail view | Keeps drag-and-drop list compact while providing full evaluation context on demand | — Pending |
|
||||
|
||||
---
|
||||
*Last updated: 2026-02-26 after initialization*
|
||||
127
.planning/REQUIREMENTS.md
Normal file
127
.planning/REQUIREMENTS.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Requirements: MOPC — AI Ranking, Advancement & Mentoring
|
||||
|
||||
**Defined:** 2026-02-26
|
||||
**Core Value:** Admins can describe ranking criteria in natural language, the system interprets and ranks projects accordingly, and they can advance the top projects to the next round with one click — all with full override control.
|
||||
|
||||
## v1 Requirements
|
||||
|
||||
Requirements for this milestone. Each maps to roadmap phases.
|
||||
|
||||
### AI Ranking Engine
|
||||
|
||||
- [x] **RANK-01**: Admin can write ranking criteria in natural language (free text) for any evaluation round
|
||||
- [x] **RANK-02**: AI interprets natural-language criteria into structured ranking rules (vote thresholds, score cutoffs, conditional logic)
|
||||
- [x] **RANK-03**: AI presents parsed rules to admin for review and confirmation before applying (preview mode)
|
||||
- [x] **RANK-04**: Admin can use quick-rank mode where AI interprets and ranks directly without preview
|
||||
- [x] **RANK-05**: System ranks projects per CompetitionCategory (STARTUP, BUSINESS_CONCEPT) separately
|
||||
- [x] **RANK-06**: Ranking considers jury evaluation scores, pass/fail votes, and any criteria defined by admin
|
||||
- [x] **RANK-07**: AI ranking service anonymizes project data before sending to OpenAI (follows existing anonymization pattern)
|
||||
- [x] **RANK-08**: Ranking results are stored as snapshots for audit trail (RankingSnapshot model)
|
||||
- [x] **RANK-09**: Ranking auto-triggers when all jury assignments for a round are completed (all evaluations submitted)
|
||||
- [x] **RANK-10**: Auto-trigger works retroactively for rounds where all assignments are already complete
|
||||
|
||||
### Ranking Dashboard
|
||||
|
||||
- [x] **DASH-01**: Admin sees ranked project list per category on the round detail page (new tab)
|
||||
- [x] **DASH-02**: Admin can drag-and-drop to reorder projects in the ranked list
|
||||
- [x] **DASH-03**: Drag-and-drop state is isolated from server state to prevent snap-back race conditions
|
||||
- [x] **DASH-04**: Admin can click a project to see full evaluation data in a side panel (scores, votes, juror comments, pass/fail)
|
||||
- [x] **DASH-05**: Admin can select "Advance top X" to promote the top N projects to the next round
|
||||
- [x] **DASH-06**: Admin can batch-reject remaining non-advanced projects
|
||||
- [x] **DASH-07**: Advance button is disabled until any pending reorder mutations settle
|
||||
|
||||
### Email & Notifications
|
||||
|
||||
- [ ] **MAIL-01**: Admin can edit email text content for advancement/rejection notifications (follows existing email styling)
|
||||
- [ ] **MAIL-02**: Email templates support variable insertion ({{firstName}}, {{teamName}}, {{competitionName}}, {{roundName}}, etc.) with simple text editor
|
||||
- [ ] **MAIL-03**: Variable substitution uses whitelist-only approach (no Handlebars/Mustache engine) to prevent template injection
|
||||
- [ ] **MAIL-04**: Admin chooses per-batch whether to send advancement email, rejection email, or just update status silently
|
||||
- [ ] **MAIL-05**: System warns admin if the next round does not have automated welcome emails configured
|
||||
- [ ] **MAIL-06**: EmailTemplate model stores templates in database, associated with competition/round
|
||||
|
||||
### Mentor Management
|
||||
|
||||
- [ ] **MENT-01**: Admin can assign mentors to projects during any round type (not just MENTORING rounds)
|
||||
- [ ] **MENT-02**: Mentor assignments auto-persist across rounds unless the project is eliminated
|
||||
- [ ] **MENT-03**: Admin can override or change mentor assignments at any time
|
||||
- [ ] **MENT-04**: AI suggests mentor-project matches based on expertise, with admin confirmation
|
||||
- [ ] **MENT-05**: System re-validates conflict of interest when mentor assignment carries over to a new round
|
||||
- [ ] **MENT-06**: Mentor assignment status is visible in the ranking dashboard for context
|
||||
|
||||
## v2 Requirements
|
||||
|
||||
Deferred to future release. Tracked but not in current roadmap.
|
||||
|
||||
### Advanced Ranking
|
||||
|
||||
- **RANK-V2-01**: Ranking history comparison (compare snapshots across re-rankings)
|
||||
- **RANK-V2-02**: Export ranking results to CSV/PDF
|
||||
- **RANK-V2-03**: Multi-language criteria support (French/English auto-detect)
|
||||
|
||||
### Advanced Email
|
||||
|
||||
- **MAIL-V2-01**: Email template versioning and rollback
|
||||
- **MAIL-V2-02**: Email preview with sample data before sending
|
||||
- **MAIL-V2-03**: Email delivery tracking (sent, opened, bounced)
|
||||
|
||||
### Advanced Mentoring
|
||||
|
||||
- **MENT-V2-01**: Mentor capacity management (max projects per mentor)
|
||||
- **MENT-V2-02**: Mentor-applicant messaging through the platform
|
||||
- **MENT-V2-03**: Mentor feedback forms per round
|
||||
|
||||
## Out of Scope
|
||||
|
||||
| Feature | Reason |
|
||||
|---------|--------|
|
||||
| Real-time collaborative ranking | CRDT complexity, single admin typically manages rankings |
|
||||
| Fully automated advancement without review | Accountability concern — human must confirm |
|
||||
| WYSIWYG email editor | XSS risk + complexity; Tiptap with variable chips is sufficient |
|
||||
| Auto-send emails on round transition | Risk of accidental mass-emails in production |
|
||||
| Award eligibility (Spotlight on Africa) | Separate workflow, later milestone |
|
||||
| Public-facing ranking results | Admin-only feature, no external visibility needed |
|
||||
|
||||
## Traceability
|
||||
|
||||
Which phases cover which requirements. Updated during roadmap creation.
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| RANK-01 | Phase 1 | Complete |
|
||||
| RANK-02 | Phase 1 | Complete |
|
||||
| RANK-03 | Phase 1 | Complete |
|
||||
| RANK-04 | Phase 1 | Complete |
|
||||
| RANK-05 | Phase 1 | Complete |
|
||||
| RANK-06 | Phase 1 | Complete |
|
||||
| RANK-07 | Phase 1 | Complete |
|
||||
| RANK-08 | Phase 1 | Complete |
|
||||
| RANK-09 | Phase 1 | Complete |
|
||||
| RANK-10 | Phase 1 | Complete |
|
||||
| DASH-01 | Phase 2 | Complete |
|
||||
| DASH-02 | Phase 2 | Complete |
|
||||
| DASH-03 | Phase 2 | Complete |
|
||||
| DASH-04 | Phase 2 | Complete |
|
||||
| DASH-05 | Phase 2 | Complete |
|
||||
| DASH-06 | Phase 2 | Complete |
|
||||
| DASH-07 | Phase 2 | Complete |
|
||||
| MAIL-01 | Phase 3 | Pending |
|
||||
| MAIL-02 | Phase 3 | Pending |
|
||||
| MAIL-03 | Phase 3 | Pending |
|
||||
| MAIL-04 | Phase 3 | Pending |
|
||||
| MAIL-05 | Phase 3 | Pending |
|
||||
| MAIL-06 | Phase 3 | Pending |
|
||||
| MENT-01 | Phase 4 | Pending |
|
||||
| MENT-02 | Phase 4 | Pending |
|
||||
| MENT-03 | Phase 4 | Pending |
|
||||
| MENT-04 | Phase 4 | Pending |
|
||||
| MENT-05 | Phase 4 | Pending |
|
||||
| MENT-06 | Phase 4 | Pending |
|
||||
|
||||
**Coverage:**
|
||||
- v1 requirements: 29 total
|
||||
- Mapped to phases: 29
|
||||
- Unmapped: 0 ✓
|
||||
|
||||
---
|
||||
*Requirements defined: 2026-02-26*
|
||||
*Last updated: 2026-02-26 after roadmap creation — all 29 requirements mapped*
|
||||
81
.planning/ROADMAP.md
Normal file
81
.planning/ROADMAP.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Roadmap: MOPC — AI Ranking, Advancement & Mentoring
|
||||
|
||||
## Overview
|
||||
|
||||
This milestone extends the MOPC jury voting platform with four capabilities: an AI-powered ranking engine that interprets natural-language criteria and ranks projects per competition category, a drag-and-drop ranking dashboard for admin review and override, a bulk advancement flow with optional email notification, and cross-round mentor persistence that allows mentors to be assigned during any round type. Phases 1-3 are sequential and time-critical (Monday deadline for ranking and advancement). Phase 4 is independent and can run in parallel with Phases 2-3.
|
||||
|
||||
## Phases
|
||||
|
||||
**Phase Numbering:**
|
||||
- Integer phases (1, 2, 3): Planned milestone work
|
||||
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
|
||||
|
||||
Decimal phases appear between their surrounding integers in numeric order.
|
||||
|
||||
- [x] **Phase 1: AI Ranking Backend** - AI service, tRPC router, schema, and auto-trigger for ranking projects per category using natural-language criteria
|
||||
(completed 2026-02-27)
|
||||
- [x] **Phase 2: Ranking Dashboard UI** - Drag-and-drop ranking tab on the round detail page with criteria input, preview-confirm dialog, and project detail side panel
|
||||
(completed 2026-02-27)
|
||||
- [ ] **Phase 3: Advancement + Email** - Bulk advancement flow with reject/advance in one operation, notify-vs-silent toggle, and admin-editable email templates
|
||||
- [ ] **Phase 4: Mentor Persistence** - Mentor assignment during any round type with cross-round carryover and COI re-validation
|
||||
|
||||
## Phase Details
|
||||
|
||||
### Phase 1: AI Ranking Backend
|
||||
**Goal**: Admin can call an AI ranking API that interprets natural-language criteria and returns per-category ranked project lists, persisted for audit
|
||||
**Depends on**: Nothing (first phase)
|
||||
**Requirements**: RANK-01, RANK-02, RANK-03, RANK-04, RANK-05, RANK-06, RANK-07, RANK-08, RANK-09, RANK-10
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Admin can submit natural-language ranking criteria and receive a structured preview of parsed rules before any ranking is applied
|
||||
2. AI ranking executes per CompetitionCategory (STARTUP and BUSINESS_CONCEPT ranked separately) using jury scores, pass/fail votes, and admin-defined criteria
|
||||
3. All project data is anonymized before leaving the server; OpenAI receives no PII
|
||||
4. Ranking results are stored as a RankingSnapshot with full audit trail (who ran it, when, with what criteria)
|
||||
5. Ranking auto-triggers when all jury assignments for a round are complete, including rounds where all assignments were already submitted before this feature shipped
|
||||
**Plans**: TBD
|
||||
|
||||
### Phase 2: Ranking Dashboard UI
|
||||
**Goal**: Admin has a working ranking interface on the round detail page where they can review, reorder, and prepare projects for advancement
|
||||
**Depends on**: Phase 1
|
||||
**Requirements**: DASH-01, DASH-02, DASH-03, DASH-04, DASH-05, DASH-06, DASH-07
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Admin sees a ranked project list per category on a new Ranking tab of the round detail page, showing AI-suggested order and any admin overrides as visually distinct states
|
||||
2. Admin can drag-and-drop projects to reorder within each category; local order is preserved while drag mutations are in-flight and does not snap back
|
||||
3. Admin can click any project row to see full evaluation detail in a side panel without leaving the ranking list
|
||||
4. Advance button is disabled while any reorder mutation is unsettled; once enabled, admin can select top N projects and advance them in one action
|
||||
5. Admin can batch-reject all non-advanced projects from the same interface
|
||||
**Plans**: TBD
|
||||
|
||||
### Phase 3: Advancement + Email
|
||||
**Goal**: Admin can advance and reject projects in one operation with full control over whether and what notification emails are sent to applicants
|
||||
**Depends on**: Phase 2
|
||||
**Requirements**: MAIL-01, MAIL-02, MAIL-03, MAIL-04, MAIL-05, MAIL-06
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Admin can choose per advancement batch whether to send an advancement email, a rejection email, both, or update statuses silently with no emails
|
||||
2. Admin can edit the text of advancement and rejection email templates directly in the platform, with supported variables shown inline
|
||||
3. Variable substitution uses a fixed whitelist ({{firstName}}, {{teamName}}, {{competitionName}}, {{roundName}}); no arbitrary template expressions are evaluated
|
||||
4. If the next round has no automated welcome emails configured, the system warns admin before they confirm advancement
|
||||
5. Email templates are stored in the database per competition/round and persist across sessions
|
||||
**Plans**: TBD
|
||||
|
||||
### Phase 4: Mentor Persistence
|
||||
**Goal**: Admin can assign mentors to projects from any round type, and those assignments carry forward to subsequent rounds unless the project is eliminated
|
||||
**Depends on**: Nothing (independent track — can run in parallel with Phases 2-3)
|
||||
**Requirements**: MENT-01, MENT-02, MENT-03, MENT-04, MENT-05, MENT-06
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Admin can open the mentor assignment UI from any round detail page, not only rounds of type MENTORING
|
||||
2. When a project advances to the next round, existing mentor assignments carry over automatically without admin action
|
||||
3. Before any mentor assignment carries over, the system re-validates that no conflict of interest exists between the mentor and the project in the new round; if one is found, the carryover is blocked and admin is notified
|
||||
4. Admin can override, change, or remove a mentor assignment at any point from any round
|
||||
5. AI suggests mentor-project matches based on expertise with admin confirmation required before the assignment is saved
|
||||
6. The ranking dashboard shows current mentor assignment status for each project in the ranked list
|
||||
**Plans**: TBD
|
||||
|
||||
## Progress
|
||||
|
||||
**Execution Order:**
|
||||
Phases execute in numeric order: 1 → 2 → 3 → 4 (Phase 4 can be parallelized with 2-3)
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|----------------|--------|-----------|
|
||||
| 1. AI Ranking Backend | 4/4 | Complete | 2026-02-27 |
|
||||
| 2. Ranking Dashboard UI | 3/3 | Complete | 2026-02-27 |
|
||||
99
.planning/STATE.md
Normal file
99
.planning/STATE.md
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: in_progress
|
||||
last_updated: "2026-02-27T08:56:00Z"
|
||||
progress:
|
||||
total_phases: 4
|
||||
completed_phases: 2
|
||||
total_plans: 10
|
||||
completed_plans: 7
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
## Project Reference
|
||||
|
||||
See: .planning/PROJECT.md (updated 2026-02-26)
|
||||
|
||||
**Core value:** Admins can describe ranking criteria in natural language, the system interprets and ranks projects accordingly, and they can advance the top projects to the next round with one click — all with full override control.
|
||||
**Current focus:** Phase 2 — Ranking Dashboard UI
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 2 of 4 (Ranking Dashboard UI) — COMPLETE
|
||||
Plan: 3 of 3 in current phase (Phase 2 all plans complete)
|
||||
Status: In progress (Phase 3 next)
|
||||
Last activity: 2026-02-27 — Plan 02-03 complete: Advance Top N dialog + batch-reject wired to tRPC mutations
|
||||
|
||||
Progress: [███████░░░] ~70%
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
**Velocity:**
|
||||
- Total plans completed: 5
|
||||
- Average duration: ~5 min
|
||||
- Total execution time: ~23 min
|
||||
|
||||
**By Phase:**
|
||||
|
||||
| Phase | Plans | Total | Avg/Plan |
|
||||
|-------|-------|-------|----------|
|
||||
| 01-ai-ranking-backend | 4 | ~18 min | ~5 min |
|
||||
| 02-ranking-dashboard-ui | 3 | ~18 min | ~6 min |
|
||||
|
||||
**Recent Trend:**
|
||||
- Last 5 plans: 01-04 (~8 min), 02-01 (~5 min), 02-02 (~8 min), 02-03 (~5 min)
|
||||
- Trend: Fast (service-layer + UI implementation tasks)
|
||||
|
||||
*Updated after each plan completion*
|
||||
| Phase 02-ranking-dashboard-ui P01 | 5 | 2 tasks | 3 files |
|
||||
| Phase 02-ranking-dashboard-ui P02 | 8 | 1 task | 1 file |
|
||||
| Phase 02-ranking-dashboard-ui P03 | 5 | 1 task | 1 file |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
### Decisions
|
||||
|
||||
Decisions are logged in PROJECT.md Key Decisions table.
|
||||
Recent decisions affecting current work:
|
||||
|
||||
- [Init]: RankingSnapshot model (new table) preferred over Round.metadataJson for audit history — confirm with client before writing migration
|
||||
- [Init]: Per-category processing (STARTUP / BUSINESS_CONCEPT) — two parallel AI calls, not one combined call
|
||||
- [Init]: Phase 4 is independent and can be parallelized with Phases 2-3
|
||||
- [01-01]: Used separate relation names per FK pair (RoundRankingSnapshots / TriggeredRankingSnapshots) — Prisma requires unique relation names per FK, not per target model
|
||||
- [01-01]: All EvaluationConfig ranking fields are optional/defaulted for backward compatibility with existing rounds
|
||||
- [01-01]: Created migration SQL manually (20260227000000_add_ranking_snapshot) — local DB credentials unavailable; migration applies on next deploy
|
||||
- [01-02]: fetchAndRankCategory exported (not private) so tRPC router can execute pre-parsed rules without re-parsing
|
||||
- [01-02]: Projects with zero SUBMITTED evaluations excluded from ranking entirely (not ranked last)
|
||||
- [01-02]: PrismaClient imported as real type (not import type) so transaction clients are compatible
|
||||
- [01-03]: Typed arrays cast to Prisma.InputJsonValue via `unknown` (direct cast rejected by strict TS — no index signature)
|
||||
- [01-03]: getSnapshot uses findUnique + manual TRPCError NOT_FOUND (findUniqueOrThrow gives INTERNAL_SERVER_ERROR)
|
||||
- [Phase 01-04]: triggerAutoRankIfComplete defined as module-level non-exported function in evaluation.ts — avoids circular imports, colocated with the mutation it serves
|
||||
- [Phase 01-04]: EvaluationConfig null fallback typed as {} as EvaluationConfig — required for TypeScript strict mode to recognize rankingCriteria and autoRankOnComplete fields
|
||||
- [Phase 01-04]: retroactiveScan uses RETROACTIVE triggerType to distinguish from MANUAL/AUTO/QUICK — prevents duplicate re-runs on subsequent scans
|
||||
- [02-01]: ReorderEvent type defined locally in ranking.ts (not exported) — only consumed by saveReorder procedure
|
||||
- [02-01]: saveReorder is append-only: full ordered list stored per event, latest entry per category = current admin order, gives full audit trail
|
||||
- [02-02]: Double cast (as unknown as RankedProjectEntry[]) required for Prisma JsonValue — direct cast rejected by TypeScript strict mode
|
||||
- [02-02]: getFullDetail returns { project, assignments, stats } shape — title accessed via .project.title not root level
|
||||
- [02-02]: saveReorder has no onSuccess invalidation — avoids re-fetch that would reset localOrder and cause snap-back
|
||||
- [02-03]: Advance button disabled via saveReorderMutation.isPending (reactive) not pendingReorderCount.current (ref, non-reactive)
|
||||
- [02-03]: topNStartup + topNConceptual === 0 disables confirm button — prevents no-op advance calls
|
||||
- [02-03]: batchRejectMutation fires conditionally (only if includeReject and rejectIds.length > 0)
|
||||
|
||||
### Pending Todos
|
||||
|
||||
None yet.
|
||||
|
||||
### Blockers/Concerns
|
||||
|
||||
- [Phase 1]: French/English variable naming for EmailTemplate bilingual fields needs client confirmation before Phase 3 schema migration
|
||||
- [Phase 1]: Poste.io bulk-send rate limits need verification before Phase 3 load testing
|
||||
- [Phase 3]: Tiptap Mention extension API in v3.x should be validated against Tiptap v3 docs before implementation
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-02-27
|
||||
Stopped at: Completed 02-03-PLAN.md (Advance Top N dialog + batch-reject — Phase 2 complete)
|
||||
Resume file: None
|
||||
193
.planning/codebase/ARCHITECTURE.md
Normal file
193
.planning/codebase/ARCHITECTURE.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Architecture
|
||||
|
||||
**Analysis Date:** 2026-02-26
|
||||
|
||||
## Pattern Overview
|
||||
|
||||
**Overall:** Role-gated multi-tenant monolith with end-to-end type safety (Prisma → tRPC → React)
|
||||
|
||||
**Key Characteristics:**
|
||||
- Single Next.js 15 App Router application serving all roles (admin, jury, applicant, mentor, observer, public)
|
||||
- All API calls go through tRPC with superjson serialization; no separate REST API for client data
|
||||
- Role enforcement happens at two levels: layout-level (`requireRole()`) and procedure-level (tRPC middleware)
|
||||
- Service layer (`src/server/services/`) contains all business logic — routers delegate immediately to services
|
||||
- All state machine transitions are audited via `DecisionAuditLog` through `src/server/utils/audit.ts`
|
||||
|
||||
## Layers
|
||||
|
||||
**Data Layer:**
|
||||
- Purpose: Schema definition, migrations, query client
|
||||
- Location: `prisma/schema.prisma`, `src/lib/prisma.ts`
|
||||
- Contains: Prisma schema, singleton PrismaClient with connection pool (limit=20, timeout=10)
|
||||
- Depends on: PostgreSQL 16
|
||||
- Used by: Service layer, tRPC context
|
||||
|
||||
**Service Layer:**
|
||||
- Purpose: Business logic, state machines, external integrations
|
||||
- Location: `src/server/services/`
|
||||
- Contains: Round engine, deliberation, assignment, AI services, submission manager, live control, result lock, notification
|
||||
- Depends on: Prisma client (passed as param to allow transactional usage), `src/lib/` utilities
|
||||
- Used by: tRPC routers only
|
||||
|
||||
**API Layer (tRPC):**
|
||||
- Purpose: Type-safe RPC procedures with role-based access control
|
||||
- Location: `src/server/routers/`, `src/server/trpc.ts`, `src/server/context.ts`
|
||||
- Contains: 44+ domain routers assembled in `src/server/routers/_app.ts`, middleware hierarchy, tRPC context
|
||||
- Depends on: Service layer, Prisma (via context)
|
||||
- Used by: Client components via `src/lib/trpc/client.ts`
|
||||
|
||||
**UI Layer:**
|
||||
- Purpose: Server and client React components, pages, layouts
|
||||
- Location: `src/app/`, `src/components/`
|
||||
- Contains: Route groups per role, layouts with role guards, client components using tRPC hooks
|
||||
- Depends on: tRPC client, shadcn/ui, Tailwind CSS
|
||||
- Used by: Browser clients
|
||||
|
||||
**Shared Utilities:**
|
||||
- Purpose: Cross-cutting helpers available everywhere
|
||||
- Location: `src/lib/`
|
||||
- Contains: Auth config, Prisma singleton, email, MinIO client, OpenAI client, logger, rate limiter, feature flags, storage provider abstraction
|
||||
- Depends on: External services
|
||||
- Used by: Service layer, routers, layouts
|
||||
|
||||
## Data Flow
|
||||
|
||||
**Client Query Flow:**
|
||||
|
||||
1. React component calls `trpc.domain.procedure.useQuery()` via `src/lib/trpc/client.ts`
|
||||
2. Request hits `src/app/api/trpc/[trpc]/route.ts` — rate limited at 100 req/min per IP
|
||||
3. tRPC resolves context (`src/server/context.ts`): auth session + prisma singleton + IP/UA
|
||||
4. Middleware chain runs: authentication check → role check → procedure handler
|
||||
5. Router delegates to service (e.g., `roundEngineRouter` → `src/server/services/round-engine.ts`)
|
||||
6. Service queries Prisma, may call external APIs, writes audit log
|
||||
7. Superjson-serialized result returns to React Query cache
|
||||
|
||||
**Mutation Flow (with audit trail):**
|
||||
|
||||
1. Component calls `trpc.domain.action.useMutation()`
|
||||
2. tRPC middleware validates auth + role
|
||||
3. Router calls `logAudit()` before or after service call
|
||||
4. Service performs database work inside `prisma.$transaction()` when atomicity required
|
||||
5. Service writes its own `logAudit()` for state machine transitions
|
||||
6. Cache invalidated via `utils.trpc.invalidate()`
|
||||
|
||||
**Server-Sent Events Flow (live voting/deliberation):**
|
||||
|
||||
1. Client subscribes via `src/hooks/use-live-voting-sse.ts` or `src/hooks/use-stage-live-sse.ts`
|
||||
2. SSE route `src/app/api/live-voting/stream/route.ts` polls database on interval
|
||||
3. Events emitted for vote count changes, cursor position changes, status changes
|
||||
4. Admin cursor controlled via `src/server/services/live-control.ts` → tRPC `liveRouter`
|
||||
|
||||
**State Management:**
|
||||
- Server state: React Query via tRPC hooks (cache + invalidation)
|
||||
- Edition/program selection: `src/contexts/edition-context.tsx` (localStorage + URL param + React Context)
|
||||
- Form state: Local React state with autosave timers (evaluation page uses refs to prevent race conditions)
|
||||
- No global client state library (no Redux/Zustand)
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
**Competition/Round State Machine:**
|
||||
- Purpose: Governs round lifecycle and per-project states within rounds
|
||||
- Examples: `src/server/services/round-engine.ts`
|
||||
- Pattern: Pure functions with explicit transition maps; `VALID_ROUND_TRANSITIONS` and `VALID_PROJECT_TRANSITIONS` constants define allowed moves. All transitions are transactional and audited.
|
||||
- Round transitions: `ROUND_DRAFT → ROUND_ACTIVE → ROUND_CLOSED → ROUND_ARCHIVED`
|
||||
- Project-in-round transitions: `PENDING → IN_PROGRESS → PASSED/REJECTED → COMPLETED/WITHDRAWN`
|
||||
|
||||
**tRPC Procedure Types (RBAC middleware):**
|
||||
- Purpose: Enforce role-based access at the API boundary
|
||||
- Examples: `src/server/trpc.ts`
|
||||
- Pattern: `publicProcedure`, `protectedProcedure`, `adminProcedure`, `superAdminProcedure`, `juryProcedure`, `mentorProcedure`, `observerProcedure`, `awardMasterProcedure`, `audienceProcedure`
|
||||
- Each is a pre-configured middleware chain; routers simply choose the correct type
|
||||
|
||||
**CompetitionContext resolver:**
|
||||
- Purpose: Loads the full typed context for a round (competition + round + parsed configJson + juryGroup + submissionWindows)
|
||||
- Examples: `src/server/services/competition-context.ts`
|
||||
- Pattern: `resolveCompetitionContext(roundId)` used by any service that needs the full picture. Also provides `resolveMemberContext()` for jury-member-specific context with assignment counts.
|
||||
|
||||
**Round-Type Config Schemas:**
|
||||
- Purpose: Each RoundType (`INTAKE`, `FILTERING`, `EVALUATION`, etc.) has a dedicated Zod config schema stored in `Round.configJson`
|
||||
- Examples: `src/types/competition-configs.ts` — `IntakeConfigSchema`, `FilteringConfigSchema`, `EvaluationConfigSchema`, etc.
|
||||
- Pattern: `safeValidateRoundConfig(roundType, configJson)` returns typed config or null; `validateRoundConfig()` throws on invalid
|
||||
|
||||
**Storage Provider Abstraction:**
|
||||
- Purpose: Swap MinIO (production) for local filesystem (dev/test) without changing service code
|
||||
- Examples: `src/lib/storage/types.ts`, `src/lib/storage/s3-provider.ts`, `src/lib/storage/local-provider.ts`, `src/lib/storage/index.ts`
|
||||
- Pattern: `StorageProvider` interface with `getUploadUrl`, `getDownloadUrl`, `deleteObject`, `putObject`, `getObject`
|
||||
|
||||
**AI Pipeline with Anonymization:**
|
||||
- Purpose: All AI calls strip PII before sending to OpenAI
|
||||
- Examples: `src/server/services/anonymization.ts` + any `src/server/services/ai-*.ts`
|
||||
- Pattern: `anonymizeProjectsForAI()` returns `AnonymizedProjectForAI[]` + `ProjectAIMapping`; AI service uses anonymous IDs; results mapped back via mapping object
|
||||
|
||||
## Entry Points
|
||||
|
||||
**tRPC API Handler:**
|
||||
- Location: `src/app/api/trpc/[trpc]/route.ts`
|
||||
- Triggers: All client data queries and mutations
|
||||
- Responsibilities: Rate limiting (100 req/min), `fetchRequestHandler` with `appRouter` + `createContext`, error logging
|
||||
|
||||
**Auth Handler:**
|
||||
- Location: `src/app/api/auth/[...nextauth]/route.ts`
|
||||
- Triggers: Login, magic link verification, session management
|
||||
- Responsibilities: NextAuth v5 with Email + Credentials providers, 5-attempt lockout
|
||||
|
||||
**Cron Endpoints:**
|
||||
- Location: `src/app/api/cron/` (audit-cleanup, digest, draft-cleanup, reminders)
|
||||
- Triggers: External scheduler via `CRON_SECRET` header check
|
||||
- Responsibilities: Periodic maintenance — evaluation reminders, draft cleanup, digest emails, audit log rotation
|
||||
|
||||
**SSE Stream:**
|
||||
- Location: `src/app/api/live-voting/stream/route.ts`
|
||||
- Triggers: Live voting/deliberation pages subscribe as long-running GET connections
|
||||
- Responsibilities: Poll DB for changes, push events for vote counts, cursor position, status transitions
|
||||
|
||||
**Next.js Middleware:**
|
||||
- Location: `middleware.ts` (root, uses `src/lib/auth.config.ts`)
|
||||
- Triggers: Every request
|
||||
- Responsibilities: Auth check (edge-compatible), redirect to `/login` if unauthenticated, redirect to `/set-password` if `mustSetPassword` flag set
|
||||
|
||||
**Role Layout Guards:**
|
||||
- Location: `src/app/(admin)/layout.tsx`, `src/app/(jury)/layout.tsx`, etc.
|
||||
- Triggers: Navigation into role-specific route group
|
||||
- Responsibilities: Server-side `requireRole()` call, redirect to role dashboard if unauthorized, onboarding gate (jury)
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Strategy:** Boundary-based — tRPC errors propagate to React Query; service errors use `TRPCError`; audit never throws
|
||||
|
||||
**Patterns:**
|
||||
- Services return typed result objects (`{ success: boolean, errors?: string[] }`) for state machine operations — no throwing
|
||||
- tRPC procedures throw `TRPCError` with code (`NOT_FOUND`, `FORBIDDEN`, `CONFLICT`, `BAD_REQUEST`) for client-visible errors
|
||||
- `logAudit()` is wrapped in try-catch — audit failures are logged to console but never surface to users
|
||||
- AI services use `classifyAIError()` from `src/server/services/ai-errors.ts` to translate OpenAI errors
|
||||
- Round notification functions explicitly catch all errors and log them — notifications never block round transitions
|
||||
- Client uses `toast.error()` from sonner for user-facing error display
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
**Logging:**
|
||||
- `src/lib/logger.ts` — tagged, level-aware (`debug/info/warn/error`), respects `LOG_LEVEL` env var, defaults to `debug` in development and `warn` in production
|
||||
- Pattern: `logger.info('ServiceName', 'message', { data })` — tag identifies the calling service
|
||||
|
||||
**Validation:**
|
||||
- Zod schemas on all tRPC procedure inputs (`.input(z.object({...}))`)
|
||||
- `ZodError` is formatted and included in tRPC error response via `errorFormatter` in `src/server/trpc.ts`
|
||||
- Round config Zod schemas in `src/types/competition-configs.ts` validate `configJson` at activation time
|
||||
|
||||
**Authentication:**
|
||||
- NextAuth v5 with JWT strategy; session available server-side via `auth()` from `src/lib/auth.ts`
|
||||
- `requireRole(...roles)` in `src/lib/auth-redirect.ts` used by all role layouts — checks `user.roles[]` array with `user.role` fallback
|
||||
- `userHasRole()` in `src/server/trpc.ts` used inline for fine-grained procedure-level checks
|
||||
|
||||
**Audit Trail:**
|
||||
- `logAudit()` in `src/server/utils/audit.ts` — writes to `AuditLog` table
|
||||
- Called from both routers (with `ctx.prisma` to share transaction) and services
|
||||
- Never throws — always wrapped in try-catch
|
||||
|
||||
**Feature Flags:**
|
||||
- `src/lib/feature-flags.ts` — reads `SystemSetting` records with `category: FEATURE_FLAGS`
|
||||
- Currently one active flag: `feature.useCompetitionModel` (defaults to `true`)
|
||||
|
||||
---
|
||||
|
||||
*Architecture analysis: 2026-02-26*
|
||||
217
.planning/codebase/CONCERNS.md
Normal file
217
.planning/codebase/CONCERNS.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# Codebase Concerns
|
||||
|
||||
**Analysis Date:** 2026-02-26
|
||||
|
||||
---
|
||||
|
||||
## Tech Debt
|
||||
|
||||
**Award Router is a Stub:**
|
||||
- Issue: Entire award router is commented out and non-functional. All award procedures were deleted when the Pipeline/Track models were removed and have not been reimplemented.
|
||||
- Files: `src/server/routers/award.ts`
|
||||
- Impact: Any UI that references award management procedures will fail at runtime. The `SpecialAward` model still exists in the schema but has no tRPC exposure via this router.
|
||||
- Fix approach: Reimplement the router procedures against the current `SpecialAward` → `Competition` FK relationship. See the TODO comment at line 9 for the list of procedures to reimplement.
|
||||
|
||||
**Deliberation Page Has Incomplete Implementation:**
|
||||
- Issue: The jury-facing deliberation page has two hardcoded stub values that break actual vote submission.
|
||||
- `juryMemberId: ''` — submitted vote will have an empty juror ID.
|
||||
- `const hasVoted = false` — the "already voted" guard never fires, allowing duplicate vote submissions.
|
||||
- Files: `src/app/(jury)/jury/competitions/deliberation/[sessionId]/page.tsx` lines 34 and 66
|
||||
- Impact: Jury members can submit blank/duplicate votes in deliberation sessions. The submitted vote will be associated with an empty string `juryMemberId`, which will likely fail at the Prisma level or silently create bad data.
|
||||
- Fix approach: Derive `juryMemberId` from `session.participants` by matching `ctx.user.id`. Derive `hasVoted` by checking if a `DeliberationVote` with the current user's jury member ID already exists in `session.votes`.
|
||||
|
||||
**Audit Middleware is a No-Op:**
|
||||
- Issue: The `withAuditLog` middleware in `src/server/trpc.ts` (lines 99–114) identifies mutation calls by path pattern but does nothing with them — the body contains only a comment: `// We'll implement this in the audit service`.
|
||||
- Files: `src/server/trpc.ts`
|
||||
- Impact: Automatic centralised audit logging for all admin mutations does not occur through this middleware. Manual `logAudit()` / `DecisionAuditLog.create()` calls are present in many routers but coverage is inconsistent.
|
||||
- Fix approach: Implement the middleware body to call `logAudit()` with `ctx.user.id`, `path`, and serialized input/output. This provides a safety net for any procedure that doesn't call `logAudit` manually.
|
||||
|
||||
**In-Memory Rate Limiter Not Suitable for Multi-Instance Deployment:**
|
||||
- Issue: `src/lib/rate-limit.ts` uses a module-level `Map` for rate limit state. This works in a single process but does not share state across multiple Node.js instances or after a process restart.
|
||||
- Files: `src/lib/rate-limit.ts`
|
||||
- Impact: Rate limits can be trivially bypassed by hitting different server instances. Auth brute-force protection (5-attempt lockout) also uses this same in-memory store (`src/lib/auth.ts` line 12).
|
||||
- Fix approach: Replace with Redis-based rate limiting (e.g., `@upstash/ratelimit` or `ioredis`). The comment at line 5 already acknowledges this: "For production with multiple instances, replace with Redis-based solution."
|
||||
|
||||
**`configJson` Widely Cast Without Validation:**
|
||||
- Issue: `configJson` (a Prisma `Json` field) is cast directly to `Record<string, unknown>` in 65 locations across server routers and services without running it through the Zod validators. The validators (`safeValidateRoundConfig`, `EvaluationConfigSchema.safeParse`) are only called in 4 locations.
|
||||
- Files: `src/server/routers/assignment.ts`, `src/server/routers/evaluation.ts`, `src/server/routers/filtering.ts`, `src/server/services/round-engine.ts`, and many others.
|
||||
- Impact: Stale or malformed config JSON stored in the database can cause silent runtime failures deep in business logic (e.g., missing criteria, wrong field names) without a clear validation error.
|
||||
- Fix approach: Extract a typed `parseRoundConfig(roundType, configJson)` utility that returns a typed config or throws a `TRPCError`. Replace bare `as Record<string, unknown>` casts with this utility at query boundaries.
|
||||
|
||||
---
|
||||
|
||||
## Known Bugs
|
||||
|
||||
**Tag Rename Performs N+1 Database Writes:**
|
||||
- Symptoms: Renaming a tag iterates over every user and every project that has the tag, issuing one `UPDATE` per record.
|
||||
- Files: `src/server/routers/tag.ts` lines 361–389 and 421–438
|
||||
- Trigger: Admin renames any tag that is widely used.
|
||||
- Workaround: None. Will time out for large datasets.
|
||||
- Fix approach: Use a raw SQL `UPDATE ... SET tags = array_replace(tags, $old, $new)` or a Prisma `$executeRaw` to perform the rename in a single query.
|
||||
|
||||
**Jury Deliberation Vote: `juryMemberId` Is Hardcoded Empty String:**
|
||||
- Symptoms: Votes submitted via the jury deliberation page will have `juryMemberId: ''`.
|
||||
- Files: `src/app/(jury)/jury/competitions/deliberation/[sessionId]/page.tsx` line 34
|
||||
- Trigger: Any jury member visits a deliberation session and submits a vote.
|
||||
- Workaround: None — the vote will silently pass or fail depending on Prisma validation.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**IP Header Spoofing on Rate Limiter:**
|
||||
- Risk: All rate limiters extract the client IP from `x-forwarded-for` without validating that the header originates from a trusted proxy. A client can set this header to any value, bypassing per-IP rate limits.
|
||||
- Files: `src/app/api/trpc/[trpc]/route.ts` lines 13–18, `src/app/api/auth/[...nextauth]/route.ts` lines 7–10, `src/app/api/email/change-password/route.ts` line 36, `src/app/api/email/verify-credentials/route.ts` line 20, `src/server/context.ts` line 13.
|
||||
- Current mitigation: Nginx passes `X-Forwarded-For` from upstream; in single-proxy deployment this reduces (but does not eliminate) risk.
|
||||
- Recommendations: Pin IP extraction to `req.headers.get('x-real-ip')` set by Nginx only, or validate the forwarded-for chain against a trusted proxy list.
|
||||
|
||||
**Cron Secret Compared with `!==` (Non–Timing-Safe):**
|
||||
- Risk: String equality check `cronSecret !== process.env.CRON_SECRET` is vulnerable to timing side-channel attacks on the secret value.
|
||||
- Files: `src/app/api/cron/audit-cleanup/route.ts`, `src/app/api/cron/digest/route.ts`, `src/app/api/cron/draft-cleanup/route.ts`, `src/app/api/cron/reminders/route.ts` — all at line 8.
|
||||
- Current mitigation: Cron endpoints are not user-facing and rate limited at Nginx level.
|
||||
- Recommendations: Replace with `timingSafeEqual(Buffer.from(cronSecret), Buffer.from(process.env.CRON_SECRET))` — the same approach already used in `src/lib/storage/local-provider.ts` line 75.
|
||||
|
||||
**No Content-Security-Policy Header:**
|
||||
- Risk: No CSP is set in `next.config.ts` or via middleware headers. If an XSS vector exists, there is no second line of defence to limit script execution.
|
||||
- Files: `next.config.ts` (missing `headers()` function), `docker/nginx/mopc-platform.conf` (missing CSP directive).
|
||||
- Current mitigation: Nginx sets `X-Frame-Options`, `X-Content-Type-Options`, and `X-XSS-Protection`, but these are legacy headers. No HSTS header is configured in Nginx either (only set post-certbot).
|
||||
- Recommendations: Add `Content-Security-Policy` and `Strict-Transport-Security` via the Next.js `headers()` config function.
|
||||
|
||||
**MinIO Fallback Credentials Hardcoded:**
|
||||
- Risk: When `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` are not set in non-production environments, the client defaults to `minioadmin`/`minioadmin`.
|
||||
- Files: `src/lib/minio.ts` lines 28–29
|
||||
- Current mitigation: Production throws an error if credentials are missing (line 20–22). The fallback only applies in development.
|
||||
- Recommendations: Remove the hardcoded fallback entirely; require credentials in all environments to prevent accidental exposure of a non-production MinIO instance.
|
||||
|
||||
**`NEXT_PUBLIC_MINIO_ENDPOINT` Undefined in Production:**
|
||||
- Risk: Two admin learning pages read `process.env.NEXT_PUBLIC_MINIO_ENDPOINT` at runtime. This variable is not defined in `docker-compose.yml` and has no `NEXT_PUBLIC_` entry. Next.js requires public env vars to be present at build time; at runtime this will always resolve to the fallback `http://localhost:9000`, making file previews broken in production.
|
||||
- Files: `src/app/(admin)/admin/learning/new/page.tsx` line 112, `src/app/(admin)/admin/learning/[id]/page.tsx` line 165.
|
||||
- Fix approach: Add `NEXT_PUBLIC_MINIO_ENDPOINT` to `docker-compose.yml` env section, or use the server-side `MINIO_PUBLIC_ENDPOINT` via a tRPC query rather than client-side env.
|
||||
|
||||
---
|
||||
|
||||
## Performance Bottlenecks
|
||||
|
||||
**Unbounded `findMany` Queries in Analytics Router:**
|
||||
- Problem: `src/server/routers/analytics.ts` contains approximately 25 `findMany` calls with no `take` limit. With large competitions (hundreds of projects, thousands of evaluations) these will perform full table scans filtered only by `roundId` or `competitionId`.
|
||||
- Files: `src/server/routers/analytics.ts` — queries at lines 38, 80, 265, 421, 539, 582, 649, 749, 794, 1207, 1227, 1346, 1406, 1481, 1498, 1654, 1677, 1700.
|
||||
- Cause: Analytics queries are built for correctness, not scale. They load entire result sets into Node.js memory before aggregation.
|
||||
- Improvement path: Move aggregation into the database using Prisma `groupBy` and `_count`/`_avg` aggregations, or write `$queryRaw` SQL for complex analytics. Add pagination or date-range limits to the procedure inputs.
|
||||
|
||||
**Tag Rename N+1 Pattern:**
|
||||
- Problem: Renaming a tag issues one DB write per entity (user or project) that carries the tag rather than a single bulk update.
|
||||
- Files: `src/server/routers/tag.ts` lines 355–390
|
||||
- Cause: Prisma does not support `array_replace` natively; the current implementation works around this with a loop.
|
||||
- Improvement path: Use `prisma.$executeRaw` with PostgreSQL's `array_replace` function.
|
||||
|
||||
**`assignment.ts` Router is 3,337 Lines:**
|
||||
- Problem: The assignment router is the single largest file and handles jury assignments, AI assignment, manual overrides, transfer, COI, and coverage checks in one module.
|
||||
- Files: `src/server/routers/assignment.ts`
|
||||
- Cause: Organic growth without module splitting.
|
||||
- Improvement path: Extract into separate files: `src/server/routers/assignment/manual.ts`, `assignment/ai.ts`, `assignment/coverage.ts`. This will also improve IDE performance and test isolation.
|
||||
|
||||
---
|
||||
|
||||
## Fragile Areas
|
||||
|
||||
**Round Engine: COMPLETED State Has No Guards on Re-Entry:**
|
||||
- Files: `src/server/services/round-engine.ts` lines 57–64
|
||||
- Why fragile: `COMPLETED` is defined as a terminal state (empty transitions array). However, there is no server-side guard preventing a direct Prisma update to a `COMPLETED` project state outside of `transitionProjectState()`. If a bug or data migration bypasses the state machine, projects can end up in unexpected states.
|
||||
- Safe modification: Always transition through `transitionProjectState()`. Any admin data repair scripts should call this function rather than using `prisma.projectRoundState.update` directly.
|
||||
- Test coverage: No unit tests for project state transitions. Only `tests/unit/assignment-policy.test.ts` exists, covering a different subsystem.
|
||||
|
||||
**`Prisma.$transaction` Typed as `any`:**
|
||||
- Files: `src/server/services/round-engine.ts` line 129, `src/server/services/result-lock.ts` lines 87, 169, `src/server/services/mentor-workspace.ts` lines 39, 254, and 50+ other locations.
|
||||
- Why fragile: `tx: any` disables all type-checking inside transaction callbacks. A mistakenly called method on `tx` (e.g., `tx.round.delete` instead of `tx.round.update`) will compile successfully but may cause silent data corruption.
|
||||
- Safe modification: Type the callback as `(tx: Parameters<Parameters<typeof prisma.$transaction>[0]>[0]) => ...` or define a `TransactionalPrisma` type alias. The `PrismaClient | any` union also defeats the purpose of typing.
|
||||
|
||||
**`email.ts` is 2,175 Lines:**
|
||||
- Files: `src/lib/email.ts`
|
||||
- Why fragile: All email templates, SMTP transport logic, and dynamic config loading are in one file. Adding a new email type requires navigating 2,000+ lines, and any change to transport setup affects all templates.
|
||||
- Safe modification: Extract individual email functions into `src/lib/email/` subdirectory with one file per template type. Keep shared transport logic in `src/lib/email/transport.ts`.
|
||||
- Test coverage: No tests for email sending. Email errors are caught and logged but not surfaced to callers consistently.
|
||||
|
||||
**`admin/rounds/[roundId]/page.tsx` is 2,398 Lines:**
|
||||
- Files: `src/app/(admin)/admin/rounds/[roundId]/page.tsx`
|
||||
- Why fragile: The entire round management UI (config, assignments, filtering, deliberation controls) lives in a single client component. State from one section can accidentally affect another, and the component re-renders on any state change.
|
||||
- Safe modification: Extract tab sections into separate `'use client'` components with scoped state. Consider converting to a tab-based layout with lazy loading.
|
||||
|
||||
**SSE Live Voting Stream Relies on Polling:**
|
||||
- Files: `src/app/api/live-voting/stream/route.ts` lines 184–194
|
||||
- Why fragile: The SSE endpoint polls the database every 2 seconds per connected client. Under live ceremony conditions with many simultaneous audience connections, this can produce significant database load.
|
||||
- Safe modification: Introduce a Redis pub/sub channel that the vote submission path writes to, and have the SSE stream subscribe to the channel rather than polling. Alternatively, implement a debounce on the poll and share the result across all open SSE connections via a singleton broadcaster.
|
||||
|
||||
---
|
||||
|
||||
## Scaling Limits
|
||||
|
||||
**In-Memory State (Rate Limiter, Login Attempts):**
|
||||
- Current capacity: Works correctly for a single Node.js process.
|
||||
- Limit: Breaks under horizontal scaling or after a process restart (all rate limit windows reset).
|
||||
- Scaling path: Replace `src/lib/rate-limit.ts` with a Redis-backed solution. Replace the `failedAttempts` Map in `src/lib/auth.ts` with Redis counters or database fields on the `User` model.
|
||||
|
||||
**SSE Connection Count vs. Database Poll Rate:**
|
||||
- Current capacity: Each SSE client issues 1 database query per 2 seconds.
|
||||
- Limit: At 100 concurrent audience connections, this is 50 queries/second to `liveVotingSession` and related tables during a ceremony.
|
||||
- Scaling path: Shared broadcaster pattern (one database poll, fan-out to all SSE streams) or Redis pub/sub as described above.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies at Risk
|
||||
|
||||
**`next-auth` v5 (Auth.js) — Beta API:**
|
||||
- Risk: Auth.js v5 was in release candidate status at time of integration. The API surface (`authConfig` + `handlers` + `auth`) differs significantly from v4. Upgrading to a stable v5 release may require changes to `src/lib/auth.ts` and `src/lib/auth.config.ts`.
|
||||
- Impact: Session type definitions, adapter interfaces, and middleware patterns may change.
|
||||
- Migration plan: Monitor the Auth.js v5 stable release. Changes are likely limited to `src/lib/auth.ts` and `src/types/next-auth.d.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Missing Critical Features
|
||||
|
||||
**No Database Backup Configuration:**
|
||||
- Problem: `docker-compose.yml` has no scheduled backup service or volume snapshot configuration for the PostgreSQL container.
|
||||
- Blocks: Point-in-time recovery after data loss or accidental deletion.
|
||||
- Recommendation: Add a sidecar backup service (e.g., `prodrigestivill/postgres-backup-local`) or configure WAL archiving to MinIO.
|
||||
|
||||
**No Error Monitoring / Observability:**
|
||||
- Problem: There is no Sentry, Datadog, or equivalent error monitoring integration. Application errors are only logged to stdout via `console.error`. In production, these are only visible if the Docker logs are actively monitored.
|
||||
- Files: No integration found in `src/instrumentation.ts` or anywhere else.
|
||||
- Blocks: Proactive detection of runtime errors, AI service failures, and payment/submission edge cases.
|
||||
- Recommendation: Add Sentry (`@sentry/nextjs`) in `src/instrumentation.ts` — Next.js has native support for this. Filter out expected errors (e.g., `TRPCError` with `NOT_FOUND`) to reduce noise.
|
||||
|
||||
**No Automated Tests for Core Business Logic (Round Engine, Evaluation, Filtering):**
|
||||
- Problem: Only one test file exists: `tests/unit/assignment-policy.test.ts`. The round engine state machine (`src/server/services/round-engine.ts`), evaluation submission flow (`src/server/routers/evaluation.ts`), and AI filtering pipeline (`src/server/services/ai-filtering.ts`) have no automated tests.
|
||||
- Blocks: Confident refactoring of the state machine, advance-criterion logic, and scoring.
|
||||
- Recommendation: Add unit tests for `activateRound`, `closeRound`, `transitionProjectState` (happy path + guard failures), and `submitEvaluation` (COI check, advance criterion logic, score validation).
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
**Round Engine State Machine:**
|
||||
- What's not tested: All `activateRound`, `closeRound`, `archiveRound`, `transitionProjectState` transitions, including guard conditions (e.g., activating an ARCHIVED round, transitioning a COMPLETED project).
|
||||
- Files: `src/server/services/round-engine.ts`
|
||||
- Risk: A regression in state transition guards could allow data corruption (e.g., re-activating a closed round, double-passing a project).
|
||||
- Priority: High
|
||||
|
||||
**Evaluation Submission (advance criterion, COI, scoring):**
|
||||
- What's not tested: `submitEvaluation` mutation — specifically the advance criterion auto-transition logic (lines 1637–1646 of `src/server/routers/evaluation.ts`), COI auto-reassignment on `declareCOI`, and `upsertForm` criterion validation.
|
||||
- Files: `src/server/routers/evaluation.ts`
|
||||
- Risk: Regression in advance criterion will silently skip project advancement. COI declaration failures are caught and logged but untested.
|
||||
- Priority: High
|
||||
|
||||
**AI Anonymization:**
|
||||
- What's not tested: `sanitizeText`, `anonymizeProject`, `validateNoPersonalInfo` in `src/server/services/anonymization.ts`. These are GDPR-critical functions.
|
||||
- Files: `src/server/services/anonymization.ts`
|
||||
- Risk: A PII leak in AI calls would violate GDPR without detection.
|
||||
- Priority: High
|
||||
|
||||
**Assignment Policy Execution:**
|
||||
- What's not tested: End-to-end `executeAssignment` flow in `src/server/services/round-assignment.ts` — specifically the COI filtering, geo-diversity penalty, familiarity bonus, and under-coverage gap-fill.
|
||||
- Files: `src/server/services/round-assignment.ts`, `src/server/services/smart-assignment.ts`
|
||||
- Risk: Silent over- or under-assignment when constraints interact.
|
||||
- Priority: Medium
|
||||
|
||||
---
|
||||
|
||||
*Concerns audit: 2026-02-26*
|
||||
267
.planning/codebase/CONVENTIONS.md
Normal file
267
.planning/codebase/CONVENTIONS.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# Coding Conventions
|
||||
|
||||
**Analysis Date:** 2026-02-26
|
||||
|
||||
## Naming Patterns
|
||||
|
||||
**Files:**
|
||||
- `kebab-case` for all TypeScript/TSX source files: `round-engine.ts`, `filtering-dashboard.tsx`, `ai-errors.ts`
|
||||
- `kebab-case` for route directories under `src/app/`: `(admin)/admin/juries/`, `[roundId]/`
|
||||
- Exception: Next.js reserved names remain as-is: `page.tsx`, `layout.tsx`
|
||||
|
||||
**Components:**
|
||||
- `PascalCase` for component functions: `FilteringDashboard`, `EmptyState`, `JuriesPage`
|
||||
- Page components follow the suffix pattern `XxxPage`: `JuriesPage`, `RoundDetailPage`, `AuditLogPage`
|
||||
- Sub-components within a file follow `XxxSection`, `XxxCard`, `XxxDialog`
|
||||
|
||||
**Functions:**
|
||||
- `camelCase` for all functions and methods: `activateRound`, `resolveEffectiveCap`, `createTestUser`
|
||||
- Service functions are named by operation + domain: `activateRound`, `closeRound`, `batchTransitionProjects`
|
||||
- Boolean functions prefixed with `is`, `has`, `should`, `can`: `shouldRetry`, `isParseError`, `shouldLog`
|
||||
|
||||
**Variables:**
|
||||
- `camelCase` for all local variables
|
||||
- `SCREAMING_SNAKE_CASE` for module-level constants: `BATCH_SIZE = 50`, `SYSTEM_DEFAULT_CAP`, `VALID_ROUND_TRANSITIONS`
|
||||
- Enum-like lookup objects in `SCREAMING_SNAKE_CASE`: `ERROR_PATTERNS`, `LOG_LEVELS`
|
||||
|
||||
**Types:**
|
||||
- `type` keyword preferred over `interface` per CLAUDE.md — but both exist in practice
|
||||
- `interface` is used for component props in some files (e.g., `ButtonProps`, `EmptyStateProps`), `type` used in others
|
||||
- Prisma-derived types use `type` aliases with `z.infer<typeof Schema>`: `type EvaluationConfig = z.infer<typeof EvaluationConfigSchema>`
|
||||
- Prop types: `type XxxProps = { ... }` (preferred in most components), `interface XxxProps { ... }` (used in deliberation, some UI components)
|
||||
- Export complex input types from `src/types/competition.ts`: `CreateCompetitionInput`, `UpdateRoundInput`
|
||||
|
||||
## Code Style
|
||||
|
||||
**Formatting:**
|
||||
- Prettier 3.4.2 with `prettier-plugin-tailwindcss` for class sorting
|
||||
- No `.prettierrc` found — uses Prettier defaults: 2-space indent, double quotes, trailing commas (ES5), 80-char print width
|
||||
- Single quotes confirmed absent in codebase: all string literals use double quotes
|
||||
- Tailwind classes sorted automatically by the plugin on format
|
||||
|
||||
**Linting:**
|
||||
- ESLint 9.x with `eslint-config-next` (Next.js configuration)
|
||||
- Run via `npm run lint` (calls `next lint`)
|
||||
- No custom rules file found — relies on Next.js default rules
|
||||
|
||||
**TypeScript:**
|
||||
- Strict mode enabled in `tsconfig.json` (`"strict": true`)
|
||||
- `noEmit: true` — TypeScript used for type checking only, not transpilation
|
||||
- Target: ES2022
|
||||
- Module resolution: `bundler` (Next.js Turbopack compatible)
|
||||
- Path alias: `@/*` maps to `./src/*`
|
||||
|
||||
## Import Organization
|
||||
|
||||
**Order (observed in client components):**
|
||||
1. `'use client'` directive (if needed)
|
||||
2. React/framework hooks: `import { useState, useEffect } from 'react'`
|
||||
3. Next.js imports: `import { useRouter } from 'next/navigation'`, `import Link from 'next/link'`
|
||||
4. tRPC client: `import { trpc } from '@/lib/trpc/client'`
|
||||
5. UI libraries: shadcn/ui components `import { Button } from '@/components/ui/button'`
|
||||
6. Icons: `import { Loader2, Save } from 'lucide-react'`
|
||||
7. Internal utilities/helpers: `import { cn } from '@/lib/utils'`
|
||||
8. Internal components: `import { FilteringDashboard } from '@/components/admin/round/...'`
|
||||
9. Types: `import type { EvaluationConfig } from '@/types/competition-configs'`
|
||||
|
||||
**Order (observed in server/service files):**
|
||||
1. `import { z } from 'zod'` (validation)
|
||||
2. `import { TRPCError } from '@trpc/server'` (errors)
|
||||
3. tRPC router/procedures: `import { router, adminProcedure } from '../trpc'`
|
||||
4. Internal services/utilities: `import { logAudit } from '@/server/utils/audit'`
|
||||
5. Type imports at end: `import type { PrismaClient } from '@prisma/client'`
|
||||
|
||||
**Path Aliases:**
|
||||
- Use `@/` prefix for all internal imports: `@/components/ui/button`, `@/server/services/round-engine`
|
||||
- Never use relative `../../` paths for cross-directory imports
|
||||
- Relative paths (`./`, `../`) only within the same directory level
|
||||
|
||||
## React Component Conventions
|
||||
|
||||
**Server vs Client Components:**
|
||||
- Default to **Server Components** — do not add `'use client'` unless needed
|
||||
- Layouts (`layout.tsx`) are server components: they call `await requireRole()`, fetch data directly from Prisma, and pass to client wrappers
|
||||
- Pages that use tRPC hooks, `useState`, or browser APIs must be `'use client'`
|
||||
- The pattern: server layout fetches session/editions → passes to client wrapper → client components handle interactive state
|
||||
|
||||
**Client Component Pattern:**
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type XxxProps = {
|
||||
competitionId: string
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export default function XxxPage() { ... }
|
||||
// Sub-components in same file as local functions (not exported)
|
||||
function XxxSection({ competition }: XxxSectionProps) { ... }
|
||||
```
|
||||
|
||||
**Server Layout Pattern:**
|
||||
```tsx
|
||||
import { requireRole } from '@/lib/auth-redirect'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await requireRole('SUPER_ADMIN', 'PROGRAM_ADMIN')
|
||||
const data = await prisma.program.findMany({ ... })
|
||||
return <ClientWrapper data={data}>{children}</ClientWrapper>
|
||||
}
|
||||
```
|
||||
|
||||
**Props with Params (Next.js 15):**
|
||||
```tsx
|
||||
type PageProps = {
|
||||
params: Promise<{ roundId: string; projectId: string }>
|
||||
}
|
||||
export default function Page({ params: paramsPromise }: PageProps) {
|
||||
const params = use(paramsPromise) // React.use() for async params
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## tRPC Router Conventions
|
||||
|
||||
**Procedure Selection:**
|
||||
- `adminProcedure` for CRUD on competition/round/jury entities
|
||||
- `protectedProcedure` for shared read access across roles
|
||||
- `juryProcedure` for jury-only operations
|
||||
- Role checks within procedure body use `userHasRole(ctx.user, 'ROLE')` for per-entity authorization
|
||||
|
||||
**Input Validation:**
|
||||
- All inputs validated with Zod `.input(z.object({ ... }))`
|
||||
- Use `.min()`, `.max()`, `.regex()` for strings
|
||||
- Use `.int().positive()` for ID/count integers
|
||||
- Use `.optional().nullable()` for optional fields with null support
|
||||
- Inline schema definition (not shared schema objects) per router
|
||||
|
||||
**Mutation Pattern:**
|
||||
```typescript
|
||||
create: adminProcedure
|
||||
.input(z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// 1. Check business rules (conflict, not found)
|
||||
const existing = await ctx.prisma.xxx.findUnique({ where: { slug: input.slug } })
|
||||
if (existing) {
|
||||
throw new TRPCError({ code: 'CONFLICT', message: '...' })
|
||||
}
|
||||
// 2. Perform operation
|
||||
const result = await ctx.prisma.xxx.create({ data: input })
|
||||
// 3. Audit log (for mutations)
|
||||
await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', ... })
|
||||
// 4. Return result
|
||||
return result
|
||||
})
|
||||
```
|
||||
|
||||
**Error Codes:**
|
||||
- `NOT_FOUND` — entity doesn't exist
|
||||
- `CONFLICT` — duplicate slug/unique constraint
|
||||
- `FORBIDDEN` — user lacks permission for specific entity
|
||||
- `UNAUTHORIZED` — not logged in (handled by middleware)
|
||||
- `BAD_REQUEST` — invalid business state (e.g., no active form)
|
||||
|
||||
## Error Handling
|
||||
|
||||
**tRPC Routers (user-facing errors):**
|
||||
- Always throw `TRPCError` with `{ code, message }` — never plain `throw new Error()`
|
||||
- Message should be human-readable: `'Competition not found'`, not `'Competition_NOT_FOUND'`
|
||||
- Use `findUniqueOrThrow` / `findFirstOrThrow` for implicit 404s on required relations
|
||||
|
||||
**Service Layer (internal errors):**
|
||||
- Services return result objects `{ success: boolean, errors?: string[] }` — they do NOT throw
|
||||
- Callers check `result.success` before proceeding
|
||||
- Error message pattern: `error instanceof Error ? error.message : 'Unknown error during X'`
|
||||
- Non-fatal side effects (notifications, emails) are wrapped in separate try/catch and logged but never propagate
|
||||
|
||||
**AI Services:**
|
||||
- Use `classifyAIError()` from `src/server/services/ai-errors.ts` for all OpenAI errors
|
||||
- Wrap AI calls with `withAIErrorHandling(fn, fallback)` for unified error + fallback handling
|
||||
- All AI errors logged with service tag: `console.error('[AI Assignment] failed:', ...)`
|
||||
|
||||
**Client-Side:**
|
||||
- Mutation errors displayed via `toast.error(err.message)` from Sonner
|
||||
- Success via `toast.success('...')` with query invalidation: `utils.xxx.yyy.invalidate()`
|
||||
- Loading states tracked via `mutation.isPending` and `query.isLoading`
|
||||
|
||||
## Logging
|
||||
|
||||
**Framework:** Custom structured logger at `src/lib/logger.ts`
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
import { logger } from '@/lib/logger'
|
||||
logger.info('RoundEngine', 'Round activated', { roundId, competitionId })
|
||||
logger.error('Storage', 'Upload failed', error)
|
||||
logger.warn('Filtering', 'Non-fatal error in document check', retroError)
|
||||
```
|
||||
|
||||
**Tag Convention:** `[ServiceName]` prefix in brackets — `'RoundEngine'`, `'AIFiltering'`, `'Storage'`
|
||||
|
||||
**Direct console usage** (still common in routers, not yet migrated to logger):
|
||||
- Tagged format: `console.log('[FeatureName] message', data)`
|
||||
- Always uses bracket tag: `'[Filtering]'`, `'[Assignment]'`, `'[COI]'`
|
||||
|
||||
**Log Level Defaults:**
|
||||
- Development: `debug` (all logs)
|
||||
- Production: `warn` (warns and errors only)
|
||||
- Override via `LOG_LEVEL` env var
|
||||
|
||||
## Comments
|
||||
|
||||
**When to Comment:**
|
||||
- All exported service functions get a JSDoc-style comment explaining purpose and invariants
|
||||
- Inline comments for non-obvious business logic: `// re-include after rejection`, `// Bounded to admin max`
|
||||
- Section header separators in large files using box-drawing chars: `// ─── Section Name ──────`
|
||||
- `// =====` separators for major logical sections in long files
|
||||
|
||||
**JSDoc/TSDoc:**
|
||||
- Used on exported functions in services and utilities
|
||||
- Standard `/**` block with plain description — no `@param`/`@returns` annotations in most code
|
||||
- Routers use `/** procedure description */` above each procedure for documentation
|
||||
|
||||
**TODO Comments:**
|
||||
- Present but sparse — only 3 found in entire codebase (deliberation page, award router)
|
||||
- Format: `// TODO: description`
|
||||
|
||||
## Function Design
|
||||
|
||||
**Size:** Service functions can be long (100-200+ lines) for complex state machines; router procedures typically 20-60 lines
|
||||
|
||||
**Parameters:**
|
||||
- Services accept `(entityId: string, actorId: string, prisma: PrismaClient)` — explicit prisma injection for testability
|
||||
- Router procedures destructure `{ ctx, input }` — never access `ctx.prisma` outside routers
|
||||
|
||||
**Return Values:**
|
||||
- Queries return data directly or throw `TRPCError`
|
||||
- Mutations return the created/updated record
|
||||
- Services return typed result objects: `RoundTransitionResult`, `BatchProjectTransitionResult`
|
||||
- Async service results always typed: `Promise<RoundTransitionResult>`
|
||||
|
||||
## Module Design
|
||||
|
||||
**Exports:**
|
||||
- Single responsibility: each service file exports one domain's functions
|
||||
- Named exports preferred over default exports for services and utilities
|
||||
- Default exports used only for React components (`export default function Page()`)
|
||||
|
||||
**Barrel Files:**
|
||||
- Used sparingly — only for chart components (`src/components/charts/index.ts`), form steps, and storage utilities
|
||||
- Most imports are direct path imports: `import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'`
|
||||
|
||||
**Prisma Transactions:**
|
||||
- Use `ctx.prisma.$transaction(async (tx) => { ... })` for multi-step mutations
|
||||
- Always pass `tx` (transaction client) through nested operations
|
||||
- Sequential array syntax `ctx.prisma.$transaction([op1, op2])` for simple atomic batches
|
||||
|
||||
---
|
||||
|
||||
*Convention analysis: 2026-02-26*
|
||||
213
.planning/codebase/INTEGRATIONS.md
Normal file
213
.planning/codebase/INTEGRATIONS.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# External Integrations
|
||||
|
||||
**Analysis Date:** 2026-02-26
|
||||
|
||||
## APIs & External Services
|
||||
|
||||
**AI Providers (configurable via SystemSettings DB table):**
|
||||
- OpenAI - AI filtering, jury assignment suggestions, evaluation summaries, project tagging, award eligibility, shortlist recommendations
|
||||
- SDK: `openai` ^6.16.0
|
||||
- Auth: `OPENAI_API_KEY` env var or `openai_api_key` SystemSetting
|
||||
- Base URL: `OPENAI_BASE_URL` env var or `openai_base_url` SystemSetting (for OpenAI-compatible proxies)
|
||||
- Model: `OPENAI_MODEL` env var or `ai_model` SystemSetting (default: `gpt-4o`)
|
||||
- Client: `src/lib/openai.ts` - lazy singleton, reset via `resetOpenAIClient()`
|
||||
|
||||
- Anthropic Claude - Alternative AI provider, same AI feature set as OpenAI
|
||||
- SDK: `@anthropic-ai/sdk` ^0.78.0
|
||||
- Auth: `ANTHROPIC_API_KEY` env var or `anthropic_api_key` SystemSetting
|
||||
- Adapter: `src/lib/openai.ts` wraps Anthropic SDK behind OpenAI `.chat.completions.create()` interface
|
||||
- Supported models: `claude-opus-4-5-20250514`, `claude-sonnet-4-5-20250514`, `claude-haiku-3-5-20241022`, `claude-opus-4-20250514`, `claude-sonnet-4-20250514`
|
||||
- Extended thinking enabled automatically for Opus models
|
||||
|
||||
- LiteLLM proxy - Third option for ChatGPT subscription routing (no real API key needed)
|
||||
- Config: `ai_provider = 'litellm'` in SystemSettings + `openai_base_url` pointing to proxy
|
||||
- Token limit fields stripped for `chatgpt/*` model prefix
|
||||
|
||||
- AI provider selection: `getConfiguredProvider()` in `src/lib/openai.ts` reads `ai_provider` SystemSetting; defaults to `openai`
|
||||
|
||||
**All AI data is anonymized before sending** via `src/server/services/anonymization.ts`
|
||||
|
||||
**Notion:**
|
||||
- Used for project import (alternative to CSV)
|
||||
- SDK: `@notionhq/client` ^2.3.0
|
||||
- Auth: API key stored in SystemSettings (`notion_api_key`), per-import flow
|
||||
- Client: `src/lib/notion.ts` - `createNotionClient(apiKey)` per-request (not singleton)
|
||||
- Router: `src/server/routers/notion-import.ts`
|
||||
|
||||
**Typeform:**
|
||||
- Used for project import from form responses
|
||||
- Auth: API key stored in SystemSettings per-import
|
||||
- Client: `src/lib/typeform.ts` - plain fetch against `https://api.typeform.com`, no SDK
|
||||
- Router: `src/server/routers/typeform-import.ts`
|
||||
|
||||
**WhatsApp (optional, configurable):**
|
||||
- Two provider options: Meta WhatsApp Business Cloud API or Twilio WhatsApp
|
||||
- Meta provider: `src/lib/whatsapp/meta-provider.ts`
|
||||
- Twilio provider: `src/lib/whatsapp/twilio-provider.ts`
|
||||
- Abstraction: `src/lib/whatsapp/index.ts` - `getWhatsAppProvider()` reads `whatsapp_provider` SystemSetting
|
||||
- Auth: API keys stored in SystemSettings (`whatsapp_enabled`, `whatsapp_provider`, provider-specific keys)
|
||||
- Used for: notification delivery (alternative to email)
|
||||
|
||||
## Data Storage
|
||||
|
||||
**Databases:**
|
||||
- PostgreSQL 16 (primary datastore)
|
||||
- Connection: `DATABASE_URL` env var (e.g., `postgresql://mopc:${password}@postgres:5432/mopc`)
|
||||
- Client: Prisma 6 ORM, `src/lib/prisma.ts` singleton with connection pool (limit=20, timeout=10)
|
||||
- Schema: `prisma/schema.prisma` (~95KB, ~100+ models)
|
||||
- Migrations: `prisma/migrations/` directory, deployed via `prisma migrate deploy` on startup
|
||||
- Test DB: `DATABASE_URL_TEST` env var (falls back to `DATABASE_URL` in test setup)
|
||||
|
||||
**File Storage:**
|
||||
- MinIO (S3-compatible, self-hosted on VPS)
|
||||
- Internal endpoint: `MINIO_ENDPOINT` env var (server-to-server)
|
||||
- Public endpoint: `MINIO_PUBLIC_ENDPOINT` env var (browser-accessible pre-signed URLs)
|
||||
- Auth: `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`
|
||||
- Bucket: `MINIO_BUCKET` (default: `mopc-files`)
|
||||
- Client: `src/lib/minio.ts` - lazy singleton via Proxy, `getMinioClient()`
|
||||
- Access pattern: pre-signed URLs only (15-minute expiry by default), never direct public bucket
|
||||
- Key structure: `{ProjectName}/{RoundName}/{timestamp}-{fileName}`
|
||||
- File types stored: EXEC_SUMMARY, PRESENTATION, VIDEO, BUSINESS_PLAN, VIDEO_PITCH, SUPPORTING_DOC, OTHER
|
||||
|
||||
**Caching:**
|
||||
- None (in-memory rate limiter in `src/lib/rate-limit.ts`, not a caching layer)
|
||||
- Note: rate limiter is in-memory only — not suitable for multi-instance deployments
|
||||
|
||||
## Authentication & Identity
|
||||
|
||||
**Auth Provider: NextAuth v5 (self-hosted)**
|
||||
- Implementation: `src/lib/auth.ts` + `src/lib/auth.config.ts`
|
||||
- Adapter: `@auth/prisma-adapter` (stores sessions/tokens in PostgreSQL)
|
||||
- Strategy: JWT sessions (24-hour default, configurable via `SESSION_MAX_AGE`)
|
||||
- Session includes: `user.id`, `user.email`, `user.name`, `user.role`, `user.roles[]`, `user.mustSetPassword`
|
||||
|
||||
**Auth Providers:**
|
||||
1. Email (Magic Links)
|
||||
- NextAuth `EmailProvider` — magic link sent via Nodemailer
|
||||
- Link expiry: 15 minutes (`MAGIC_LINK_EXPIRY` env var or default 900s)
|
||||
- Custom send function: `sendMagicLinkEmail()` in `src/lib/email.ts`
|
||||
2. Credentials (Password + Invite Token)
|
||||
- Email/password with bcryptjs hashing (`src/lib/password.ts`)
|
||||
- Invite token flow: one-time token clears on first use, sets `mustSetPassword: true`
|
||||
- Failed login tracking: 5-attempt lockout, 15-minute duration (in-memory, not persistent)
|
||||
- `mustSetPassword` flag forces redirect to `/set-password` before any other page
|
||||
|
||||
**Role System:**
|
||||
- User model has `role` (primary, legacy scalar) and `roles` (array, multi-role)
|
||||
- `userHasRole()` helper in `src/server/trpc.ts` checks `roles[]` with `[role]` fallback
|
||||
- 8 roles: `SUPER_ADMIN`, `PROGRAM_ADMIN`, `JURY_MEMBER`, `MENTOR`, `OBSERVER`, `APPLICANT`, `AWARD_MASTER`, `AUDIENCE`
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
**Error Tracking:**
|
||||
- Not detected (no Sentry, Datadog, or similar third-party service)
|
||||
|
||||
**Logs:**
|
||||
- Custom structured logger: `src/lib/logger.ts`
|
||||
- Tagged format: `{timestamp} [LEVEL] [Tag] message data`
|
||||
- Levels: debug, info, warn, error
|
||||
- Default: `debug` in development, `warn` in production
|
||||
- Configurable via `LOG_LEVEL` env var
|
||||
- All output to `console.*` (stdout/stderr)
|
||||
|
||||
**Audit Logging:**
|
||||
- All auth events and admin mutations logged to `AuditLog` DB table
|
||||
- `src/server/utils/audit.ts` - `logAudit()` helper
|
||||
- Events tracked: LOGIN_SUCCESS, LOGIN_FAILED, INVITATION_ACCEPTED, all CRUD operations on major entities
|
||||
- Cron for cleanup: `src/app/api/cron/audit-cleanup/`
|
||||
|
||||
**Application Metrics:**
|
||||
- Health check endpoint: `GET /api/health` (used by Docker healthcheck)
|
||||
|
||||
## CI/CD & Deployment
|
||||
|
||||
**Hosting:**
|
||||
- Self-hosted VPS with Docker
|
||||
- Nginx reverse proxy with SSL (external to compose stack)
|
||||
- Domain: `monaco-opc.com`
|
||||
|
||||
**CI Pipeline:**
|
||||
- Gitea Actions (self-hosted Gitea at `code.monaco-opc.com/MOPC/MOPC-Portal`)
|
||||
- Pipeline builds Docker image and pushes to private container registry
|
||||
- `REGISTRY_URL` env var configures the registry in `docker/docker-compose.yml`
|
||||
|
||||
**Docker Setup:**
|
||||
- Production: `docker/docker-compose.yml` — app + postgres services
|
||||
- Dev: `docker/docker-compose.dev.yml` — dev stack variant
|
||||
- App image: standalone Next.js build (`output: 'standalone'`)
|
||||
- Entrypoint: `docker/docker-entrypoint.sh` — migrations → generate → seed → start
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**Required env vars:**
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `NEXTAUTH_URL` - Full URL of the app (e.g., `https://monaco-opc.com`)
|
||||
- `NEXTAUTH_SECRET` / `AUTH_SECRET` - JWT signing secret
|
||||
- `MINIO_ENDPOINT` - MinIO server URL (internal)
|
||||
- `MINIO_ACCESS_KEY` - MinIO access key
|
||||
- `MINIO_SECRET_KEY` - MinIO secret key
|
||||
- `MINIO_BUCKET` - MinIO bucket name
|
||||
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` - SMTP credentials
|
||||
- `EMAIL_FROM` - Sender address
|
||||
- `CRON_SECRET` - Shared secret for cron endpoint authentication
|
||||
|
||||
**Optional env vars:**
|
||||
- `MINIO_PUBLIC_ENDPOINT` - Public-facing MinIO URL for pre-signed URLs
|
||||
- `OPENAI_API_KEY` - OpenAI API key (also in SystemSettings)
|
||||
- `OPENAI_MODEL` - Default AI model (default: `gpt-4o`)
|
||||
- `OPENAI_BASE_URL` - Custom base URL for OpenAI-compatible providers
|
||||
- `ANTHROPIC_API_KEY` - Anthropic Claude API key
|
||||
- `POSTE_API_URL` - Poste.io mail server API URL
|
||||
- `POSTE_ADMIN_EMAIL`, `POSTE_ADMIN_PASSWORD`, `POSTE_MAIL_DOMAIN` - Poste.io admin
|
||||
- `SESSION_MAX_AGE` - JWT session duration in seconds (default: 86400)
|
||||
- `MAX_FILE_SIZE` - Max upload size in bytes (default: 524288000 = 500MB)
|
||||
- `LOG_LEVEL` - Logging verbosity (debug/info/warn/error)
|
||||
- `MAGIC_LINK_EXPIRY` - Magic link lifetime in seconds (default: 900)
|
||||
|
||||
**Secrets location:**
|
||||
- `.env` file at repo root (read by Docker Compose via `env_file: .env`)
|
||||
- Runtime secrets also configurable via `SystemSettings` DB table (admin UI)
|
||||
|
||||
## Webhooks & Callbacks
|
||||
|
||||
**Incoming:**
|
||||
- `/api/auth/[...nextauth]` - NextAuth callback routes (magic link verification, OAuth if added)
|
||||
- No third-party webhook receivers detected
|
||||
|
||||
**Outgoing:**
|
||||
- Configurable webhooks via `Webhook` DB model and `src/server/services/webhook-dispatcher.ts`
|
||||
- Admin-managed via `src/server/routers/webhook.ts` (SUPER_ADMIN only)
|
||||
- Signed with HMAC-SHA256 (`X-Webhook-Signature: sha256={sig}`)
|
||||
- Events dispatched: `evaluation.submitted`, `evaluation.updated`, `project.created`, `project.statusChanged`, `round.activated`, `round.closed`, `assignment.created`, `assignment.completed`, `user.invited`, `user.activated`
|
||||
- Retry logic: configurable max retries per webhook (0–10), retry via cron
|
||||
|
||||
## Real-Time
|
||||
|
||||
**Server-Sent Events (SSE):**
|
||||
- Endpoint: `/api/sse/` - in-app notifications push
|
||||
- Used for: real-time notification delivery to connected clients
|
||||
|
||||
**Live Voting Stream:**
|
||||
- Endpoint: `/api/live-voting/stream/` - SSE stream for live ceremony voting cursor
|
||||
- Service: `src/server/services/live-control.ts`
|
||||
|
||||
## Cron Jobs
|
||||
|
||||
All cron endpoints protected by `CRON_SECRET` header check:
|
||||
- `GET /api/cron/reminders` - Evaluation reminders via `src/server/services/evaluation-reminders.ts`
|
||||
- `GET /api/cron/digest` - Email digests via `src/server/services/email-digest.ts`
|
||||
- `GET /api/cron/draft-cleanup` - Remove stale draft evaluations
|
||||
- `GET /api/cron/audit-cleanup` - Purge old audit log entries
|
||||
|
||||
## Email
|
||||
|
||||
**SMTP Transport:**
|
||||
- Provider: Poste.io (self-hosted mail server, port 587)
|
||||
- Client: Nodemailer 7 via `src/lib/email.ts`
|
||||
- Config priority: SystemSettings DB > env vars
|
||||
- Transporter cached, rebuilt when config hash changes
|
||||
- Error handling: email errors logged but never thrown (non-blocking)
|
||||
|
||||
---
|
||||
|
||||
*Integration audit: 2026-02-26*
|
||||
141
.planning/codebase/STACK.md
Normal file
141
.planning/codebase/STACK.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Technology Stack
|
||||
|
||||
**Analysis Date:** 2026-02-26
|
||||
|
||||
## Languages
|
||||
|
||||
**Primary:**
|
||||
- TypeScript 5.7 - All application code (strict mode, `noEmit`, `ES2022` target)
|
||||
|
||||
**Secondary:**
|
||||
- CSS (Tailwind utility classes only, no custom CSS files)
|
||||
|
||||
## Runtime
|
||||
|
||||
**Environment:**
|
||||
- Node.js >=20.0.0 (engines field in `package.json`)
|
||||
|
||||
**Package Manager:**
|
||||
- npm (standard)
|
||||
- Lockfile: `package-lock.json` present
|
||||
|
||||
## Frameworks
|
||||
|
||||
**Core:**
|
||||
- Next.js 15.1 - App Router, standalone output, Turbopack dev mode
|
||||
- React 19.0 - Server Components by default; `'use client'` only where needed
|
||||
|
||||
**API Layer:**
|
||||
- tRPC 11 (RC build `11.0.0-rc.678`) - End-to-end typed RPC, superjson transformer
|
||||
- `@trpc/server`, `@trpc/client`, `@trpc/react-query` - All at same version
|
||||
|
||||
**Data:**
|
||||
- Prisma 6.19 - ORM and schema-first migrations; binary targets: `native`, `windows`, `linux-musl-openssl-3.0.x`
|
||||
- `@prisma/client` 6.19 - Generated client with connection pool (limit=20, timeout=10)
|
||||
|
||||
**Auth:**
|
||||
- NextAuth v5 (Beta 25) - JWT strategy, 24-hour sessions; Prisma adapter via `@auth/prisma-adapter`
|
||||
|
||||
**Forms & Validation:**
|
||||
- Zod 3.24 - Input validation for all tRPC procedures
|
||||
- React Hook Form 7.54 - Client-side form state; `@hookform/resolvers` for Zod integration
|
||||
|
||||
**UI Components:**
|
||||
- shadcn/ui (configured via `components.json`) - Radix UI primitives styled with Tailwind
|
||||
- Radix UI primitives: alert-dialog, avatar, checkbox, collapsible, dialog, dropdown-menu, label, popover, progress, radio-group, scroll-area, select, separator, slider, slot, switch, tabs, toggle, tooltip (all `^1.x` or `^2.x`)
|
||||
- Tailwind CSS 4.1 - Utility-first, `@tailwindcss/postcss` plugin
|
||||
- Lucide React 0.563 - Icon library (import-optimized via `next.config.ts`)
|
||||
- Framer Motion 11 (`motion` package) - Animation
|
||||
- Tremor 3.18 - Data visualization / chart components
|
||||
- `@blocknote/react`, `@blocknote/core`, `@blocknote/shadcn` 0.46 - Rich text block editor
|
||||
- next-themes 0.4 - Dark/light mode switching
|
||||
- Sonner 2.0 - Toast notifications
|
||||
|
||||
**Testing:**
|
||||
- Vitest 4.0 - Test runner, `fileParallelism: false`, `pool: 'forks'`
|
||||
- `@playwright/test` 1.49 - E2E test runner
|
||||
|
||||
**Build/Dev:**
|
||||
- Turbopack (built into Next.js 15) - Dev server via `next dev --turbopack`
|
||||
- tsx 4.19 - Direct TypeScript execution for scripts and seeds
|
||||
- ESLint 9.17 + `eslint-config-next` 15.1 - Linting
|
||||
- Prettier 3.4 + `prettier-plugin-tailwindcss` 0.7 - Formatting
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
**Critical:**
|
||||
- `superjson` 2.2 - tRPC transformer; enables Date, Map, Set serialization over the wire
|
||||
- `bcryptjs` 3.0 - Password hashing (no native bcrypt — pure JS for portability)
|
||||
- `minio` 8.0 - S3-compatible object storage client
|
||||
- `nodemailer` 7.0 - SMTP email delivery
|
||||
- `openai` 6.16 - OpenAI SDK for AI features
|
||||
- `@anthropic-ai/sdk` 0.78 - Anthropic Claude SDK; wrapped in adapter matching OpenAI interface
|
||||
- `@notionhq/client` 2.3 - Notion API for project import
|
||||
- `csv-parse` 6.1 - CSV import for candidatures seed
|
||||
|
||||
**Infrastructure:**
|
||||
- `date-fns` 4.1 - Date manipulation
|
||||
- `use-debounce` 10.0 - Input debouncing
|
||||
- `@tanstack/react-query` 5.62 - Server state caching (used via tRPC)
|
||||
- `@dnd-kit/core`, `@dnd-kit/sortable` - Drag-and-drop ordering UI
|
||||
- `leaflet` 1.9 + `react-leaflet` 5.0 - Map rendering
|
||||
- `mammoth` 1.11 - DOCX to HTML conversion for file content extraction
|
||||
- `pdf-parse` 2.4, `unpdf` 1.4 - PDF text extraction
|
||||
- `html2canvas` 1.4, `jspdf` 4.1, `jspdf-autotable` 5.0 - PDF export for reports
|
||||
- `franc` 6.2 - Language detection for multilingual project content
|
||||
- `papaparse` 5.4 - CSV parsing in browser
|
||||
- `cmdk` 1.0 - Command palette component
|
||||
- `react-easy-crop` 5.5 - Avatar image cropping
|
||||
- `react-phone-number-input` 3.4 - International phone number input
|
||||
- `react-day-picker` 9.13 - Date picker calendar
|
||||
|
||||
## Configuration
|
||||
|
||||
**TypeScript:**
|
||||
- `src/tsconfig.json`: strict mode, `ES2022` target, path alias `@/*` → `./src/*`, bundler module resolution
|
||||
- Config file: `tsconfig.json`
|
||||
|
||||
**Next.js:**
|
||||
- Config file: `next.config.ts`
|
||||
- `output: 'standalone'` for Docker deployment
|
||||
- `typedRoutes: true` for compile-time route safety
|
||||
- `serverExternalPackages: ['@prisma/client', 'minio']` — not bundled
|
||||
|
||||
**Tailwind:**
|
||||
- Config file: `tailwind.config.ts`
|
||||
- PostCSS via `postcss.config.mjs` + `@tailwindcss/postcss`
|
||||
- Brand palette: Primary Red `#de0f1e`, Dark Blue `#053d57`, White `#fefefe`, Teal `#557f8c`
|
||||
|
||||
**Vitest:**
|
||||
- Config file: `vitest.config.ts`
|
||||
- `environment: 'node'`, `testTimeout: 30000`, sequential execution
|
||||
- Path alias mirrors tsconfig
|
||||
|
||||
**Environment:**
|
||||
- Required vars: `DATABASE_URL`, `NEXTAUTH_URL`, `NEXTAUTH_SECRET`, `MINIO_ENDPOINT`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`, `MINIO_BUCKET`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `EMAIL_FROM`, `OPENAI_API_KEY`, `CRON_SECRET`
|
||||
- Optional vars: `MINIO_PUBLIC_ENDPOINT`, `OPENAI_MODEL` (default: `gpt-4o`), `OPENAI_BASE_URL`, `ANTHROPIC_API_KEY`, `SESSION_MAX_AGE` (default: 86400), `MAX_FILE_SIZE` (default: 524288000), `LOG_LEVEL`, `MAGIC_LINK_EXPIRY` (default: 900)
|
||||
- All settings also configurable via `SystemSettings` DB table (DB takes priority over env vars)
|
||||
|
||||
**Build:**
|
||||
- `npm run build` → `next build` → produces `.next/standalone/` output
|
||||
- `npm run typecheck` → `tsc --noEmit` (no emit, type checking only)
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
**Development:**
|
||||
- Node.js >=20.0.0
|
||||
- PostgreSQL 16 (via Docker or local)
|
||||
- MinIO instance (optional in dev, defaults to `localhost:9000`)
|
||||
|
||||
**Production:**
|
||||
- Docker (compose file: `docker/docker-compose.yml`)
|
||||
- PostgreSQL 16 (`postgres:16-alpine` image) in Docker network
|
||||
- Next.js app runs as standalone Node.js server on port 7600
|
||||
- MinIO and Poste.io are external pre-existing services on VPS
|
||||
- Nginx reverse proxy with SSL (external, not in compose)
|
||||
- CI/CD: Gitea Actions (image pushed to container registry, `pull_policy: always`)
|
||||
- App entrypoint (`docker/docker-entrypoint.sh`): runs `prisma migrate deploy` → `prisma generate` → auto-seeds if DB empty → `node server.js`
|
||||
|
||||
---
|
||||
|
||||
*Stack analysis: 2026-02-26*
|
||||
327
.planning/codebase/STRUCTURE.md
Normal file
327
.planning/codebase/STRUCTURE.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# Codebase Structure
|
||||
|
||||
**Analysis Date:** 2026-02-26
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
MOPC/
|
||||
├── prisma/ # Database schema and migrations
|
||||
│ ├── schema.prisma # Single source of truth for all models
|
||||
│ ├── seed.ts # Seed script (imports from docs/CSV files)
|
||||
│ └── migrations/ # Prisma migration history (auto-generated)
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router — all routes
|
||||
│ │ ├── (admin)/ # Admin dashboard route group (SUPER_ADMIN, PROGRAM_ADMIN)
|
||||
│ │ ├── (jury)/ # Jury evaluation route group (JURY_MEMBER)
|
||||
│ │ ├── (applicant)/ # Applicant dashboard route group (APPLICANT)
|
||||
│ │ ├── (mentor)/ # Mentor workspace route group (MENTOR)
|
||||
│ │ ├── (observer)/ # Observer read-only route group (OBSERVER)
|
||||
│ │ ├── (auth)/ # Public auth pages (login, verify, onboarding)
|
||||
│ │ ├── (public)/ # Fully public pages (apply, vote, live-scores)
|
||||
│ │ ├── (settings)/ # User settings (profile)
|
||||
│ │ └── api/ # API routes (tRPC, auth, cron, SSE, files, health)
|
||||
│ ├── components/ # React components organized by domain
|
||||
│ │ ├── admin/ # Admin-specific components
|
||||
│ │ ├── jury/ # Jury-specific components
|
||||
│ │ ├── applicant/ # Applicant-specific components
|
||||
│ │ ├── mentor/ # Mentor-specific components
|
||||
│ │ ├── observer/ # Observer-specific components
|
||||
│ │ ├── public/ # Public-facing components
|
||||
│ │ ├── forms/ # Shared form components (apply wizard, COI dialog)
|
||||
│ │ ├── charts/ # Chart/visualization components
|
||||
│ │ ├── dashboard/ # Dashboard widgets
|
||||
│ │ ├── layouts/ # Navigation layouts per role (sidebar, nav bars)
|
||||
│ │ ├── shared/ # Reusable cross-domain components
|
||||
│ │ └── ui/ # shadcn/ui primitives
|
||||
│ ├── server/ # Server-only code
|
||||
│ │ ├── routers/ # tRPC domain routers (44+ files)
|
||||
│ │ │ └── _app.ts # Root router composing all domains
|
||||
│ │ ├── services/ # Business logic services
|
||||
│ │ ├── utils/ # Server utilities (audit, ai-usage, image-upload)
|
||||
│ │ ├── trpc.ts # tRPC init, middleware, procedure types
|
||||
│ │ └── context.ts # tRPC context factory (session + prisma + IP)
|
||||
│ ├── lib/ # Shared libraries (client + server)
|
||||
│ │ ├── trpc/ # tRPC client (client.ts), server caller (server.ts)
|
||||
│ │ ├── storage/ # Storage provider abstraction (S3/local)
|
||||
│ │ ├── whatsapp/ # WhatsApp notification client
|
||||
│ │ ├── auth.ts # NextAuth full configuration
|
||||
│ │ ├── auth.config.ts # Edge-compatible auth config (middleware)
|
||||
│ │ ├── auth-redirect.ts # requireRole() server helper
|
||||
│ │ ├── prisma.ts # Prisma singleton with connection pooling
|
||||
│ │ ├── logger.ts # Structured logger (tagged, level-aware)
|
||||
│ │ ├── email.ts # Nodemailer email sender
|
||||
│ │ ├── minio.ts # MinIO client initialization
|
||||
│ │ ├── openai.ts # OpenAI client initialization
|
||||
│ │ ├── rate-limit.ts # In-memory rate limiter
|
||||
│ │ ├── feature-flags.ts # DB-backed feature flags
|
||||
│ │ ├── round-config.ts # Round config helper utilities
|
||||
│ │ ├── utils.ts # General utilities (cn, formatters)
|
||||
│ │ └── [others] # countries, pdf-generator, typeform, notion, etc.
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ │ ├── competition.ts # Composite types for Competition/Round domain
|
||||
│ │ ├── competition-configs.ts # Per-RoundType Zod schemas + inferred types
|
||||
│ │ └── wizard-config.ts # Application wizard configuration types
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ │ ├── use-debounce.ts
|
||||
│ │ ├── use-live-voting-sse.ts # SSE subscription for live voting
|
||||
│ │ └── use-stage-live-sse.ts # SSE subscription for live stage
|
||||
│ └── contexts/ # React contexts
|
||||
│ └── edition-context.tsx # Edition/Program selector context
|
||||
├── tests/ # Test files (Vitest)
|
||||
│ ├── setup.ts # Test setup (prisma client, helpers)
|
||||
│ ├── helpers.ts # Test factories (createTestUser, createTestCompetition, etc.)
|
||||
│ └── unit/ # Unit test files
|
||||
├── docs/ # Internal documentation and architecture notes
|
||||
├── docker/ # Docker Compose configs and Nginx config
|
||||
├── public/ # Static assets (fonts, images, maps)
|
||||
├── scripts/ # Utility scripts
|
||||
├── middleware.ts # Next.js edge middleware (auth check)
|
||||
├── next.config.ts # Next.js config (standalone output, legacy redirects)
|
||||
└── prisma/ # (see above)
|
||||
```
|
||||
|
||||
## Directory Purposes
|
||||
|
||||
**`src/app/(admin)/admin/`:**
|
||||
- Purpose: All admin pages behind SUPER_ADMIN/PROGRAM_ADMIN role gate
|
||||
- Contains: Competition management, round config, project management, jury groups, members, programs, reports, audit, awards, settings, messages, mentors, partners, learning
|
||||
- Key files: `layout.tsx` (role guard + edition selector), `admin/page.tsx` (dashboard), `admin/rounds/[roundId]/page.tsx` (round detail — largest page)
|
||||
|
||||
**`src/app/(jury)/jury/`:**
|
||||
- Purpose: Jury evaluation interface behind JURY_MEMBER role gate
|
||||
- Contains: Competitions list, round overview, project list, evaluate page, deliberation, live voting, learning resources, awards
|
||||
- Key files: `layout.tsx` (role guard + onboarding check), `competitions/[roundId]/projects/[projectId]/evaluate/page.tsx` (evaluation form)
|
||||
|
||||
**`src/app/(applicant)/applicant/`:**
|
||||
- Purpose: Applicant dashboard behind APPLICANT role gate
|
||||
- Contains: Competition progress, documents, evaluations received, mentor chat, resources, team
|
||||
- Key files: `layout.tsx`, `applicant/page.tsx`
|
||||
|
||||
**`src/app/(mentor)/mentor/`:**
|
||||
- Purpose: Mentor workspace behind MENTOR role gate
|
||||
- Contains: Project list, workspace per project, resources
|
||||
- Key files: `layout.tsx`, `mentor/workspace/[projectId]/page.tsx`
|
||||
|
||||
**`src/app/(observer)/observer/`:**
|
||||
- Purpose: Read-only view behind OBSERVER role gate
|
||||
- Contains: Projects, reports
|
||||
- Key files: `layout.tsx`
|
||||
|
||||
**`src/app/(public)/`:**
|
||||
- Purpose: No-auth-required pages
|
||||
- Contains: Application form (`apply/[slug]`), edition application (`apply/edition/[programSlug]`), live scores display (`live-scores/[sessionId]`), audience vote (`vote/[sessionId]`), submission status (`my-submission/[id]`), email change password
|
||||
- Key files: `apply/[slug]/page.tsx` (application wizard)
|
||||
|
||||
**`src/app/(auth)/`:**
|
||||
- Purpose: Auth flow pages
|
||||
- Contains: Login, verify (magic link), verify-email, accept-invite, onboarding, set-password, error
|
||||
- Key files: `login/page.tsx`, `onboarding/page.tsx`, `accept-invite/page.tsx`
|
||||
|
||||
**`src/app/api/`:**
|
||||
- Purpose: Next.js route handlers for non-tRPC API
|
||||
- Contains:
|
||||
- `trpc/[trpc]/` — tRPC HTTP adapter (GET + POST)
|
||||
- `auth/[...nextauth]/` — NextAuth handler
|
||||
- `cron/` — Cron job endpoints (audit-cleanup, digest, draft-cleanup, reminders)
|
||||
- `live-voting/stream/` — SSE stream for live voting
|
||||
- `files/bulk-download/` — Bulk file download handler
|
||||
- `storage/local/` — Local dev storage handler
|
||||
- `health/` — DB health check endpoint
|
||||
|
||||
**`src/server/routers/`:**
|
||||
- Purpose: tRPC domain routers, one file per domain
|
||||
- Contains: 44+ router files assembled in `_app.ts`
|
||||
- Key files: `competition.ts`, `round.ts`, `roundEngine.ts`, `evaluation.ts`, `filtering.ts`, `deliberation.ts`, `resultLock.ts`, `roundAssignment.ts`, `assignment.ts`, `project.ts`, `user.ts`, `program.ts`
|
||||
|
||||
**`src/server/services/`:**
|
||||
- Purpose: All business logic — state machines, AI integrations, external service calls
|
||||
- Contains:
|
||||
- `round-engine.ts` — Round and project state machine
|
||||
- `deliberation.ts` — Deliberation session lifecycle (DELIB_OPEN → VOTING → TALLYING → DELIB_LOCKED)
|
||||
- `round-assignment.ts` — Jury assignment generation with policy enforcement
|
||||
- `smart-assignment.ts` — Scoring algorithm (tag overlap, bio match, workload, geo-diversity, COI, availability)
|
||||
- `submission-manager.ts` — Submission window lifecycle and file requirement enforcement
|
||||
- `result-lock.ts` — Immutable result locking with snapshot
|
||||
- `live-control.ts` — Live ceremony cursor management
|
||||
- `competition-context.ts` — Cross-cutting context resolver
|
||||
- `ai-filtering.ts`, `ai-assignment.ts`, `ai-evaluation-summary.ts`, `ai-tagging.ts`, `ai-award-eligibility.ts`, `ai-shortlist.ts` — AI feature services
|
||||
- `anonymization.ts` — PII stripping before AI calls
|
||||
- `notification.ts`, `in-app-notification.ts`, `evaluation-reminders.ts`, `email-digest.ts` — Notification services
|
||||
- `assignment-policy.ts`, `assignment-intent.ts` — Policy governance for assignments
|
||||
- `mentor-matching.ts`, `mentor-workspace.ts` — Mentor domain services
|
||||
|
||||
**`src/components/admin/round/`:**
|
||||
- Purpose: Components for the round detail page (the most complex admin page)
|
||||
- Key files: `filtering-dashboard.tsx`, `project-states-table.tsx`
|
||||
|
||||
**`src/components/admin/rounds/config/`:**
|
||||
- Purpose: Per-RoundType config form sections
|
||||
- Contains: Config UI for each round type (`intake-config.tsx`, `evaluation-config.tsx`, etc.)
|
||||
|
||||
**`src/components/shared/`:**
|
||||
- Purpose: Domain-agnostic reusable components
|
||||
- Contains: `page-header.tsx`, `status-badge.tsx`, `file-upload.tsx`, `file-viewer.tsx`, `pagination.tsx`, `notification-bell.tsx`, `edition-selector.tsx`, `empty-state.tsx`, `loading-spinner.tsx`, and others
|
||||
|
||||
**`src/components/ui/`:**
|
||||
- Purpose: shadcn/ui primitive components (never modified directly)
|
||||
- Contains: `button.tsx`, `card.tsx`, `dialog.tsx`, `form.tsx`, `select.tsx`, `table.tsx`, etc.
|
||||
|
||||
**`src/components/layouts/`:**
|
||||
- Purpose: Role-specific navigation shells
|
||||
- Contains: `admin-sidebar.tsx`, `jury-nav.tsx`, `mentor-nav.tsx`, `observer-nav.tsx`, `applicant-nav.tsx`, `role-nav.tsx`, `admin-edition-wrapper.tsx`
|
||||
|
||||
**`src/lib/trpc/`:**
|
||||
- Purpose: tRPC client configuration
|
||||
- Contains:
|
||||
- `client.ts` — `createTRPCReact<AppRouter>()` export (client components use `import { trpc } from '@/lib/trpc/client'`)
|
||||
- `server.ts` — Server-side caller for Server Components
|
||||
- `index.ts` — Provider setup (TRPCProvider + QueryClientProvider)
|
||||
|
||||
**`src/types/`:**
|
||||
- Purpose: Shared TypeScript types not generated by Prisma
|
||||
- Contains:
|
||||
- `competition.ts` — Composite types with nested relations (e.g., `CompetitionWithRounds`, `RoundWithRelations`)
|
||||
- `competition-configs.ts` — Per-RoundType Zod config schemas and inferred TypeScript types
|
||||
- `wizard-config.ts` — Application wizard step configuration types
|
||||
|
||||
## Key File Locations
|
||||
|
||||
**Entry Points:**
|
||||
- `middleware.ts` — Edge middleware (auth check before every request)
|
||||
- `src/app/api/trpc/[trpc]/route.ts` — tRPC HTTP handler
|
||||
- `src/app/api/auth/[...nextauth]/route.ts` — Auth handler
|
||||
- `src/server/routers/_app.ts` — Root tRPC router
|
||||
|
||||
**Configuration:**
|
||||
- `prisma/schema.prisma` — Database schema
|
||||
- `next.config.ts` — Next.js configuration + legacy route redirects
|
||||
- `src/lib/auth.config.ts` — Edge-compatible NextAuth config + Session type augmentations
|
||||
- `src/lib/auth.ts` — Full NextAuth configuration with providers
|
||||
- `src/server/trpc.ts` — tRPC initialization and all procedure type definitions
|
||||
- `src/server/context.ts` — tRPC context (session, prisma, ip, userAgent)
|
||||
- `tsconfig.json` — TypeScript strict mode config with `@/` path alias
|
||||
|
||||
**Core Logic:**
|
||||
- `src/server/services/round-engine.ts` — Round state machine
|
||||
- `src/server/services/deliberation.ts` — Deliberation state machine
|
||||
- `src/server/services/round-assignment.ts` — Assignment generation
|
||||
- `src/server/services/smart-assignment.ts` — Scoring algorithm
|
||||
- `src/server/services/competition-context.ts` — Context resolver
|
||||
- `src/types/competition-configs.ts` — Zod schemas for round configs
|
||||
- `src/server/utils/audit.ts` — Audit logging utility
|
||||
|
||||
**Testing:**
|
||||
- `tests/setup.ts` — Vitest setup with Prisma client
|
||||
- `tests/helpers.ts` — Test data factories
|
||||
- `tests/unit/` — Unit test files
|
||||
- `vitest.config.ts` — Vitest configuration
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
**Files:**
|
||||
- kebab-case for all source files: `round-engine.ts`, `admin-sidebar.tsx`, `use-live-voting-sse.ts`
|
||||
- Router files match domain name: `competition.ts`, `roundEngine.ts` (camelCase variants also seen for compound names)
|
||||
- Service files use kebab-case: `round-assignment.ts`, `ai-filtering.ts`, `result-lock.ts`
|
||||
|
||||
**Directories:**
|
||||
- kebab-case for all directories: `admin/`, `round-assignment/`, `apply-steps/`
|
||||
- Route group segments use parentheses per Next.js convention: `(admin)`, `(jury)`, `(public)`
|
||||
- Dynamic segments use square brackets: `[roundId]`, `[projectId]`, `[trpc]`
|
||||
|
||||
**Components:**
|
||||
- PascalCase exports: `AdminSidebar`, `FilteringDashboard`, `JurorProgressDashboard`
|
||||
- Component files kebab-case: `admin-sidebar.tsx`, `filtering-dashboard.tsx`
|
||||
|
||||
**Types:**
|
||||
- `type` keyword preferred over `interface` (TypeScript strict mode project)
|
||||
- Prisma-generated types used directly where possible; composite types in `src/types/`
|
||||
- Zod schemas named `[Domain]ConfigSchema`; inferred types named `[Domain]Config`
|
||||
|
||||
## Where to Add New Code
|
||||
|
||||
**New tRPC Domain Router:**
|
||||
- Router file: `src/server/routers/[domain].ts`
|
||||
- Register in: `src/server/routers/_app.ts`
|
||||
- Follow pattern: import from `../trpc`, use typed procedure (`adminProcedure`, `juryProcedure`, etc.), call `logAudit()` on mutations
|
||||
|
||||
**New Business Logic Service:**
|
||||
- Implementation: `src/server/services/[domain].ts`
|
||||
- Accept `prisma: PrismaClient | any` as parameter (for transaction compatibility)
|
||||
- Return typed result objects `{ success: boolean, errors?: string[] }` for state machine functions
|
||||
- Call `logAudit()` for all state changes
|
||||
- Never import tRPC types — services are tRPC-agnostic
|
||||
|
||||
**New Admin Page:**
|
||||
- Page file: `src/app/(admin)/admin/[section]/page.tsx`
|
||||
- Layout guard is inherited from `src/app/(admin)/layout.tsx` — no additional role check needed
|
||||
- Use `export const dynamic = 'force-dynamic'` for data-fetching pages
|
||||
- Fetch data server-side in page component using `auth()` + `prisma` directly, or use client component with tRPC hooks
|
||||
|
||||
**New Jury Page:**
|
||||
- Page file: `src/app/(jury)/jury/[section]/page.tsx`
|
||||
- Layout guard in `src/app/(jury)/layout.tsx` checks `JURY_MEMBER` role and onboarding completion
|
||||
|
||||
**New Public Page:**
|
||||
- Page file: `src/app/(public)/[section]/page.tsx`
|
||||
- No auth guard — fully public
|
||||
|
||||
**New Component (domain-specific):**
|
||||
- Admin component: `src/components/admin/[subdomain]/[component-name].tsx`
|
||||
- Jury component: `src/components/jury/[component-name].tsx`
|
||||
- Shared component: `src/components/shared/[component-name].tsx`
|
||||
|
||||
**New shadcn/ui Primitive:**
|
||||
- Location: `src/components/ui/[component].tsx` (generated via `npx shadcn@latest add [component]`)
|
||||
|
||||
**New Round Config Schema:**
|
||||
- Add Zod schema to `src/types/competition-configs.ts` following existing pattern
|
||||
- Add to `RoundConfigMap` discriminated union
|
||||
- Update `validateRoundConfig()` and `safeValidateRoundConfig()` switch statements
|
||||
- Add config UI component to `src/components/admin/rounds/config/`
|
||||
|
||||
**Utilities:**
|
||||
- Shared server+client helpers: `src/lib/utils.ts` or new `src/lib/[utility].ts`
|
||||
- Server-only utilities: `src/server/utils/[utility].ts`
|
||||
- Custom React hooks: `src/hooks/use-[name].ts`
|
||||
|
||||
## Special Directories
|
||||
|
||||
**`prisma/migrations/`:**
|
||||
- Purpose: Auto-generated SQL migration files
|
||||
- Generated: Yes (by `prisma migrate dev`)
|
||||
- Committed: Yes
|
||||
|
||||
**`.next/`:**
|
||||
- Purpose: Next.js build output cache
|
||||
- Generated: Yes
|
||||
- Committed: No
|
||||
|
||||
**`docs/`:**
|
||||
- Purpose: Internal architecture notes, redesign plans, GDPR documentation, feature plans
|
||||
- Generated: No
|
||||
- Committed: Yes
|
||||
|
||||
**`prototypes/`:**
|
||||
- Purpose: HTML/CSS prototype mockups for admin redesign
|
||||
- Generated: No
|
||||
- Committed: Yes
|
||||
|
||||
**`docker/`:**
|
||||
- Purpose: Docker Compose files for production and dev stacks; Nginx reverse proxy config
|
||||
- Generated: No
|
||||
- Committed: Yes
|
||||
|
||||
**`.planning/`:**
|
||||
- Purpose: GSD planning documents (codebase analysis, implementation plans)
|
||||
- Generated: By GSD tooling
|
||||
- Committed: No (gitignored)
|
||||
|
||||
**`.serena/`:**
|
||||
- Purpose: Serena MCP project cache and memories
|
||||
- Generated: Yes
|
||||
- Committed: No
|
||||
|
||||
---
|
||||
|
||||
*Structure analysis: 2026-02-26*
|
||||
289
.planning/codebase/TESTING.md
Normal file
289
.planning/codebase/TESTING.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Testing Patterns
|
||||
|
||||
**Analysis Date:** 2026-02-26
|
||||
|
||||
## Test Framework
|
||||
|
||||
**Runner:**
|
||||
- Vitest 4.0.18
|
||||
- Config: `vitest.config.ts`
|
||||
- Environment: `node` (no jsdom — tests are server-side only)
|
||||
- Globals: `true` — `describe`, `it`, `expect` available without imports (but explicit imports are used in practice)
|
||||
- `fileParallelism: false` — test files run sequentially
|
||||
- `pool: 'forks'` — each test file in isolated subprocess
|
||||
|
||||
**Assertion Library:**
|
||||
- Vitest built-in (`expect`)
|
||||
|
||||
**Path Aliases:**
|
||||
- `@/` resolves to `./src/` in test files (configured in `vitest.config.ts` via `resolve.alias`)
|
||||
|
||||
**Run Commands:**
|
||||
```bash
|
||||
npx vitest # Watch mode (all tests)
|
||||
npx vitest run # Run all tests once
|
||||
npx vitest run tests/unit/assignment-policy.test.ts # Single file
|
||||
npx vitest run -t 'test name' # Single test by name/pattern
|
||||
```
|
||||
|
||||
**Timeout:**
|
||||
- Default `testTimeout: 30000` (30 seconds) — allows for database operations
|
||||
|
||||
## Test File Organization
|
||||
|
||||
**Location:**
|
||||
- All tests live under `tests/` (not co-located with source files)
|
||||
- `tests/unit/` — pure-logic tests, no database
|
||||
- `tests/integration/` — database-backed tests using real Prisma client (currently `assignment-policy.test.ts` in both directories)
|
||||
- Setup: `tests/setup.ts`
|
||||
- Factories: `tests/helpers.ts`
|
||||
|
||||
**Naming:**
|
||||
- `{domain}.test.ts` — matches domain name: `assignment-policy.test.ts`, `round-engine.test.ts`
|
||||
- No `.spec.ts` files — exclusively `.test.ts`
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
tests/
|
||||
├── setup.ts # Global test context, prisma client, createTestContext()
|
||||
├── helpers.ts # Test data factories (createTestUser, createTestRound, etc.)
|
||||
├── unit/
|
||||
│ └── assignment-policy.test.ts # Pure logic, no DB
|
||||
└── integration/
|
||||
└── assignment-policy.test.ts # DB-backed tests
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
**Suite Organization:**
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { CapMode } from '@prisma/client'
|
||||
import { resolveEffectiveCap } from '@/server/services/assignment-policy'
|
||||
|
||||
// ============================================================================
|
||||
// Section Title with box dividers
|
||||
// ============================================================================
|
||||
|
||||
describe('functionName', () => {
|
||||
it('returns expected value when condition', () => {
|
||||
const result = functionName(input)
|
||||
expect(result.value).toBe(expected)
|
||||
expect(result.source).toBe('system')
|
||||
})
|
||||
|
||||
describe('nested scenario group', () => {
|
||||
it('specific behavior', () => { ... })
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Helper/Stub Pattern:**
|
||||
```typescript
|
||||
// Builder functions at top of file construct minimal test objects
|
||||
function baseMemberContext(overrides: Partial<MemberContext> = {}): MemberContext {
|
||||
return {
|
||||
competition: {} as any,
|
||||
round: {} as any,
|
||||
member: { id: 'member-1', role: 'MEMBER', ... } as any,
|
||||
currentAssignmentCount: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function withJuryGroup(ctx: MemberContext, groupOverrides = {}): MemberContext {
|
||||
return { ...ctx, juryGroup: { id: 'jg-1', defaultMaxAssignments: 20, ...groupOverrides } as any }
|
||||
}
|
||||
```
|
||||
|
||||
**Patterns:**
|
||||
- Build minimal context objects inline — no heavy mocking frameworks
|
||||
- Use spread + override: `{ ...ctx, member: { ...ctx.member, maxAssignmentsOverride: 10 } }`
|
||||
- Assert on both value AND metadata: `expect(result.value).toBe(25)` + `expect(result.source).toBe('jury_group')`
|
||||
- Tests are descriptive: `'admin per-member override takes precedence over group default'`
|
||||
|
||||
## Mocking
|
||||
|
||||
**Framework:** None — unit tests avoid mocking entirely by testing pure functions.
|
||||
|
||||
**Approach:**
|
||||
- Unit tests pass plain JavaScript objects (`{} as any`) for unused dependencies
|
||||
- No `vi.mock()`, `vi.fn()`, or `vi.spyOn()` observed in current test files
|
||||
- Prisma is a real client connected to a test database (see integration tests)
|
||||
- tRPC context is constructed via `createTestContext(user)` — a plain object, not mocked
|
||||
|
||||
**What to Mock:**
|
||||
- External I/O (email, MinIO, OpenAI) — not currently tested; fire-and-forget pattern used
|
||||
- Anything not relevant to the assertion being made (`{} as any` for unused context fields)
|
||||
|
||||
**What NOT to Mock:**
|
||||
- Business logic functions under test
|
||||
- Prisma in integration tests — use real database with `DATABASE_URL_TEST`
|
||||
- The `createTestContext` / `createCaller` — these are lightweight stubs, not mocks
|
||||
|
||||
## Fixtures and Factories
|
||||
|
||||
**Test Data (from `tests/helpers.ts`):**
|
||||
```typescript
|
||||
// uid() creates unique prefixed IDs to avoid collisions
|
||||
export function uid(prefix = 'test'): string {
|
||||
return `${prefix}-${randomUUID().slice(0, 12)}`
|
||||
}
|
||||
|
||||
// Factories accept overrides for specific test scenarios
|
||||
export async function createTestUser(
|
||||
role: UserRole = 'JURY_MEMBER',
|
||||
overrides: Partial<{ email: string; name: string; ... }> = {}
|
||||
) {
|
||||
const id = uid('user')
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
email: overrides.email ?? `${id}@test.local`,
|
||||
role,
|
||||
...
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Available Factories:**
|
||||
- `createTestUser(role, overrides)` — creates User in database
|
||||
- `createTestProgram(overrides)` — creates Program
|
||||
- `createTestCompetition(programId, overrides)` — creates Competition
|
||||
- `createTestRound(competitionId, overrides)` — creates Round (default: EVALUATION, ROUND_ACTIVE)
|
||||
- `createTestProject(programId, overrides)` — creates Project
|
||||
- `createTestProjectRoundState(projectId, roundId, overrides)` — creates ProjectRoundState
|
||||
- `createTestAssignment(userId, projectId, roundId, overrides)` — creates Assignment
|
||||
- `createTestEvaluation(assignmentId, formId, overrides)` — creates Evaluation
|
||||
- `createTestEvaluationForm(roundId, criteria)` — creates EvaluationForm
|
||||
- `createTestFilteringRule(roundId, overrides)` — creates FilteringRule
|
||||
- `createTestCOI(assignmentId, userId, projectId, hasConflict)` — creates ConflictOfInterest
|
||||
- `createTestCohort(roundId, overrides)` — creates Cohort
|
||||
- `createTestCohortProject(cohortId, projectId)` — creates CohortProject
|
||||
|
||||
**Location:**
|
||||
- Factories in `tests/helpers.ts`
|
||||
- Shared Prisma client in `tests/setup.ts`
|
||||
|
||||
## Coverage
|
||||
|
||||
**Requirements:** None enforced — no coverage thresholds configured.
|
||||
|
||||
**View Coverage:**
|
||||
```bash
|
||||
npx vitest run --coverage # Requires @vitest/coverage-v8 (not currently installed)
|
||||
```
|
||||
|
||||
## Test Types
|
||||
|
||||
**Unit Tests (`tests/unit/`):**
|
||||
- Scope: Pure business logic functions with no I/O
|
||||
- Approach: Construct in-memory objects, call function, assert return value
|
||||
- Examples: `assignment-policy.test.ts` tests `resolveEffectiveCap`, `evaluateAssignmentPolicy`
|
||||
- No database, no HTTP, no file system
|
||||
|
||||
**Integration Tests (`tests/integration/`):**
|
||||
- Scope: tRPC router procedures via `createCaller`
|
||||
- Approach: Create real database records → call procedure → assert DB state or return value → cleanup
|
||||
- Uses `DATABASE_URL_TEST` (or falls back to `DATABASE_URL`)
|
||||
- Sequential execution (`fileParallelism: false`) to avoid DB conflicts
|
||||
|
||||
**E2E Tests:**
|
||||
- Playwright configured (`@playwright/test` installed, `npm run test:e2e` script)
|
||||
- No test files found yet — framework is available but not implemented
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Integration Test Pattern (calling tRPC procedures):**
|
||||
```typescript
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { prisma } from '../setup'
|
||||
import { createTestUser, createTestProgram, createTestCompetition, cleanupTestData, uid } from '../helpers'
|
||||
import { roundRouter } from '@/server/routers/round'
|
||||
|
||||
describe('round procedures', () => {
|
||||
let programId: string
|
||||
let adminUser: Awaited<ReturnType<typeof createTestUser>>
|
||||
|
||||
beforeAll(async () => {
|
||||
adminUser = await createTestUser('SUPER_ADMIN')
|
||||
const program = await createTestProgram()
|
||||
programId = program.id
|
||||
})
|
||||
|
||||
it('activates a round', async () => {
|
||||
const competition = await createTestCompetition(programId)
|
||||
const caller = createCaller(roundRouter, adminUser)
|
||||
const result = await caller.activate({ roundId: round.id })
|
||||
expect(result.status).toBe('ROUND_ACTIVE')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData(programId, [adminUser.id])
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Unit Test Pattern (pure logic):**
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveEffectiveCap } from '@/server/services/assignment-policy'
|
||||
|
||||
describe('resolveEffectiveCap', () => {
|
||||
it('returns system default when no jury group', () => {
|
||||
const ctx = baseMemberContext() // local builder function
|
||||
const result = resolveEffectiveCap(ctx)
|
||||
expect(result.value).toBe(SYSTEM_DEFAULT_CAP)
|
||||
expect(result.source).toBe('system')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Async Testing:**
|
||||
```typescript
|
||||
it('creates evaluation', async () => {
|
||||
const result = await caller.evaluation.start({ assignmentId: assignment.id })
|
||||
expect(result.status).toBe('DRAFT')
|
||||
})
|
||||
```
|
||||
|
||||
**Error Testing:**
|
||||
```typescript
|
||||
it('throws FORBIDDEN when accessing others evaluation', async () => {
|
||||
const otherUser = await createTestUser('JURY_MEMBER')
|
||||
const caller = createCaller(evaluationRouter, otherUser)
|
||||
await expect(
|
||||
caller.get({ assignmentId: assignment.id })
|
||||
).rejects.toThrow('FORBIDDEN')
|
||||
})
|
||||
```
|
||||
|
||||
**Cleanup (afterAll):**
|
||||
```typescript
|
||||
afterAll(async () => {
|
||||
// Pass programId to cascade-delete competition data, plus explicit user IDs
|
||||
await cleanupTestData(programId, [adminUser.id, jurorUser.id])
|
||||
})
|
||||
```
|
||||
|
||||
## Test Infrastructure Details
|
||||
|
||||
**`createTestContext(user)`** in `tests/setup.ts`:
|
||||
- Builds a fake tRPC context matching `{ session: { user, expires }, prisma, ip, userAgent }`
|
||||
- `prisma` is the shared test client
|
||||
- Used internally by `createCaller`
|
||||
|
||||
**`createCaller(routerModule, user)`** in `tests/setup.ts`:
|
||||
- Shorthand: `const caller = createCaller(evaluationRouter, adminUser)`
|
||||
- Returns type-safe caller — procedures called as `await caller.procedureName(input)`
|
||||
- Import the router module directly, not `appRouter`
|
||||
|
||||
**Database Isolation:**
|
||||
- Tests share one database — isolation is by unique IDs (via `uid()`)
|
||||
- `cleanupTestData(programId)` does ordered deletion respecting FK constraints
|
||||
- Always call `cleanupTestData` in `afterAll`, never skip
|
||||
|
||||
---
|
||||
|
||||
*Testing analysis: 2026-02-26*
|
||||
118
.planning/phases/01-ai-ranking-backend/01-01-SUMMARY.md
Normal file
118
.planning/phases/01-ai-ranking-backend/01-01-SUMMARY.md
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
phase: 01-ai-ranking-backend
|
||||
plan: "01"
|
||||
subsystem: schema
|
||||
tags: [prisma, schema, migration, zod, ranking]
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides: [RankingSnapshot-model, RankingSnapshot-enums, EvaluationConfig-ranking-fields]
|
||||
affects: [01-02, 01-03, 01-04]
|
||||
tech_stack:
|
||||
added: [RankingSnapshot Prisma model, RankingTriggerType enum, RankingMode enum, RankingSnapshotStatus enum]
|
||||
patterns: [FilteringJob pattern for job models, Zod optional fields with defaults for backward compatibility]
|
||||
key_files:
|
||||
created:
|
||||
- prisma/migrations/20260227000000_add_ranking_snapshot/migration.sql
|
||||
modified:
|
||||
- prisma/schema.prisma
|
||||
- src/types/competition-configs.ts
|
||||
decisions:
|
||||
- "Used separate relation names: RoundRankingSnapshots (Round FK) and TriggeredRankingSnapshots (User FK) to avoid Prisma ambiguous relation error — each FK pair on RankingSnapshot gets its own named relation"
|
||||
- "Created migration SQL manually (20260227000000) since local DB credentials were unavailable; migration file is correct and will apply cleanly on next deploy"
|
||||
- "All three ranking fields (rankingEnabled, rankingCriteria, autoRankOnComplete) are optional/defaulted for zero-migration compatibility with existing EvaluationConfig data"
|
||||
metrics:
|
||||
duration: "~7 minutes"
|
||||
completed: "2026-02-27"
|
||||
tasks_completed: 2
|
||||
files_changed: 3
|
||||
---
|
||||
|
||||
# Phase 1 Plan 01: RankingSnapshot Schema + EvaluationConfig Ranking Fields Summary
|
||||
|
||||
**One-liner:** Added RankingSnapshot Prisma model with 3 enums and migration SQL, plus 3 ranking fields to EvaluationConfigSchema, establishing the data contracts for Plans 02-04.
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: RankingSnapshot model + enums (schema.prisma)
|
||||
|
||||
Added three new enums to `prisma/schema.prisma`:
|
||||
- `RankingTriggerType` — MANUAL, AUTO, RETROACTIVE, QUICK
|
||||
- `RankingMode` — PREVIEW, CONFIRMED, QUICK
|
||||
- `RankingSnapshotStatus` — PENDING, RUNNING, COMPLETED, FAILED
|
||||
|
||||
Added `RankingSnapshot` model with:
|
||||
- `roundId` FK → Round (Cascade delete, named relation "RoundRankingSnapshots")
|
||||
- `triggeredById` FK → User (SetNull on delete, named relation "TriggeredRankingSnapshots")
|
||||
- `criteriaText` (Text), `parsedRulesJson` (JsonB) — criteria + parsed rules
|
||||
- `startupRankingJson`, `conceptRankingJson`, `evaluationDataJson` (optional JsonB) — results per category
|
||||
- `mode`, `status` — with sensible defaults (PREVIEW, COMPLETED)
|
||||
- `reordersJson` (optional JsonB) — for Phase 2 drag-and-drop
|
||||
- `model`, `tokensUsed` — AI metadata
|
||||
- Indexes on roundId, triggeredById, createdAt
|
||||
|
||||
Added back-relations:
|
||||
- `Round.rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots")`
|
||||
- `User.rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots")`
|
||||
|
||||
Created migration: `prisma/migrations/20260227000000_add_ranking_snapshot/migration.sql`
|
||||
|
||||
### Task 2: EvaluationConfigSchema ranking fields (competition-configs.ts)
|
||||
|
||||
Appended to `EvaluationConfigSchema` in `src/types/competition-configs.ts`:
|
||||
```typescript
|
||||
// Ranking (Phase 1)
|
||||
rankingEnabled: z.boolean().default(false),
|
||||
rankingCriteria: z.string().optional(),
|
||||
autoRankOnComplete: z.boolean().default(false),
|
||||
```
|
||||
|
||||
All fields are intentionally optional/defaulted so existing rounds parse without errors.
|
||||
|
||||
## TDD Verification Results
|
||||
|
||||
All four TDD cases from the plan pass:
|
||||
- `EvaluationConfigSchema.parse({})` → `{rankingEnabled: false, autoRankOnComplete: false, rankingCriteria: undefined}` ✓
|
||||
- `EvaluationConfigSchema.parse({rankingEnabled: true, rankingCriteria: "rank by score"})` → succeeds ✓
|
||||
- `EvaluationConfigSchema.parse({rankingCriteria: 123})` → throws ZodError ✓
|
||||
- `prisma.rankingSnapshot` accessible in generated client ✓
|
||||
|
||||
## Key Decisions
|
||||
|
||||
1. **Separate relation names per FK pair:** Used `RoundRankingSnapshots` for the Round → RankingSnapshot relation and `TriggeredRankingSnapshots` for the User → RankingSnapshot relation. Each FK pair requires its own named relation in Prisma to avoid ambiguous relation errors.
|
||||
|
||||
2. **Manual migration file:** Local PostgreSQL credentials were unavailable (DB running but `mopc:devpassword` rejected). Created migration SQL manually following the exact Prisma-generated format. The migration will apply on next `prisma migrate deploy` or Docker restart.
|
||||
|
||||
3. **Backward-compatible defaults:** All three EvaluationConfig ranking fields default to `false`/`undefined` so existing round configs parse cleanly without migration.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Database authentication unavailable for migration**
|
||||
- **Found during:** Task 1 (migration step)
|
||||
- **Issue:** PostgreSQL running locally but `mopc:devpassword` credentials rejected — P1000 auth error on `npx prisma migrate dev`
|
||||
- **Fix:** Created migration SQL file manually at `prisma/migrations/20260227000000_add_ranking_snapshot/migration.sql` following exact Prisma format. Ran `npx prisma generate` separately (no DB needed) to regenerate client.
|
||||
- **Impact:** Migration file is correct and complete; will apply on first DB connection or Docker deploy. TypeScript typecheck passes confirming no schema errors.
|
||||
- **Files modified:** `prisma/migrations/20260227000000_add_ranking_snapshot/migration.sql` (created)
|
||||
|
||||
**2. [Rule 2 - Schema] Separate relation names per FK pair**
|
||||
- **Found during:** Task 1 (schema design)
|
||||
- **Issue:** Plan's implementation note mentioned "TriggeredRankingSnapshots" for both the Round and User relations, but Prisma requires unique relation names per FK pair (not per target model)
|
||||
- **Fix:** Used `RoundRankingSnapshots` for Round FK and `TriggeredRankingSnapshots` for User FK — distinct names per FK pair as Prisma requires
|
||||
- **Files modified:** `prisma/schema.prisma`
|
||||
|
||||
## Self-Check
|
||||
|
||||
### Files Exist
|
||||
- [x] `prisma/schema.prisma` — contains `model RankingSnapshot`, all 3 enums, back-relations on Round and User
|
||||
- [x] `prisma/migrations/20260227000000_add_ranking_snapshot/migration.sql` — migration SQL created
|
||||
- [x] `src/types/competition-configs.ts` — EvaluationConfigSchema has rankingEnabled, rankingCriteria, autoRankOnComplete
|
||||
|
||||
### Commits Exist
|
||||
- [x] `91bc100` — feat(01-01): add RankingSnapshot model + enums to schema.prisma
|
||||
- [x] `af9528d` — feat(01-01): extend EvaluationConfigSchema with ranking fields
|
||||
|
||||
### TypeScript Clean
|
||||
- [x] `npm run typecheck` exits 0 — no errors
|
||||
|
||||
## Self-Check: PASSED
|
||||
134
.planning/phases/01-ai-ranking-backend/01-04-SUMMARY.md
Normal file
134
.planning/phases/01-ai-ranking-backend/01-04-SUMMARY.md
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
phase: 01-ai-ranking-backend
|
||||
plan: 04
|
||||
subsystem: ai-ranking
|
||||
tags: [auto-trigger, notifications, ranking, evaluation, tRPC]
|
||||
requirements: [RANK-09, RANK-10]
|
||||
dependency_graph:
|
||||
requires: [01-01, 01-02, 01-03]
|
||||
provides: [auto-trigger-on-evaluation-complete, retroactive-scan, admin-notifications]
|
||||
affects: [evaluation-submit-mutation, ranking-router, in-app-notifications]
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns: [fire-and-forget async, cooldown-guard, module-level-helper-function]
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- src/server/services/in-app-notification.ts
|
||||
- src/server/routers/evaluation.ts
|
||||
- src/server/routers/ranking.ts
|
||||
decisions:
|
||||
- "triggerAutoRankIfComplete defined as module-level (non-exported) function in evaluation.ts — avoids circular imports and keeps the auto-trigger logic colocated with the mutation it serves"
|
||||
- "EvaluationConfig null fallback typed as {} as EvaluationConfig rather than just {} — required for TypeScript strict mode to recognize rankingCriteria and autoRankOnComplete fields"
|
||||
- "ParsedRankingRule[] cast via unknown as Prisma.InputJsonValue — Prisma InputJsonValue does not overlap with typed arrays, double-cast is the correct pattern throughout the codebase"
|
||||
- "retroactiveScan uses RETROACTIVE triggerType to distinguish from MANUAL/AUTO/QUICK — prevents duplicate re-runs on subsequent scans"
|
||||
- "triggerAutoRank procedure in ranking.ts uses MANUAL triggerType (not AUTO) because it is admin-initiated, not system-initiated"
|
||||
metrics:
|
||||
duration: ~8min
|
||||
completed_date: "2026-02-27"
|
||||
tasks_completed: 2
|
||||
files_modified: 3
|
||||
---
|
||||
|
||||
# Phase 1 Plan 04: Auto-Trigger + Retroactive Scan Summary
|
||||
|
||||
**One-liner:** Fire-and-forget auto-ranking on evaluation completion with 5-minute cooldown guard, plus retroactive scan procedure for rounds already complete at deploy time.
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: AI_RANKING_COMPLETE + AI_RANKING_FAILED notification types
|
||||
|
||||
Added two new entries to `src/server/services/in-app-notification.ts`:
|
||||
|
||||
- `NotificationTypes.AI_RANKING_COMPLETE` — type string + `BarChart3` icon + `normal` priority
|
||||
- `NotificationTypes.AI_RANKING_FAILED` — type string + `AlertTriangle` icon + `high` priority
|
||||
|
||||
Pattern follows existing FILTERING_COMPLETE / FILTERING_FAILED entries exactly.
|
||||
|
||||
### Task 2: Auto-trigger hook + new ranking procedures
|
||||
|
||||
**evaluation.ts — `triggerAutoRankIfComplete` function:**
|
||||
|
||||
```typescript
|
||||
async function triggerAutoRankIfComplete(
|
||||
roundId: string,
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
Logic flow:
|
||||
1. Count required assignments — skip if not all complete
|
||||
2. Read `round.configJson` → check `autoRankOnComplete` + `rankingCriteria` — skip silently if not configured
|
||||
3. Cooldown guard — skip if AUTO snapshot exists within last 5 minutes
|
||||
4. Call `aiQuickRank()` — executes in 10-30s asynchronously
|
||||
5. Create `RankingSnapshot` with `triggerType: 'AUTO'`, `triggeredById: null`
|
||||
6. Notify admins via `AI_RANKING_COMPLETE` notification
|
||||
7. Catch-all: any error sends `AI_RANKING_FAILED` notification, never rethrows
|
||||
|
||||
**Fire-and-forget call in submit mutation (line 378):**
|
||||
```typescript
|
||||
void triggerAutoRankIfComplete(evaluation.assignment.roundId, ctx.prisma, ctx.user.id)
|
||||
```
|
||||
Placed after `$transaction([evaluation.update, assignment.update])`, before `logAudit`. The submission returns immediately — ranking runs asynchronously.
|
||||
|
||||
**ranking.ts — 7 total procedures:**
|
||||
|
||||
| Procedure | Type | Trigger | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `parseRankingCriteria` | mutation | admin | Preview-only parse (RANK-01, RANK-03) |
|
||||
| `executeRanking` | mutation | admin | Confirmed ranking with pre-parsed rules (RANK-05, RANK-06, RANK-08) |
|
||||
| `quickRank` | mutation | admin | Parse + execute in one step (RANK-04) |
|
||||
| `listSnapshots` | query | admin | List snapshots for round, most recent first |
|
||||
| `getSnapshot` | query | admin | Retrieve single snapshot by ID |
|
||||
| `triggerAutoRank` | mutation | admin | Manual trigger from round config (RANK-09) |
|
||||
| `retroactiveScan` | mutation | admin | Scan all active/closed rounds (RANK-10) |
|
||||
|
||||
**retroactiveScan logic:**
|
||||
- Finds all `ROUND_ACTIVE` and `ROUND_CLOSED` rounds
|
||||
- Checks each for `autoRankOnComplete + rankingCriteria` config
|
||||
- Checks if all required assignments are complete
|
||||
- Skips rounds that already have a `RETROACTIVE` snapshot
|
||||
- Executes ranking sequentially (not parallel) to avoid OpenAI rate limits
|
||||
- Returns `{ results[], total, triggered }` summary
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] TypeScript strict mode: `?? {}` loses EvaluationConfig type**
|
||||
- **Found during:** Task 2 typecheck
|
||||
- **Issue:** `(configJson as EvaluationConfig | null) ?? {}` — the `{}` fallback widens the type to `{}` which doesn't have `rankingCriteria` / `autoRankOnComplete` fields, causing TS2339 errors
|
||||
- **Fix:** Changed to `?? ({} as EvaluationConfig)` in all three locations (evaluation.ts + two in ranking.ts)
|
||||
- **Files modified:** `src/server/routers/evaluation.ts`, `src/server/routers/ranking.ts`
|
||||
- **Commit:** c310631
|
||||
|
||||
**2. [Rule 1 - Bug] TypeScript: ParsedRankingRule[] requires double-cast to InputJsonValue**
|
||||
- **Found during:** Task 2 typecheck
|
||||
- **Issue:** `result.parsedRules as Prisma.InputJsonValue` produces TS2352 — neither type overlaps
|
||||
- **Fix:** Changed to `result.parsedRules as unknown as Prisma.InputJsonValue` (matching the pattern already used for `rankedProjects` arrays)
|
||||
- **Files modified:** `src/server/routers/ranking.ts` (triggerAutoRank + retroactiveScan)
|
||||
- **Commit:** c310631
|
||||
|
||||
## Build Status
|
||||
|
||||
- `npm run typecheck` — PASSED (0 errors)
|
||||
- `npm run build` — PASSED (full production build)
|
||||
|
||||
## Key Links Implemented
|
||||
|
||||
| From | To | Via |
|
||||
|------|----|-----|
|
||||
| `evaluation.ts submit` | `triggerAutoRankIfComplete` | `void` fire-and-forget after `isCompleted: true` |
|
||||
| `triggerAutoRankIfComplete` | `ai-ranking.ts quickRank` | `aiQuickRank(criteriaText, roundId, prisma, userId)` |
|
||||
| `triggerAutoRankIfComplete` | `in-app-notification.ts` | `notifyAdmins({ type: AI_RANKING_COMPLETE })` |
|
||||
| `ranking.ts triggerAutoRank` | `ai-ranking.ts quickRank` | admin-initiated, creates MANUAL snapshot |
|
||||
| `ranking.ts retroactiveScan` | `ai-ranking.ts quickRank` | sequential per-round, creates RETROACTIVE snapshot |
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `src/server/routers/evaluation.ts` — modified, contains `void triggerAutoRankIfComplete(...)`
|
||||
- `src/server/routers/ranking.ts` — modified, contains `triggerAutoRank` and `retroactiveScan`
|
||||
- `src/server/services/in-app-notification.ts` — modified, contains `AI_RANKING_COMPLETE` and `AI_RANKING_FAILED`
|
||||
- Commits: 4683bb8 (Task 1), c310631 (Task 2)
|
||||
- Build: PASSED
|
||||
100
.planning/phases/02-ranking-dashboard-ui/02-01-SUMMARY.md
Normal file
100
.planning/phases/02-ranking-dashboard-ui/02-01-SUMMARY.md
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
phase: 02-ranking-dashboard-ui
|
||||
plan: 01
|
||||
subsystem: ui
|
||||
tags: [trpc, prisma, ranking, react, nextjs]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 01-ai-ranking-backend
|
||||
provides: rankingRouter with listSnapshots/getSnapshot, RankingSnapshot model with reordersJson field
|
||||
provides:
|
||||
- saveReorder mutation on rankingRouter (append-only audit log of admin drag-reorders)
|
||||
- Ranking tab visible on EVALUATION round detail pages
|
||||
- RankingDashboard stub component (Plan 02 will flesh out)
|
||||
affects: [02-02-ranking-dashboard-ui]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Append-only JSON log pattern: read existing array, push new event, write full array as Prisma.InputJsonValue"
|
||||
- "isEvaluation guard for tab/content conditional rendering (matches isFiltering pattern)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- src/components/admin/round/ranking-dashboard.tsx
|
||||
modified:
|
||||
- src/server/routers/ranking.ts
|
||||
- src/app/(admin)/admin/rounds/[roundId]/page.tsx
|
||||
|
||||
key-decisions:
|
||||
- "ReorderEvent type defined locally in ranking.ts (not exported) — only used by saveReorder"
|
||||
- "saveReorder is append-only: full ordered list stored per event, latest entry per category = current admin order, gives full audit trail"
|
||||
|
||||
patterns-established:
|
||||
- "Tab conditional: ...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []) follows existing isFiltering pattern"
|
||||
|
||||
requirements-completed: [DASH-01]
|
||||
|
||||
# Metrics
|
||||
duration: 5min
|
||||
completed: 2026-02-27
|
||||
---
|
||||
|
||||
# Phase 2 Plan 01: Ranking Tab Entry Point Summary
|
||||
|
||||
**saveReorder append-only audit mutation + Ranking tab registered on EVALUATION round detail pages with RankingDashboard stub component**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~5 min
|
||||
- **Started:** 2026-02-27T00:15:00Z
|
||||
- **Completed:** 2026-02-27T00:20:00Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 3
|
||||
|
||||
## Accomplishments
|
||||
- Added `saveReorder` adminProcedure to rankingRouter — accepts snapshotId, category, orderedProjectIds; appends ReorderEvent to reordersJson (append-only audit log)
|
||||
- Registered Ranking tab in round detail page guarded by `isEvaluation` with BarChart3 icon (already imported)
|
||||
- Created minimal `RankingDashboard` stub component that compiles and renders placeholder text
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Add saveReorder mutation to ranking router** - `68422e6` (feat)
|
||||
2. **Task 2: Register Ranking tab in round detail page + create component stub** - `8f71527` (feat)
|
||||
|
||||
**Plan metadata:** (docs commit to follow)
|
||||
|
||||
## Files Created/Modified
|
||||
- `src/server/routers/ranking.ts` - Added ReorderEvent local type and saveReorder adminProcedure
|
||||
- `src/components/admin/round/ranking-dashboard.tsx` - New stub component exporting RankingDashboard with competitionId+roundId props
|
||||
- `src/app/(admin)/admin/rounds/[roundId]/page.tsx` - Import RankingDashboard, add Ranking tab to tab array, add TabsContent block
|
||||
|
||||
## Decisions Made
|
||||
- `ReorderEvent` type defined locally in ranking.ts (not exported) — only consumed by saveReorder
|
||||
- Stub uses `_competitionId` / `_roundId` underscore prefix to avoid TypeScript unused-var warnings while keeping the correct prop signature for Plan 02 to use
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- saveReorder backend contract established and type-safe
|
||||
- Ranking tab entry point wired — visible when viewing EVALUATION rounds
|
||||
- Plan 02 can now replace the stub body with the full RankingDashboard component
|
||||
- Build and typecheck both pass with 0 errors
|
||||
|
||||
---
|
||||
*Phase: 02-ranking-dashboard-ui*
|
||||
*Completed: 2026-02-27*
|
||||
123
.planning/phases/02-ranking-dashboard-ui/02-02-SUMMARY.md
Normal file
123
.planning/phases/02-ranking-dashboard-ui/02-02-SUMMARY.md
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
phase: 02-ranking-dashboard-ui
|
||||
plan: 02
|
||||
subsystem: ui
|
||||
tags: [react, dnd-kit, trpc, ranking, drag-and-drop, sheet-panel]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 02-01
|
||||
provides: RankingDashboard stub, saveReorder mutation, Ranking tab entry point
|
||||
provides:
|
||||
- Full RankingDashboard component with drag-and-drop reorder, AI vs override visual states, and Sheet-based juror evaluation detail panel
|
||||
affects: [02-03-advance-projects]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "useRef init guard: initialized.current prevents localOrder re-init from server data on every re-render — eliminates snap-back"
|
||||
- "Fire-and-forget mutation inside setLocalOrder callback: setLocalOrder runs synchronously first, mutation fires async, no onSuccess invalidation"
|
||||
- "Double cast via unknown: Prisma JsonValue cast to RankedProjectEntry[] requires (json ?? []) as unknown as RankedProjectEntry[]"
|
||||
- "getFullDetail response shape: { project, assignments, stats } — title accessed as projectDetail.project.title"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- src/components/admin/round/ranking-dashboard.tsx
|
||||
|
||||
key-decisions:
|
||||
- "Double cast (as unknown as RankedProjectEntry[]) required for Prisma JsonValue — direct cast rejected by TypeScript strict mode"
|
||||
- "getFullDetail returns { project, assignments, stats } shape, not flat — project title accessed via .project.title"
|
||||
- "saveReorder mutation has no onSuccess invalidation — avoids triggering re-fetch that would reset localOrder"
|
||||
|
||||
patterns-established:
|
||||
- "SortableProjectRow sub-component defined above export in same file (no separate file needed for inline sub-components)"
|
||||
- "Per-category drag context: separate DndContext per category prevents cross-category drag"
|
||||
|
||||
requirements-completed: [DASH-01, DASH-02, DASH-03, DASH-04]
|
||||
|
||||
# Metrics
|
||||
duration: 8min
|
||||
completed: 2026-02-27
|
||||
---
|
||||
|
||||
# Phase 2 Plan 02: Full RankingDashboard Component Summary
|
||||
|
||||
**Full RankingDashboard with per-category drag-and-drop (dnd-kit), AI vs override rank badges, snap-back-proof localOrder state, and lazy-loaded Sheet detail panel showing per-juror evaluation breakdown**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~8 min
|
||||
- **Started:** 2026-02-27T08:40:00Z
|
||||
- **Completed:** 2026-02-27T08:48:11Z
|
||||
- **Tasks:** 1
|
||||
- **Files modified:** 1
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Replaced RankingDashboard stub with full 486-line implementation
|
||||
- DASH-01: Ranked project list per category (STARTUP / BUSINESS_CONCEPT) with composite score, pass rate, and evaluator count displayed per row
|
||||
- DASH-02: Drag-and-drop reorder via GripVertical handle using dnd-kit (DndContext + SortableContext + useSortable), fire-and-forget saveReorder mutation
|
||||
- DASH-03: localOrder stored in useState with useRef guard (`initialized.current`) — init fires once on first snapshot load, never re-initialized from server data; no snap-back
|
||||
- DASH-04: Sheet panel opens on row click, lazy-loads `trpc.project.getFullDetail` (enabled only when selectedProjectId is set), displays stats summary and per-juror evaluation list filtered to SUBMITTED assignments for the current round
|
||||
- AI-order rows display dark-blue rank badge (#N); admin-reordered rows display amber `#N (override)` badge
|
||||
- "Run Ranking" button in header card calls `triggerAutoRank`, resets `initialized.current` to allow re-init on new snapshot
|
||||
- Empty categories show a placeholder message instead of an empty drag zone
|
||||
- TypeScript strict mode: 0 errors; build: PASSED
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | Build full RankingDashboard component | 6512e4e | src/components/admin/round/ranking-dashboard.tsx |
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `src/components/admin/round/ranking-dashboard.tsx` — Full component replacing stub (486 lines → includes SortableProjectRow sub-component + RankingDashboard main export)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Double cast via unknown**: `(json ?? []) as unknown as RankedProjectEntry[]` — TypeScript strict mode rejects direct cast from Prisma `JsonValue`; intermediate `unknown` is required. Matches pattern from Phase 01-03.
|
||||
- **getFullDetail response shape**: The procedure returns `{ project, assignments, stats }` (not flat) — `projectDetail.project.title`, not `projectDetail.title`.
|
||||
- **No onSuccess invalidation in saveReorder**: Calling `utils.ranking.getSnapshot.invalidate()` in `onSuccess` would trigger a re-fetch that resets `localOrder` to server data, causing snap-back. Mutation only shows toast on error.
|
||||
- **Per-category DndContext**: Separate `DndContext` per category prevents accidental cross-category drags.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — plan executed exactly as written. All type errors encountered were auto-fixed inline (Rule 1 — double cast pattern, Rule 1 — response shape).
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Prisma JsonValue cast requires double cast via unknown**
|
||||
- **Found during:** Task 1 (typecheck)
|
||||
- **Issue:** `(snapshot.startupRankingJson ?? []) as RankedProjectEntry[]` — TypeScript strict mode rejects because `JsonValue` and `RankedProjectEntry[]` don't sufficiently overlap
|
||||
- **Fix:** Changed to `as unknown as RankedProjectEntry[]` (identical pattern used in Phase 01-03)
|
||||
- **Files modified:** ranking-dashboard.tsx
|
||||
- **Commit:** 6512e4e (same task commit)
|
||||
|
||||
**2. [Rule 1 - Bug] getFullDetail response shape — title not on root**
|
||||
- **Found during:** Task 1 (typecheck)
|
||||
- **Issue:** `projectDetail?.title` — getFullDetail returns `{ project, assignments, stats }`, not a flat object
|
||||
- **Fix:** Changed to `projectDetail?.project.title`
|
||||
- **Files modified:** ranking-dashboard.tsx
|
||||
- **Commit:** 6512e4e (same task commit)
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None beyond the two auto-fixed type errors above.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- RankingDashboard fully functional — admin can view ranked projects, drag to reorder, see juror-level evaluation details in Sheet panel
|
||||
- Plan 03 can now add the "Advance Projects" action button to the dashboard header
|
||||
- saveReorder mutation is append-only audit log — Plan 03 can read latest reorder per category to determine final advance order
|
||||
- Build and typecheck both pass with 0 errors
|
||||
|
||||
---
|
||||
*Phase: 02-ranking-dashboard-ui*
|
||||
*Completed: 2026-02-27*
|
||||
104
.planning/phases/02-ranking-dashboard-ui/02-03-SUMMARY.md
Normal file
104
.planning/phases/02-ranking-dashboard-ui/02-03-SUMMARY.md
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
phase: 02-ranking-dashboard-ui
|
||||
plan: 03
|
||||
subsystem: ui
|
||||
tags: [react, trpc, dialog, shadcn, ranking, advance-projects, batch-reject]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 02-02
|
||||
provides: Full RankingDashboard with drag-and-drop, AI vs override badges, Sheet detail panel, saveReorderMutation
|
||||
provides:
|
||||
- Advance Top N dialog wired to trpc.round.advanceProjects with per-category N inputs
|
||||
- Batch-reject checkbox in advance dialog wired to trpc.roundEngine.batchTransition
|
||||
- DASH-07 disabled state: Advance button disabled while saveReorderMutation.isPending
|
||||
affects: []
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "pendingReorderCount useRef pattern: onMutate increments, onSettled decrements — provides belt-and-suspenders for rapid drag scenarios alongside isPending reactive signal"
|
||||
- "Parallel mutation from single handler: advanceMutation.mutate + batchRejectMutation.mutate fired in same handleAdvance — fire-and-forget both, each handles own onSuccess/onError"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- src/components/admin/round/ranking-dashboard.tsx
|
||||
|
||||
key-decisions:
|
||||
- "Advance button disabled via saveReorderMutation.isPending (reactive) not pendingReorderCount.current (ref, non-reactive) — ref used for belt-and-suspenders coverage, boolean state for actual UI"
|
||||
- "topNStartup + topNConceptual === 0 disables the Advance button inside the dialog — prevents no-op advance calls"
|
||||
- "batchRejectMutation fired conditionally (only if includeReject and rejectIds.length > 0) — avoids empty batch call"
|
||||
|
||||
patterns-established:
|
||||
- "Per-category N inputs with min/max clamping via Math.max/Math.min on parseInt — prevents out-of-range values"
|
||||
- "Preview section in dialog: live count of advancing/rejecting projects — feedback before confirmation"
|
||||
|
||||
requirements-completed: [DASH-05, DASH-06, DASH-07]
|
||||
|
||||
# Metrics
|
||||
duration: 5min
|
||||
completed: 2026-02-27
|
||||
---
|
||||
|
||||
# Phase 2 Plan 03: Advance Top N Dialog + Batch-Reject Summary
|
||||
|
||||
**Advance Top N dialog with per-category numeric inputs, optional batch-reject checkbox, and disabled state tied to pending reorder mutations — completing the full advancement workflow for the RankingDashboard**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~5 min
|
||||
- **Started:** 2026-02-27T08:51:01Z
|
||||
- **Completed:** 2026-02-27T08:56:00Z
|
||||
- **Tasks:** 1
|
||||
- **Files modified:** 1
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- DASH-05: Admin can click "Advance Top N" button to open a dialog, enter a number N per category, and advance the top N projects from each category to the next round
|
||||
- DASH-06: Admin can enable "Also batch-reject non-advanced projects" checkbox in the dialog — fires batchTransition for non-advanced projects; toasts use `.succeeded.length` per MEMORY.md
|
||||
- DASH-07: Advance button is disabled while `saveReorderMutation.isPending` is true (reorder in flight) and when no snapshot exists
|
||||
- pendingReorderCount ref added to saveReorderMutation (onMutate++, onSettled--) for belt-and-suspenders reorder tracking
|
||||
- Full dialog: per-category N inputs with range clamping, live preview of advance/reject counts, cancel/confirm buttons
|
||||
- Both mutations invalidate `roundEngine.getProjectStates` on success to refresh downstream state
|
||||
- TypeScript strict mode: 0 errors; build: PASSED
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | Add Advance Top N dialog + batch-reject to RankingDashboard | a6f3945 | src/components/admin/round/ranking-dashboard.tsx |
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `src/components/admin/round/ranking-dashboard.tsx` — Added Dialog, Input, Label imports; pendingReorderCount ref; advanceMutation; batchRejectMutation; handleAdvance; Advance Top N button; Dialog JSX (681 lines total, +197 insertions)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Advance button disabled state uses `saveReorderMutation.isPending`** (reactive) as the primary signal, not `pendingReorderCount.current` (useRef is not reactive — React won't re-render on ref mutation). The ref still provides belt-and-suspenders for rapid multi-drag scenarios but is not the gating signal.
|
||||
- **topNStartup + topNConceptual === 0** disables the confirm button inside the dialog — prevents a no-op advance call when both inputs are zero.
|
||||
- **batchRejectMutation fires conditionally** — only when `includeReject` is true AND `rejectIds.length > 0`. Avoids sending an empty projectIds array to the mutation.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- All 7 DASH requirements (DASH-01 through DASH-07) completed across plans 01-03
|
||||
- Phase 2 (Ranking Dashboard UI) is fully complete
|
||||
- Phase 3 (Notification Templates) or Phase 4 (Juror Criterion Progress) can begin next
|
||||
- Full build and typecheck pass with 0 errors
|
||||
|
||||
---
|
||||
*Phase: 02-ranking-dashboard-ui*
|
||||
*Completed: 2026-02-27*
|
||||
108
docs/plans/2026-02-25-advance-criterion-design.md
Normal file
108
docs/plans/2026-02-25-advance-criterion-design.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Advance Criterion & Juror Progress Dashboard
|
||||
|
||||
**Date:** 2026-02-25
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
Jurors have no visibility into their evaluation progress — specifically how many YES/NO advancement decisions they've made and for which projects. Admins similarly lack a quick summary of advancement votes across the jury.
|
||||
|
||||
## Solution
|
||||
|
||||
A new `advance` criterion type, a juror-facing progress dashboard, and admin dashboard enhancements.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. New Criterion Type: `advance`
|
||||
|
||||
Added alongside `numeric`, `text`, `boolean`, `section_header`.
|
||||
|
||||
**Shape in `criteriaJson`:**
|
||||
|
||||
```ts
|
||||
{
|
||||
id: string,
|
||||
label: string, // default "Advance to next round?"
|
||||
description?: string,
|
||||
type: "advance",
|
||||
trueLabel: string, // default "Yes"
|
||||
falseLabel: string, // default "No"
|
||||
required: true // always required, not configurable
|
||||
}
|
||||
```
|
||||
|
||||
**Storage:** `criterionScoresJson` as `{ [criterionId]: true | false }`.
|
||||
|
||||
**Constraints:**
|
||||
- Max one per `EvaluationForm` (enforced in form builder UI and server-side on upsert)
|
||||
- Always `required: true`
|
||||
- No `weight` — does not factor into numeric average
|
||||
- No `condition` — always visible, never conditional
|
||||
|
||||
### 2. Round Config Addition
|
||||
|
||||
New field in `EvaluationConfig` (JSON column, no migration needed):
|
||||
|
||||
```ts
|
||||
showJurorProgressDashboard: boolean // default false
|
||||
```
|
||||
|
||||
Admin toggle in round config to enable/disable the juror progress view.
|
||||
|
||||
### 3. Juror Progress Dashboard
|
||||
|
||||
**Location:** Collapsible card above the project assignment cards on `/jury/competitions/[roundId]`, gated by `showJurorProgressDashboard`.
|
||||
|
||||
**Data source:** New tRPC query `evaluation.getMyProgress(roundId)` returning:
|
||||
|
||||
```ts
|
||||
{
|
||||
total: number,
|
||||
completed: number,
|
||||
advanceCounts: { yes: number, no: number },
|
||||
submissions: Array<{
|
||||
projectId: string,
|
||||
projectName: string,
|
||||
submittedAt: Date,
|
||||
advanceDecision: boolean | null,
|
||||
criterionScores: Array<{ label: string, value: number }>,
|
||||
numericAverage: number | null,
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
**UI:**
|
||||
- Progress bar: `completed / total` with percentage (shadcn Progress)
|
||||
- Advance summary: `X YES · Y NO` inline badges
|
||||
- Submissions table: Project Name | Numeric Average | per-criterion scores | Advance (green YES / red NO badge) | Date — sorted by `submittedAt` DESC, submitted evaluations only
|
||||
|
||||
### 4. Admin Dashboard Changes
|
||||
|
||||
**Summary card** (`AdvancementSummaryCard`):
|
||||
- Renders on round detail page for EVALUATION rounds when form contains an `advance` criterion
|
||||
- Donut/bar visual: YES / NO / Pending counts with percentages
|
||||
|
||||
**Assignments table:**
|
||||
- New "Advance" column after Score column
|
||||
- Green YES / red NO / gray "—" badges
|
||||
|
||||
### 5. Form Builder Changes
|
||||
|
||||
- New button `+ Advance to Next Round?` alongside existing add buttons
|
||||
- Disabled with tooltip when one already exists in the form
|
||||
- Edit mode: `trueLabel`/`falseLabel` customization, description field, `required` locked true, no weight/condition
|
||||
- Juror rendering: two prominent buttons with green/red color treatment
|
||||
|
||||
### 6. Scope Boundaries
|
||||
|
||||
**In scope:**
|
||||
- `advance` criterion type (form builder, juror rendering, server validation)
|
||||
- Juror progress dashboard (gated by round config toggle)
|
||||
- Admin summary card and table column
|
||||
- One new tRPC query
|
||||
|
||||
**Out of scope:**
|
||||
- No changes to `binaryDecision` field or `scoringMode: "binary"`
|
||||
- No changes to AI summary generation
|
||||
- No schema migration (all JSON columns)
|
||||
- Export unchanged (advance values flow through `criterionScoresJson` automatically)
|
||||
844
docs/plans/2026-02-25-advance-criterion-plan.md
Normal file
844
docs/plans/2026-02-25-advance-criterion-plan.md
Normal file
@@ -0,0 +1,844 @@
|
||||
# Advance Criterion & Juror Progress Dashboard — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add an `advance` criterion type to the evaluation form system, a juror-facing progress dashboard showing past submissions with scores and advance decisions, and admin-facing summary card + table column for advancement votes.
|
||||
|
||||
**Architecture:** The `advance` type is added to the existing criterion type union and flows through the same `criteriaJson`/`criterionScoresJson` JSON columns — no Prisma schema migration. A new `showJurorProgressDashboard` field in `EvaluationConfig` gates the juror view. A new tRPC query aggregates the juror's submissions. Admin components get an extra column and a summary card.
|
||||
|
||||
**Tech Stack:** TypeScript, tRPC, Prisma (JSON columns), React, shadcn/ui, Tailwind CSS, Zod
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `advance` to CriterionType and Form Builder Types
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/forms/evaluation-form-builder.tsx:57` (CriterionType union)
|
||||
- Modify: `src/components/forms/evaluation-form-builder.tsx:96-114` (createDefaultCriterion)
|
||||
- Modify: `src/components/forms/evaluation-form-builder.tsx:117-122` (CRITERION_TYPE_OPTIONS)
|
||||
|
||||
**Step 1: Update the CriterionType union**
|
||||
|
||||
In `evaluation-form-builder.tsx` line 57, change:
|
||||
```ts
|
||||
export type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'
|
||||
```
|
||||
to:
|
||||
```ts
|
||||
export type CriterionType = 'numeric' | 'text' | 'boolean' | 'advance' | 'section_header'
|
||||
```
|
||||
|
||||
**Step 2: Add default creation for `advance` type**
|
||||
|
||||
In `createDefaultCriterion` (line 96), add a new case before `section_header`:
|
||||
```ts
|
||||
case 'advance':
|
||||
return { ...base, label: 'Advance to next round?', trueLabel: 'Yes', falseLabel: 'No', required: true }
|
||||
```
|
||||
|
||||
**Step 3: Add `advance` to the type options array**
|
||||
|
||||
In `CRITERION_TYPE_OPTIONS` (line 117), add an import for a suitable icon (e.g., `ArrowUpCircle` from lucide-react) and add the entry. Note: this button will be rendered separately with disable logic, so do NOT add it to `CRITERION_TYPE_OPTIONS`. Instead, we'll add a standalone button in Task 2.
|
||||
|
||||
Actually — to keep things clean, do NOT add `advance` to `CRITERION_TYPE_OPTIONS`. The advance button is rendered separately with one-per-form enforcement. See Task 2.
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add src/components/forms/evaluation-form-builder.tsx
|
||||
git commit -m "feat: add advance criterion type to CriterionType union and defaults"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add "Advance to Next Round?" Button in Form Builder
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/forms/evaluation-form-builder.tsx:39-54` (imports — add ArrowUpCircle)
|
||||
- Modify: `src/components/forms/evaluation-form-builder.tsx:671-690` (add buttons section)
|
||||
|
||||
**Step 1: Add the `ArrowUpCircle` icon import**
|
||||
|
||||
At line 39 in the lucide-react import block, add `ArrowUpCircle` to the imports.
|
||||
|
||||
**Step 2: Add the advance button with one-per-form enforcement**
|
||||
|
||||
After the `CRITERION_TYPE_OPTIONS.map(...)` buttons (around line 685), before the PreviewDialog, add:
|
||||
```tsx
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addCriterion('advance')}
|
||||
disabled={editingId !== null || criteria.some((c) => c.type === 'advance')}
|
||||
title={criteria.some((c) => c.type === 'advance') ? 'Only one advance criterion allowed per form' : undefined}
|
||||
className={cn(
|
||||
'border-brand-blue/40 text-brand-blue hover:bg-brand-blue/5',
|
||||
criteria.some((c) => c.type === 'advance') && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<ArrowUpCircle className="mr-1 h-4 w-4" />
|
||||
Advance to Next Round?
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add src/components/forms/evaluation-form-builder.tsx
|
||||
git commit -m "feat: add advance criterion button with one-per-form enforcement"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add Edit Mode and Preview for `advance` Criterion
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/forms/evaluation-form-builder.tsx` — edit mode section (around lines 237-414)
|
||||
- Modify: `src/components/forms/evaluation-form-builder.tsx` — preview dialog (around lines 787-798)
|
||||
- Modify: `src/components/forms/evaluation-form-builder.tsx` — type badge display in list view
|
||||
|
||||
**Step 1: Add edit mode fields for `advance` type**
|
||||
|
||||
In the edit mode form (after the `boolean` block ending around line 414), add a block for `advance`:
|
||||
```tsx
|
||||
{(editDraft.type) === 'advance' && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`trueLabel-${criterion.id}`}>Yes Label</Label>
|
||||
<Input
|
||||
id={`trueLabel-${criterion.id}`}
|
||||
value={editDraft.trueLabel || 'Yes'}
|
||||
onChange={(e) => updateDraft({ trueLabel: e.target.value })}
|
||||
placeholder="Yes"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`falseLabel-${criterion.id}`}>No Label</Label>
|
||||
<Input
|
||||
id={`falseLabel-${criterion.id}`}
|
||||
value={editDraft.falseLabel || 'No'}
|
||||
onChange={(e) => updateDraft({ falseLabel: e.target.value })}
|
||||
placeholder="No"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
Note: No `required` toggle (always true), no `weight`, no `condition` fields for advance type.
|
||||
|
||||
**Step 2: Add the type badge rendering**
|
||||
|
||||
Find where the type badge is shown in list view (around line 237-240). The existing code uses `CRITERION_TYPE_OPTIONS.find(...)`. For `advance`, it won't find a match so will show nothing. Add a fallback or handle it. Where the badge text is resolved, add:
|
||||
```ts
|
||||
editDraft.type === 'advance' ? 'Advance to Next Round?' : CRITERION_TYPE_OPTIONS.find(...)?.label ?? 'Numeric Score'
|
||||
```
|
||||
|
||||
**Step 3: Add preview rendering for `advance` type**
|
||||
|
||||
In the PreviewDialog (around line 787), after the `boolean` rendering block, add:
|
||||
```tsx
|
||||
{type === 'advance' && (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 h-14 rounded-lg border-2 border-emerald-300 bg-emerald-50/50 flex items-center justify-center text-sm font-semibold text-emerald-700">
|
||||
<ThumbsUp className="mr-2 h-4 w-4" />
|
||||
{criterion.trueLabel || 'Yes'}
|
||||
</div>
|
||||
<div className="flex-1 h-14 rounded-lg border-2 border-red-300 bg-red-50/50 flex items-center justify-center text-sm font-semibold text-red-700">
|
||||
<ThumbsDown className="mr-2 h-4 w-4" />
|
||||
{criterion.falseLabel || 'No'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add src/components/forms/evaluation-form-builder.tsx
|
||||
git commit -m "feat: add edit mode and preview rendering for advance criterion type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Server-Side — Accept `advance` in `upsertForm` and `submit` Validation
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/evaluation.ts:1230` (upsertForm Zod input — add 'advance' to type enum)
|
||||
- Modify: `src/server/routers/evaluation.ts:1270-1304` (criteriaJson builder — add advance case)
|
||||
- Modify: `src/server/routers/evaluation.ts:238-260` (submit validation — handle advance type)
|
||||
|
||||
**Step 1: Add `advance` to the Zod type enum in upsertForm input**
|
||||
|
||||
At line 1230, change:
|
||||
```ts
|
||||
type: z.enum(['numeric', 'text', 'boolean', 'section_header']).optional(),
|
||||
```
|
||||
to:
|
||||
```ts
|
||||
type: z.enum(['numeric', 'text', 'boolean', 'advance', 'section_header']).optional(),
|
||||
```
|
||||
|
||||
**Step 2: Add advance case in criteriaJson builder**
|
||||
|
||||
After the `boolean` case (line 1295-1300), add:
|
||||
```ts
|
||||
if (type === 'advance') {
|
||||
return {
|
||||
...base,
|
||||
required: true, // always required, override any input
|
||||
trueLabel: c.trueLabel || 'Yes',
|
||||
falseLabel: c.falseLabel || 'No',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add server-side one-per-form validation**
|
||||
|
||||
In the `upsertForm` mutation, after line 1256 (`const { roundId, criteria } = input`), add:
|
||||
```ts
|
||||
// Enforce max one advance criterion per form
|
||||
const advanceCount = criteria.filter((c) => c.type === 'advance').length
|
||||
if (advanceCount > 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Only one advance criterion is allowed per evaluation form',
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Handle `advance` in submit validation**
|
||||
|
||||
In the `requireAllCriteriaScored` block (line 242-252), the `scorableCriteria` filter excludes `section_header` and `text`. The `advance` type should be treated like `boolean` — it's a required boolean. Update the missing criteria check:
|
||||
|
||||
At line 250, change:
|
||||
```ts
|
||||
if (c.type === 'boolean') return typeof val !== 'boolean'
|
||||
```
|
||||
to:
|
||||
```ts
|
||||
if (c.type === 'boolean' || c.type === 'advance') return typeof val !== 'boolean'
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
```bash
|
||||
git add src/server/routers/evaluation.ts
|
||||
git commit -m "feat: server-side support for advance criterion type in upsertForm and submit"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Juror Evaluation Page — Render `advance` Criterion
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx:660-703` (boolean rendering — add advance case)
|
||||
- Modify: same file, client-side validation (around line 355-360)
|
||||
|
||||
**Step 1: Add advance criterion rendering in the evaluation form**
|
||||
|
||||
After the boolean rendering block (line 660-703), add a new block for `advance`. It should look similar to boolean but with larger, more prominent buttons and a colored border:
|
||||
|
||||
```tsx
|
||||
if (criterion.type === 'advance') {
|
||||
const currentValue = criteriaValues[criterion.id]
|
||||
return (
|
||||
<div key={criterion.id} className="space-y-3 p-5 border-2 border-brand-blue/30 rounded-xl bg-brand-blue/5">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base font-semibold text-brand-blue">
|
||||
{criterion.label}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCriterionChange(criterion.id, true)}
|
||||
className={cn(
|
||||
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
|
||||
currentValue === true
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200'
|
||||
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
|
||||
)}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-5 w-5" />
|
||||
{criterion.trueLabel || 'Yes'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCriterionChange(criterion.id, false)}
|
||||
className={cn(
|
||||
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
|
||||
currentValue === false
|
||||
? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200'
|
||||
: 'border-border hover:border-red-300 hover:bg-red-50/50'
|
||||
)}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-5 w-5" />
|
||||
{criterion.falseLabel || 'No'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Update client-side validation**
|
||||
|
||||
In the client-side submit validation (around line 355-360), where boolean required criteria are checked, ensure `advance` is also handled. Find the block that checks for boolean criteria values and add `|| c.type === 'advance'` to the condition.
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add "src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx"
|
||||
git commit -m "feat: render advance criterion on juror evaluation page with prominent styling"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add `showJurorProgressDashboard` to EvaluationConfig
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/types/competition-configs.ts:90-141` (EvaluationConfigSchema — add field)
|
||||
- Modify: `src/components/admin/rounds/config/evaluation-config.tsx` (add toggle)
|
||||
|
||||
**Step 1: Add the field to the Zod schema**
|
||||
|
||||
In `EvaluationConfigSchema` (line 90), add after line 103 (`peerReviewEnabled`):
|
||||
```ts
|
||||
showJurorProgressDashboard: z.boolean().default(false),
|
||||
```
|
||||
|
||||
**Step 2: Add the toggle in the admin config UI**
|
||||
|
||||
In `evaluation-config.tsx`, in the Feedback Requirements card (after the `peerReviewEnabled` switch, around line 176), add:
|
||||
```tsx
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="showJurorProgressDashboard">Juror Progress Dashboard</Label>
|
||||
<p className="text-xs text-muted-foreground">Show jurors a dashboard with their past evaluations, scores, and advance decisions</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="showJurorProgressDashboard"
|
||||
checked={(config.showJurorProgressDashboard as boolean) ?? false}
|
||||
onCheckedChange={(v) => update('showJurorProgressDashboard', v)}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add src/types/competition-configs.ts src/components/admin/rounds/config/evaluation-config.tsx
|
||||
git commit -m "feat: add showJurorProgressDashboard toggle to EvaluationConfig"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: New tRPC Query — `evaluation.getMyProgress`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/server/routers/evaluation.ts` (add new juryProcedure query at the end of the router)
|
||||
|
||||
**Step 1: Add the query**
|
||||
|
||||
Add this query to the `evaluationRouter` (before the closing `})` of the router):
|
||||
|
||||
```ts
|
||||
getMyProgress: juryProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { roundId } = input
|
||||
const userId = ctx.user.id
|
||||
|
||||
// Get all assignments for this juror in this round
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId, userId },
|
||||
include: {
|
||||
project: { select: { id: true, title: true } },
|
||||
evaluation: {
|
||||
include: { form: { select: { criteriaJson: true } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const total = assignments.length
|
||||
let completed = 0
|
||||
let advanceYes = 0
|
||||
let advanceNo = 0
|
||||
|
||||
const submissions: Array<{
|
||||
projectId: string
|
||||
projectName: string
|
||||
submittedAt: Date | null
|
||||
advanceDecision: boolean | null
|
||||
criterionScores: Array<{ label: string; value: number }>
|
||||
numericAverage: number | null
|
||||
}> = []
|
||||
|
||||
for (const a of assignments) {
|
||||
const ev = a.evaluation
|
||||
if (!ev || ev.status !== 'SUBMITTED') continue
|
||||
completed++
|
||||
|
||||
const criteria = (ev.form?.criteriaJson ?? []) as Array<{
|
||||
id: string; label: string; type?: string; weight?: number
|
||||
}>
|
||||
const scores = (ev.criterionScoresJson ?? {}) as Record<string, unknown>
|
||||
|
||||
// Find the advance criterion
|
||||
const advanceCriterion = criteria.find((c) => c.type === 'advance')
|
||||
let advanceDecision: boolean | null = null
|
||||
if (advanceCriterion) {
|
||||
const val = scores[advanceCriterion.id]
|
||||
if (typeof val === 'boolean') {
|
||||
advanceDecision = val
|
||||
if (val) advanceYes++
|
||||
else advanceNo++
|
||||
}
|
||||
}
|
||||
|
||||
// Collect numeric criterion scores
|
||||
const numericScores: Array<{ label: string; value: number }> = []
|
||||
for (const c of criteria) {
|
||||
if (c.type === 'numeric' || (!c.type && c.weight !== undefined)) {
|
||||
const val = scores[c.id]
|
||||
if (typeof val === 'number') {
|
||||
numericScores.push({ label: c.label, value: val })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const numericAverage = numericScores.length > 0
|
||||
? Math.round((numericScores.reduce((sum, s) => sum + s.value, 0) / numericScores.length) * 10) / 10
|
||||
: null
|
||||
|
||||
submissions.push({
|
||||
projectId: a.project.id,
|
||||
projectName: a.project.title,
|
||||
submittedAt: ev.submittedAt,
|
||||
advanceDecision,
|
||||
criterionScores: numericScores,
|
||||
numericAverage,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by most recent first
|
||||
submissions.sort((a, b) => {
|
||||
if (!a.submittedAt) return 1
|
||||
if (!b.submittedAt) return -1
|
||||
return b.submittedAt.getTime() - a.submittedAt.getTime()
|
||||
})
|
||||
|
||||
return {
|
||||
total,
|
||||
completed,
|
||||
advanceCounts: { yes: advanceYes, no: advanceNo },
|
||||
submissions,
|
||||
}
|
||||
}),
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
```bash
|
||||
git add src/server/routers/evaluation.ts
|
||||
git commit -m "feat: add evaluation.getMyProgress tRPC query for juror dashboard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Juror Progress Dashboard Component
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/jury/juror-progress-dashboard.tsx`
|
||||
|
||||
**Step 1: Create the component**
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ChevronDown, ChevronUp, ThumbsUp, ThumbsDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function JurorProgressDashboard({ roundId }: { roundId: string }) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const { data, isLoading } = trpc.evaluation.getMyProgress.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-32 w-full" />
|
||||
}
|
||||
|
||||
if (!data || data.total === 0) return null
|
||||
|
||||
const pct = Math.round((data.completed / data.total) * 100)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Your Progress</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={() => setExpanded(!expanded)}>
|
||||
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{data.completed} / {data.total} evaluated
|
||||
</span>
|
||||
<span className="font-medium">{pct}%</span>
|
||||
</div>
|
||||
<Progress value={pct} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* Advance summary */}
|
||||
{(data.advanceCounts.yes > 0 || data.advanceCounts.no > 0) && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">Advance:</span>
|
||||
<Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200">
|
||||
<ThumbsUp className="mr-1 h-3 w-3" />
|
||||
{data.advanceCounts.yes} Yes
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">
|
||||
<ThumbsDown className="mr-1 h-3 w-3" />
|
||||
{data.advanceCounts.no} No
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submissions table */}
|
||||
{expanded && data.submissions.length > 0 && (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/30">
|
||||
<th className="text-left px-3 py-2 font-medium">Project</th>
|
||||
<th className="text-center px-3 py-2 font-medium">Avg Score</th>
|
||||
{data.submissions[0]?.criterionScores.map((cs, i) => (
|
||||
<th key={i} className="text-center px-2 py-2 font-medium text-xs max-w-[80px] truncate" title={cs.label}>
|
||||
{cs.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="text-center px-3 py-2 font-medium">Advance</th>
|
||||
<th className="text-right px-3 py-2 font-medium">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.submissions.map((s) => (
|
||||
<tr key={s.projectId} className="border-b last:border-0 hover:bg-muted/20">
|
||||
<td className="px-3 py-2 font-medium truncate max-w-[200px]">{s.projectName}</td>
|
||||
<td className="text-center px-3 py-2">
|
||||
{s.numericAverage != null ? (
|
||||
<span className="font-semibold">{s.numericAverage}</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
{s.criterionScores.map((cs, i) => (
|
||||
<td key={i} className="text-center px-2 py-2 text-muted-foreground">{cs.value}</td>
|
||||
))}
|
||||
<td className="text-center px-3 py-2">
|
||||
{s.advanceDecision === true ? (
|
||||
<Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200 text-xs">YES</Badge>
|
||||
) : s.advanceDecision === false ? (
|
||||
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200 text-xs">NO</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-2 text-muted-foreground text-xs whitespace-nowrap">
|
||||
{s.submittedAt ? new Date(s.submittedAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
```bash
|
||||
git add src/components/jury/juror-progress-dashboard.tsx
|
||||
git commit -m "feat: create JurorProgressDashboard component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Wire Juror Progress Dashboard into Round Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(jury)/jury/competitions/[roundId]/page.tsx`
|
||||
|
||||
**Step 1: Import the component and add it to the page**
|
||||
|
||||
Add import at the top:
|
||||
```ts
|
||||
import { JurorProgressDashboard } from '@/components/jury/juror-progress-dashboard'
|
||||
```
|
||||
|
||||
**Step 2: Fetch round config and conditionally render**
|
||||
|
||||
The page already fetches `round` via `trpc.round.getById.useQuery`. Use it to check the config:
|
||||
|
||||
After the heading `<div>` (around line 53) and before the `<Card>` with "Assigned Projects" (line 56), add:
|
||||
```tsx
|
||||
{(() => {
|
||||
const config = (round?.configJson as Record<string, unknown>) ?? {}
|
||||
if (config.showJurorProgressDashboard) {
|
||||
return <JurorProgressDashboard roundId={roundId} />
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add "src/app/(jury)/jury/competitions/[roundId]/page.tsx"
|
||||
git commit -m "feat: wire JurorProgressDashboard into jury round detail page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Admin — Add "Advance" Column to Assignments Table
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/admin/assignment/individual-assignments-table.tsx:315-319` (column header)
|
||||
- Modify: `src/components/admin/assignment/individual-assignments-table.tsx:325-351` (row rendering)
|
||||
|
||||
**Step 1: Add the column header**
|
||||
|
||||
At line 315, change the grid from `grid-cols-[1fr_1fr_100px_70px]` to `grid-cols-[1fr_1fr_80px_80px_70px]` and add an "Advance" header:
|
||||
```tsx
|
||||
<div className="grid grid-cols-[1fr_1fr_80px_80px_70px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b">
|
||||
<span>Juror</span>
|
||||
<span>Project</span>
|
||||
<span>Status</span>
|
||||
<span>Advance</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 2: Update row grid and add the advance cell**
|
||||
|
||||
At line 325, update the grid class to match: `grid-cols-[1fr_1fr_80px_80px_70px]`.
|
||||
|
||||
After the Status cell (line 351 `</div>`) and before the DropdownMenu (line 352), add:
|
||||
```tsx
|
||||
<div className="flex items-center justify-center">
|
||||
{(() => {
|
||||
const ev = a.evaluation
|
||||
if (!ev || ev.status !== 'SUBMITTED') return <span className="text-muted-foreground text-xs">—</span>
|
||||
const criteria = (ev.form?.criteriaJson ?? []) as Array<{ id: string; type?: string }>
|
||||
const scores = (ev.criterionScoresJson ?? {}) as Record<string, unknown>
|
||||
const advCrit = criteria.find((c) => c.type === 'advance')
|
||||
if (!advCrit) return <span className="text-muted-foreground text-xs">—</span>
|
||||
const val = scores[advCrit.id]
|
||||
if (val === true) return <Badge variant="outline" className="text-[10px] bg-emerald-50 text-emerald-700 border-emerald-200">YES</Badge>
|
||||
if (val === false) return <Badge variant="outline" className="text-[10px] bg-red-50 text-red-700 border-red-200">NO</Badge>
|
||||
return <span className="text-muted-foreground text-xs">—</span>
|
||||
})()}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 3: Ensure the query includes form data**
|
||||
|
||||
Check that `trpc.assignment.listByStage` includes `evaluation.form` in its response. If it doesn't, we need to add `form: { select: { criteriaJson: true } }` to the evaluation include in the `listByStage` query in `src/server/routers/assignment.ts`. Look for the `listByStage` procedure and update its evaluation include.
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add src/components/admin/assignment/individual-assignments-table.tsx
|
||||
git add src/server/routers/assignment.ts # if modified
|
||||
git commit -m "feat: add Advance column to admin individual assignments table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Admin — Advancement Summary Card
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/admin/round/advancement-summary-card.tsx`
|
||||
|
||||
**Step 1: Create the component**
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ThumbsUp, ThumbsDown, Clock } from 'lucide-react'
|
||||
|
||||
export function AdvancementSummaryCard({ roundId }: { roundId: string }) {
|
||||
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 15_000 },
|
||||
)
|
||||
|
||||
if (isLoading) return <Skeleton className="h-40 w-full" />
|
||||
|
||||
if (!assignments || assignments.length === 0) return null
|
||||
|
||||
// Check if form has an advance criterion
|
||||
const firstSubmitted = assignments.find(
|
||||
(a: any) => a.evaluation?.status === 'SUBMITTED' && a.evaluation?.form?.criteriaJson
|
||||
)
|
||||
if (!firstSubmitted) return null
|
||||
|
||||
const criteria = ((firstSubmitted as any).evaluation?.form?.criteriaJson ?? []) as Array<{ id: string; type?: string }>
|
||||
const advanceCriterion = criteria.find((c) => c.type === 'advance')
|
||||
if (!advanceCriterion) return null
|
||||
|
||||
let yesCount = 0
|
||||
let noCount = 0
|
||||
let pendingCount = 0
|
||||
|
||||
for (const a of assignments as any[]) {
|
||||
const ev = a.evaluation
|
||||
if (!ev || ev.status !== 'SUBMITTED') {
|
||||
pendingCount++
|
||||
continue
|
||||
}
|
||||
const scores = (ev.criterionScoresJson ?? {}) as Record<string, unknown>
|
||||
const val = scores[advanceCriterion.id]
|
||||
if (val === true) yesCount++
|
||||
else if (val === false) noCount++
|
||||
else pendingCount++
|
||||
}
|
||||
|
||||
const total = yesCount + noCount + pendingCount
|
||||
const yesPct = total > 0 ? Math.round((yesCount / total) * 100) : 0
|
||||
const noPct = total > 0 ? Math.round((noCount / total) * 100) : 0
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Advancement Votes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-10 w-10 rounded-full bg-emerald-100 flex items-center justify-center">
|
||||
<ThumbsUp className="h-5 w-5 text-emerald-700" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-emerald-700">{yesCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Yes ({yesPct}%)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-10 w-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<ThumbsDown className="h-5 w-5 text-red-700" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-red-700">{noCount}</p>
|
||||
<p className="text-xs text-muted-foreground">No ({noPct}%)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-10 w-10 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Clock className="h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-600">{pendingCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Pending</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stacked bar */}
|
||||
<div className="mt-4 h-3 rounded-full bg-gray-100 overflow-hidden flex">
|
||||
{yesPct > 0 && <div className="bg-emerald-500 transition-all" style={{ width: `${yesPct}%` }} />}
|
||||
{noPct > 0 && <div className="bg-red-500 transition-all" style={{ width: `${noPct}%` }} />}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
```bash
|
||||
git add src/components/admin/round/advancement-summary-card.tsx
|
||||
git commit -m "feat: create AdvancementSummaryCard admin component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Wire Advancement Summary Card into Admin Round Detail
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (overview tab, around line 871)
|
||||
|
||||
**Step 1: Import the component**
|
||||
|
||||
Add at the imports section:
|
||||
```ts
|
||||
import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card'
|
||||
```
|
||||
|
||||
**Step 2: Add it to the overview tab**
|
||||
|
||||
In the overview tab content (after the Launch Readiness card, around line 943), add:
|
||||
```tsx
|
||||
{isEvaluation && <AdvancementSummaryCard roundId={roundId} />}
|
||||
```
|
||||
|
||||
Where `isEvaluation` is the existing variable that checks `round.roundType === 'EVALUATION'`.
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add "src/app/(admin)/admin/rounds/[roundId]/page.tsx"
|
||||
git commit -m "feat: wire AdvancementSummaryCard into admin round overview tab"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Build and Typecheck
|
||||
|
||||
**Step 1: Run typecheck**
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
Expected: No errors (fix any that appear).
|
||||
|
||||
**Step 2: Run build**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
Expected: Successful build.
|
||||
|
||||
**Step 3: Fix any issues and commit**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: resolve any type or build errors from advance criterion feature"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 14: Manual QA Checklist
|
||||
|
||||
Run `npm run dev` and verify:
|
||||
|
||||
1. **Form builder**: Admin can add "Advance to Next Round?" criterion. Button disables after one is added. Edit mode shows trueLabel/falseLabel. Preview renders correctly.
|
||||
2. **Juror evaluation**: Advance criterion renders with prominent green/red buttons. Required validation works. Autosave works. Submit stores value in `criterionScoresJson`.
|
||||
3. **Juror dashboard**: When `showJurorProgressDashboard` is enabled in round config, the progress card appears with progress bar, YES/NO counts, and submissions table sorted by date.
|
||||
4. **Admin config**: The "Juror Progress Dashboard" toggle appears in the Evaluation round config.
|
||||
5. **Admin assignments table**: "Advance" column appears with YES/NO/— badges.
|
||||
6. **Admin overview**: `AdvancementSummaryCard` renders with correct counts and stacked bar.
|
||||
@@ -0,0 +1,45 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RankingTriggerType" AS ENUM ('MANUAL', 'AUTO', 'RETROACTIVE', 'QUICK');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RankingMode" AS ENUM ('PREVIEW', 'CONFIRMED', 'QUICK');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RankingSnapshotStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RankingSnapshot" (
|
||||
"id" TEXT NOT NULL,
|
||||
"roundId" TEXT NOT NULL,
|
||||
"triggeredById" TEXT,
|
||||
"triggerType" "RankingTriggerType" NOT NULL DEFAULT 'MANUAL',
|
||||
"criteriaText" TEXT NOT NULL,
|
||||
"parsedRulesJson" JSONB NOT NULL,
|
||||
"startupRankingJson" JSONB,
|
||||
"conceptRankingJson" JSONB,
|
||||
"evaluationDataJson" JSONB,
|
||||
"mode" "RankingMode" NOT NULL DEFAULT 'PREVIEW',
|
||||
"status" "RankingSnapshotStatus" NOT NULL DEFAULT 'COMPLETED',
|
||||
"reordersJson" JSONB,
|
||||
"model" TEXT,
|
||||
"tokensUsed" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "RankingSnapshot_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RankingSnapshot_roundId_idx" ON "RankingSnapshot"("roundId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RankingSnapshot_triggeredById_idx" ON "RankingSnapshot"("triggeredById");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RankingSnapshot_createdAt_idx" ON "RankingSnapshot"("createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RankingSnapshot" ADD CONSTRAINT "RankingSnapshot_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RankingSnapshot" ADD CONSTRAINT "RankingSnapshot_triggeredById_fkey" FOREIGN KEY ("triggeredById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -425,6 +425,9 @@ model User {
|
||||
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
|
||||
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
|
||||
|
||||
// AI Ranking
|
||||
rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots")
|
||||
|
||||
@@index([role])
|
||||
@@index([status])
|
||||
}
|
||||
@@ -1405,6 +1408,74 @@ enum AssignmentJobStatus {
|
||||
FAILED
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AI RANKING MODELS
|
||||
// =============================================================================
|
||||
|
||||
enum RankingTriggerType {
|
||||
MANUAL // Admin clicked "Run ranking"
|
||||
AUTO // Auto-triggered by assignment completion
|
||||
RETROACTIVE // Retroactive scan on deployment
|
||||
QUICK // Quick-rank mode (no preview)
|
||||
}
|
||||
|
||||
enum RankingMode {
|
||||
PREVIEW // Parsed rules shown to admin (not yet applied)
|
||||
CONFIRMED // Admin confirmed rules, ranking applied
|
||||
QUICK // Quick-rank: parse + apply without preview
|
||||
}
|
||||
|
||||
enum RankingSnapshotStatus {
|
||||
PENDING
|
||||
RUNNING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
// Captures a point-in-time AI ranking run for a round
|
||||
model RankingSnapshot {
|
||||
id String @id @default(cuid())
|
||||
|
||||
roundId String
|
||||
|
||||
// Trigger metadata
|
||||
triggeredById String? // null = auto-triggered
|
||||
triggerType RankingTriggerType @default(MANUAL)
|
||||
|
||||
// Criteria used
|
||||
criteriaText String @db.Text
|
||||
parsedRulesJson Json @db.JsonB
|
||||
|
||||
// Results per category (either can be null/empty if no projects in that category)
|
||||
startupRankingJson Json? @db.JsonB
|
||||
conceptRankingJson Json? @db.JsonB
|
||||
|
||||
// Evaluation data freeze (raw scores at time of ranking)
|
||||
evaluationDataJson Json? @db.JsonB
|
||||
|
||||
// Mode and status
|
||||
mode RankingMode @default(PREVIEW)
|
||||
status RankingSnapshotStatus @default(COMPLETED)
|
||||
|
||||
// Post-drag-and-drop reorders (Phase 2 will populate this)
|
||||
reordersJson Json? @db.JsonB
|
||||
|
||||
// AI metadata
|
||||
model String?
|
||||
tokensUsed Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
round Round @relation("RoundRankingSnapshots", fields: [roundId], references: [id], onDelete: Cascade)
|
||||
triggeredBy User? @relation("TriggeredRankingSnapshots", fields: [triggeredById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([roundId])
|
||||
@@index([triggeredById])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// Tracks progress of long-running AI tagging jobs
|
||||
model TaggingJob {
|
||||
id String @id @default(cuid())
|
||||
@@ -2146,6 +2217,7 @@ model Round {
|
||||
filteringResults FilteringResult[]
|
||||
filteringJobs FilteringJob[]
|
||||
assignmentJobs AssignmentJob[]
|
||||
rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots")
|
||||
reminderLogs ReminderLog[]
|
||||
evaluationSummaries EvaluationSummary[]
|
||||
evaluationDiscussions EvaluationDiscussion[]
|
||||
|
||||
@@ -927,7 +927,7 @@ function EvaluationDetailSheet({
|
||||
|
||||
if (type === 'section_header') return null
|
||||
|
||||
if (type === 'boolean') {
|
||||
if (type === 'boolean' || type === 'advance') {
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between p-2.5 rounded-lg border">
|
||||
<span className="text-sm">{label}</span>
|
||||
|
||||
@@ -89,6 +89,7 @@ import { ProjectStatesTable } from '@/components/admin/round/project-states-tabl
|
||||
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
|
||||
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
|
||||
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
||||
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
|
||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
@@ -115,6 +116,7 @@ import { AIRecommendationsDisplay } from '@/components/admin/round/ai-recommenda
|
||||
import { EvaluationCriteriaEditor } from '@/components/admin/round/evaluation-criteria-editor'
|
||||
import { COIReviewSection } from '@/components/admin/assignment/coi-review-section'
|
||||
import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-section-header'
|
||||
import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card'
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -841,6 +843,7 @@ export default function RoundDetailPage() {
|
||||
{ value: 'projects', label: 'Projects', icon: Layers },
|
||||
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
|
||||
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []),
|
||||
...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []),
|
||||
...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
|
||||
{ value: 'config', label: 'Config', icon: Settings },
|
||||
...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []),
|
||||
@@ -942,6 +945,13 @@ export default function RoundDetailPage() {
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Advancement Votes Summary — only for EVALUATION rounds */}
|
||||
{isEvaluation && (
|
||||
<AnimatedCard index={1}>
|
||||
<AdvancementSummaryCard roundId={roundId} />
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Filtering Results Summary — only for FILTERING rounds with results */}
|
||||
{isFiltering && filteringStats && filteringStats.total > 0 && (
|
||||
<AnimatedCard index={1}>
|
||||
@@ -2038,6 +2048,13 @@ export default function RoundDetailPage() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* ═══════════ RANKING TAB (EVALUATION rounds) ═══════════ */}
|
||||
{isEvaluation && (
|
||||
<TabsContent value="ranking" className="space-y-4">
|
||||
<RankingDashboard competitionId={competitionId} roundId={roundId} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* ═══════════ CONFIG TAB ═══════════ */}
|
||||
<TabsContent value="config" className="space-y-6">
|
||||
{/* Round Dates */}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft, CheckCircle2, Clock, Circle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { JurorProgressDashboard } from '@/components/jury/juror-progress-dashboard'
|
||||
|
||||
export default function JuryRoundDetailPage() {
|
||||
const params = useParams()
|
||||
@@ -53,6 +54,14 @@ export default function JuryRoundDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const config = (round?.configJson as Record<string, unknown>) ?? {}
|
||||
if (config.showJurorProgressDashboard) {
|
||||
return <JurorProgressDashboard roundId={roundId} />
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Assigned Projects</CardTitle>
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
description: c.description,
|
||||
type: type as 'numeric' | 'text' | 'boolean' | 'section_header',
|
||||
type: type as 'numeric' | 'text' | 'boolean' | 'advance' | 'section_header',
|
||||
weight: c.weight,
|
||||
minScore,
|
||||
maxScore,
|
||||
@@ -352,7 +352,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
if (c.type === 'boolean' && val === undefined) {
|
||||
if ((c.type === 'boolean' || c.type === 'advance') && val === undefined) {
|
||||
toast.error(`Please answer "${c.label}"`)
|
||||
isSubmittingRef.current = false
|
||||
setIsSubmitting(false)
|
||||
@@ -657,6 +657,51 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
)
|
||||
}
|
||||
|
||||
if (criterion.type === 'advance') {
|
||||
const currentValue = criteriaValues[criterion.id]
|
||||
return (
|
||||
<div key={criterion.id} className="space-y-3 p-5 border-2 border-brand-blue/30 rounded-xl bg-brand-blue/5">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base font-semibold text-brand-blue">
|
||||
{criterion.label}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground">{criterion.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCriterionChange(criterion.id, true)}
|
||||
className={cn(
|
||||
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
|
||||
currentValue === true
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200'
|
||||
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
|
||||
)}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-5 w-5" />
|
||||
{criterion.trueLabel || 'Yes'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCriterionChange(criterion.id, false)}
|
||||
className={cn(
|
||||
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
|
||||
currentValue === false
|
||||
? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200'
|
||||
: 'border-border hover:border-red-300 hover:bg-red-50/50'
|
||||
)}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-5 w-5" />
|
||||
{criterion.falseLabel || 'No'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (criterion.type === 'boolean') {
|
||||
const currentValue = criteriaValues[criterion.id]
|
||||
return (
|
||||
|
||||
@@ -312,17 +312,18 @@ export function IndividualAssignmentsTable({
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-[500px] overflow-y-auto">
|
||||
<div className="grid grid-cols-[1fr_1fr_100px_70px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b">
|
||||
<div className="grid grid-cols-[1fr_1fr_80px_80px_70px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b">
|
||||
<span>Juror</span>
|
||||
<span>Project</span>
|
||||
<span>Status</span>
|
||||
<span>Advance</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{assignments.map((a: any, idx: number) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className={cn(
|
||||
'grid grid-cols-[1fr_1fr_100px_70px] gap-2 items-center px-3 py-2 rounded-md text-sm transition-colors',
|
||||
'grid grid-cols-[1fr_1fr_80px_80px_70px] gap-2 items-center px-3 py-2 rounded-md text-sm transition-colors',
|
||||
idx % 2 === 1 ? 'bg-muted/20' : 'hover:bg-muted/20',
|
||||
)}
|
||||
>
|
||||
@@ -349,6 +350,20 @@ export function IndividualAssignmentsTable({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
{(() => {
|
||||
const ev = a.evaluation
|
||||
if (!ev || ev.status !== 'SUBMITTED') return <span className="text-muted-foreground text-xs">—</span>
|
||||
const criteria = (ev.form?.criteriaJson ?? []) as Array<{ id: string; type?: string }>
|
||||
const scores = (ev.criterionScoresJson ?? {}) as Record<string, unknown>
|
||||
const advCrit = criteria.find((c: any) => c.type === 'advance')
|
||||
if (!advCrit) return <span className="text-muted-foreground text-xs">—</span>
|
||||
const val = scores[advCrit.id]
|
||||
if (val === true) return <Badge variant="outline" className="text-[10px] bg-emerald-50 text-emerald-700 border-emerald-200">YES</Badge>
|
||||
if (val === false) return <Badge variant="outline" className="text-[10px] bg-red-50 text-red-700 border-red-200">NO</Badge>
|
||||
return <span className="text-muted-foreground text-xs">—</span>
|
||||
})()}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
|
||||
93
src/components/admin/round/advancement-summary-card.tsx
Normal file
93
src/components/admin/round/advancement-summary-card.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ThumbsUp, ThumbsDown, Clock } from 'lucide-react'
|
||||
|
||||
export function AdvancementSummaryCard({ roundId }: { roundId: string }) {
|
||||
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 15_000 },
|
||||
)
|
||||
|
||||
if (isLoading) return <Skeleton className="h-40 w-full" />
|
||||
|
||||
if (!assignments || assignments.length === 0) return null
|
||||
|
||||
// Check if form has an advance criterion
|
||||
const firstSubmitted = assignments.find(
|
||||
(a: any) => a.evaluation?.status === 'SUBMITTED' && a.evaluation?.form?.criteriaJson
|
||||
)
|
||||
if (!firstSubmitted) return null
|
||||
|
||||
const criteria = ((firstSubmitted as any).evaluation?.form?.criteriaJson ?? []) as Array<{ id: string; type?: string }>
|
||||
const advanceCriterion = criteria.find((c) => c.type === 'advance')
|
||||
if (!advanceCriterion) return null
|
||||
|
||||
let yesCount = 0
|
||||
let noCount = 0
|
||||
let pendingCount = 0
|
||||
|
||||
for (const a of assignments as any[]) {
|
||||
const ev = a.evaluation
|
||||
if (!ev || ev.status !== 'SUBMITTED') {
|
||||
pendingCount++
|
||||
continue
|
||||
}
|
||||
const scores = (ev.criterionScoresJson ?? {}) as Record<string, unknown>
|
||||
const val = scores[advanceCriterion.id]
|
||||
if (val === true) yesCount++
|
||||
else if (val === false) noCount++
|
||||
else pendingCount++
|
||||
}
|
||||
|
||||
const total = yesCount + noCount + pendingCount
|
||||
const yesPct = total > 0 ? Math.round((yesCount / total) * 100) : 0
|
||||
const noPct = total > 0 ? Math.round((noCount / total) * 100) : 0
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Advancement Votes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-10 w-10 rounded-full bg-emerald-100 flex items-center justify-center">
|
||||
<ThumbsUp className="h-5 w-5 text-emerald-700" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-emerald-700">{yesCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Yes ({yesPct}%)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-10 w-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<ThumbsDown className="h-5 w-5 text-red-700" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-red-700">{noCount}</p>
|
||||
<p className="text-xs text-muted-foreground">No ({noPct}%)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-10 w-10 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Clock className="h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-600">{pendingCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Pending</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stacked bar */}
|
||||
<div className="mt-4 h-3 rounded-full bg-gray-100 overflow-hidden flex">
|
||||
{yesPct > 0 && <div className="bg-emerald-500 transition-all" style={{ width: `${yesPct}%` }} />}
|
||||
{noPct > 0 && <div className="bg-red-500 transition-all" style={{ width: `${noPct}%` }} />}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
680
src/components/admin/round/ranking-dashboard.tsx
Normal file
680
src/components/admin/round/ranking-dashboard.tsx
Normal file
@@ -0,0 +1,680 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from '@/components/ui/sheet'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
GripVertical,
|
||||
BarChart3,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Trophy,
|
||||
} from 'lucide-react'
|
||||
import type { RankedProjectEntry } from '@/server/services/ai-ranking'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type RankingDashboardProps = {
|
||||
competitionId: string
|
||||
roundId: string
|
||||
}
|
||||
|
||||
type SortableProjectRowProps = {
|
||||
projectId: string
|
||||
currentRank: number
|
||||
entry: RankedProjectEntry | undefined
|
||||
onSelect: () => void
|
||||
isSelected: boolean
|
||||
}
|
||||
|
||||
// ─── Sub-component: SortableProjectRow ────────────────────────────────────────
|
||||
|
||||
function SortableProjectRow({
|
||||
projectId,
|
||||
currentRank,
|
||||
entry,
|
||||
onSelect,
|
||||
isSelected,
|
||||
}: SortableProjectRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: projectId })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
// isOverridden: current position differs from AI-assigned rank
|
||||
const isOverridden = entry !== undefined && currentRank !== entry.rank
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border bg-card p-3 cursor-pointer transition-all hover:shadow-sm',
|
||||
isDragging && 'opacity-50 shadow-lg ring-2 ring-[#de0f1e]/30',
|
||||
isSelected && 'ring-2 ring-[#de0f1e]',
|
||||
)}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<button
|
||||
className="cursor-grab touch-none text-muted-foreground hover:text-foreground flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Rank badge */}
|
||||
{isOverridden ? (
|
||||
<Badge className="flex-shrink-0 bg-amber-100 text-amber-700 hover:bg-amber-100 border-amber-200 text-xs font-semibold">
|
||||
#{currentRank} (override)
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
className="flex-shrink-0 text-xs font-semibold"
|
||||
style={{ backgroundColor: '#053d57', color: '#fefefe' }}
|
||||
>
|
||||
#{currentRank}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Project identifier */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
Project …{projectId.slice(-6)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{entry && (
|
||||
<div className="flex items-center gap-4 flex-shrink-0 text-xs text-muted-foreground">
|
||||
<span title="Composite score">
|
||||
<BarChart3 className="inline h-3 w-3 mr-0.5" />
|
||||
{Math.round(entry.compositeScore * 100)}%
|
||||
</span>
|
||||
{entry.avgGlobalScore !== null && (
|
||||
<span title="Average global score">
|
||||
Avg {entry.avgGlobalScore.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
<span title="Pass rate">
|
||||
Pass {Math.round(entry.passRate * 100)}%
|
||||
</span>
|
||||
<span title="Evaluator count">
|
||||
{entry.evaluatorCount} juror{entry.evaluatorCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export function RankingDashboard({ competitionId: _competitionId, roundId }: RankingDashboardProps) {
|
||||
// ─── State ────────────────────────────────────────────────────────────────
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
|
||||
const [localOrder, setLocalOrder] = useState<Record<'STARTUP' | 'BUSINESS_CONCEPT', string[]>>({
|
||||
STARTUP: [],
|
||||
BUSINESS_CONCEPT: [],
|
||||
})
|
||||
const initialized = useRef(false)
|
||||
const pendingReorderCount = useRef(0)
|
||||
|
||||
// ─── Advance dialog state ─────────────────────────────────────────────────
|
||||
const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false)
|
||||
const [topNStartup, setTopNStartup] = useState(3)
|
||||
const [topNConceptual, setTopNConceptual] = useState(3)
|
||||
const [includeReject, setIncludeReject] = useState(false)
|
||||
|
||||
// ─── Sensors ──────────────────────────────────────────────────────────────
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
// ─── tRPC queries ─────────────────────────────────────────────────────────
|
||||
const { data: snapshots, isLoading: snapshotsLoading } = trpc.ranking.listSnapshots.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
const latestSnapshotId = snapshots?.[0]?.id ?? null
|
||||
const latestSnapshot = snapshots?.[0] ?? null
|
||||
|
||||
const { data: snapshot, isLoading: snapshotLoading } = trpc.ranking.getSnapshot.useQuery(
|
||||
{ snapshotId: latestSnapshotId! },
|
||||
{ enabled: !!latestSnapshotId },
|
||||
)
|
||||
|
||||
const { data: projectDetail, isLoading: detailLoading } = trpc.project.getFullDetail.useQuery(
|
||||
{ id: selectedProjectId! },
|
||||
{ enabled: !!selectedProjectId },
|
||||
)
|
||||
|
||||
// ─── tRPC mutations ───────────────────────────────────────────────────────
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const saveReorderMutation = trpc.ranking.saveReorder.useMutation({
|
||||
onMutate: () => { pendingReorderCount.current++ },
|
||||
onSettled: () => { pendingReorderCount.current-- },
|
||||
onError: (err) => toast.error(`Failed to save order: ${err.message}`),
|
||||
// Do NOT invalidate getSnapshot — would reset localOrder
|
||||
})
|
||||
|
||||
const triggerRankMutation = trpc.ranking.triggerAutoRank.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Ranking complete. Reload to see results.')
|
||||
initialized.current = false // allow re-init on next snapshot load
|
||||
void utils.ranking.listSnapshots.invalidate({ roundId })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const advanceMutation = trpc.round.advanceProjects.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`)
|
||||
void utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
setAdvanceDialogOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const batchRejectMutation = trpc.roundEngine.batchTransition.useMutation({
|
||||
onSuccess: (data) => {
|
||||
// MEMORY.md: use .length, not direct value comparison
|
||||
toast.success(`Rejected ${data.succeeded.length} project(s)`)
|
||||
if (data.failed.length > 0) {
|
||||
toast.warning(`${data.failed.length} project(s) could not be rejected`)
|
||||
}
|
||||
void utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
setAdvanceDialogOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// ─── rankingMap (O(1) lookup) ──────────────────────────────────────────────
|
||||
const rankingMap = useMemo(() => {
|
||||
const map = new Map<string, RankedProjectEntry>()
|
||||
if (!snapshot) return map
|
||||
const startup = (snapshot.startupRankingJson ?? []) as unknown as RankedProjectEntry[]
|
||||
const concept = (snapshot.conceptRankingJson ?? []) as unknown as RankedProjectEntry[]
|
||||
;[...startup, ...concept].forEach((entry) => map.set(entry.projectId, entry))
|
||||
return map
|
||||
}, [snapshot])
|
||||
|
||||
// ─── localOrder init (once, with useRef guard) ────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!initialized.current && snapshot) {
|
||||
const startup = (snapshot.startupRankingJson ?? []) as unknown as RankedProjectEntry[]
|
||||
const concept = (snapshot.conceptRankingJson ?? []) as unknown as RankedProjectEntry[]
|
||||
setLocalOrder({
|
||||
STARTUP: startup.map((r) => r.projectId),
|
||||
BUSINESS_CONCEPT: concept.map((r) => r.projectId),
|
||||
})
|
||||
initialized.current = true
|
||||
}
|
||||
}, [snapshot])
|
||||
|
||||
// ─── handleDragEnd ────────────────────────────────────────────────────────
|
||||
function handleDragEnd(category: 'STARTUP' | 'BUSINESS_CONCEPT', event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
setLocalOrder((prev) => {
|
||||
const ids = prev[category]
|
||||
const newIds = arrayMove(
|
||||
ids,
|
||||
ids.indexOf(active.id as string),
|
||||
ids.indexOf(over.id as string),
|
||||
)
|
||||
saveReorderMutation.mutate({
|
||||
snapshotId: latestSnapshotId!,
|
||||
category,
|
||||
orderedProjectIds: newIds,
|
||||
})
|
||||
return { ...prev, [category]: newIds }
|
||||
})
|
||||
}
|
||||
|
||||
// ─── handleAdvance ────────────────────────────────────────────────────────
|
||||
function handleAdvance() {
|
||||
const advanceIds = [
|
||||
...localOrder.STARTUP.slice(0, topNStartup),
|
||||
...localOrder.BUSINESS_CONCEPT.slice(0, topNConceptual),
|
||||
]
|
||||
const advanceSet = new Set(advanceIds)
|
||||
|
||||
advanceMutation.mutate({ roundId, projectIds: advanceIds })
|
||||
|
||||
if (includeReject) {
|
||||
const rejectIds = [...localOrder.STARTUP, ...localOrder.BUSINESS_CONCEPT].filter(
|
||||
(id) => !advanceSet.has(id),
|
||||
)
|
||||
if (rejectIds.length > 0) {
|
||||
batchRejectMutation.mutate({ projectIds: rejectIds, roundId, newState: 'REJECTED' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Loading state ────────────────────────────────────────────────────────
|
||||
if (snapshotsLoading || snapshotLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
<Skeleton className="h-48 w-full rounded-lg" />
|
||||
<Skeleton className="h-48 w-full rounded-lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Empty state ──────────────────────────────────────────────────────────
|
||||
if (!latestSnapshotId) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center gap-4 py-12 text-center">
|
||||
<BarChart3 className="h-10 w-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">No ranking available yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Run ranking from the Config tab to generate results, or trigger it now.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => triggerRankMutation.mutate({ roundId })}
|
||||
disabled={triggerRankMutation.isPending}
|
||||
>
|
||||
{triggerRankMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Run Ranking Now
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main content ─────────────────────────────────────────────────────────
|
||||
const categoryLabels: Record<'STARTUP' | 'BUSINESS_CONCEPT', string> = {
|
||||
STARTUP: 'Startups',
|
||||
BUSINESS_CONCEPT: 'Business Concepts',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{/* Header card */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base">Latest Ranking Snapshot</CardTitle>
|
||||
{latestSnapshot && (
|
||||
<CardDescription className="mt-1 space-y-0.5">
|
||||
<span>
|
||||
Created{' '}
|
||||
{new Date(latestSnapshot.createdAt).toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})}
|
||||
{latestSnapshot.triggeredBy?.name && ` by ${latestSnapshot.triggeredBy.name}`}
|
||||
{' · '}
|
||||
{latestSnapshot.triggerType}
|
||||
</span>
|
||||
{latestSnapshot.criteriaText && (
|
||||
<span className="block truncate text-xs">
|
||||
Criteria: {latestSnapshot.criteriaText.slice(0, 120)}
|
||||
{latestSnapshot.criteriaText.length > 120 ? '…' : ''}
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => triggerRankMutation.mutate({ roundId })}
|
||||
disabled={triggerRankMutation.isPending}
|
||||
>
|
||||
{triggerRankMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Run Ranking
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={saveReorderMutation.isPending || advanceMutation.isPending || !latestSnapshotId}
|
||||
onClick={() => setAdvanceDialogOpen(true)}
|
||||
className="bg-[#053d57] hover:bg-[#053d57]/90"
|
||||
>
|
||||
{advanceMutation.isPending ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Advancing...</>
|
||||
) : (
|
||||
<><Trophy className="h-4 w-4 mr-2" /> Advance Top N</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Per-category sections */}
|
||||
{(['STARTUP', 'BUSINESS_CONCEPT'] as const).map((category) => (
|
||||
<Card key={category}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{categoryLabels[category]}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{localOrder[category].length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No {category === 'STARTUP' ? 'startup' : 'business concept'} projects ranked.
|
||||
</p>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => handleDragEnd(category, event)}
|
||||
>
|
||||
<SortableContext
|
||||
items={localOrder[category]}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
<div className="space-y-2">
|
||||
{localOrder[category].map((projectId, index) => (
|
||||
<motion.div
|
||||
key={projectId}
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<SortableProjectRow
|
||||
projectId={projectId}
|
||||
currentRank={index + 1}
|
||||
entry={rankingMap.get(projectId)}
|
||||
onSelect={() => setSelectedProjectId(projectId)}
|
||||
isSelected={selectedProjectId === projectId}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Advance Top N dialog */}
|
||||
<Dialog open={advanceDialogOpen} onOpenChange={setAdvanceDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Advance Top Projects</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select how many top-ranked projects to advance to the next round per category.
|
||||
Projects are advanced in the order shown in the ranking list.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Top N for STARTUP */}
|
||||
{localOrder.STARTUP.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="w-40 text-sm">Startups to advance</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={localOrder.STARTUP.length}
|
||||
value={topNStartup}
|
||||
onChange={(e) =>
|
||||
setTopNStartup(
|
||||
Math.max(0, Math.min(localOrder.STARTUP.length, parseInt(e.target.value) || 0)),
|
||||
)
|
||||
}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">of {localOrder.STARTUP.length}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top N for BUSINESS_CONCEPT */}
|
||||
{localOrder.BUSINESS_CONCEPT.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="w-40 text-sm">Business concepts to advance</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={localOrder.BUSINESS_CONCEPT.length}
|
||||
value={topNConceptual}
|
||||
onChange={(e) =>
|
||||
setTopNConceptual(
|
||||
Math.max(0, Math.min(localOrder.BUSINESS_CONCEPT.length, parseInt(e.target.value) || 0)),
|
||||
)
|
||||
}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">of {localOrder.BUSINESS_CONCEPT.length}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional: also batch-reject non-advanced */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="includeReject"
|
||||
checked={includeReject}
|
||||
onChange={(e) => setIncludeReject(e.target.checked)}
|
||||
className="h-4 w-4 accent-[#de0f1e]"
|
||||
/>
|
||||
<Label htmlFor="includeReject" className="text-sm cursor-pointer">
|
||||
Also batch-reject non-advanced projects
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="text-xs text-muted-foreground bg-muted/50 rounded-md p-3">
|
||||
<p>Advancing: {topNStartup + topNConceptual} projects</p>
|
||||
{includeReject && (
|
||||
<p>
|
||||
Rejecting:{' '}
|
||||
{localOrder.STARTUP.length - topNStartup + (localOrder.BUSINESS_CONCEPT.length - topNConceptual)}{' '}
|
||||
projects
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAdvanceDialogOpen(false)}
|
||||
disabled={advanceMutation.isPending || batchRejectMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdvance}
|
||||
disabled={
|
||||
advanceMutation.isPending ||
|
||||
batchRejectMutation.isPending ||
|
||||
topNStartup + topNConceptual === 0
|
||||
}
|
||||
className="bg-[#053d57] hover:bg-[#053d57]/90"
|
||||
>
|
||||
{advanceMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Advancing...
|
||||
</>
|
||||
) : (
|
||||
`Advance ${topNStartup + topNConceptual} Project${topNStartup + topNConceptual !== 1 ? 's' : ''}`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Side panel Sheet */}
|
||||
<Sheet
|
||||
open={!!selectedProjectId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedProjectId(null)
|
||||
}}
|
||||
>
|
||||
<SheetContent className="w-[480px] sm:max-w-[480px] overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{projectDetail?.project.title ?? 'Project Details'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{selectedProjectId ? `ID: …${selectedProjectId.slice(-8)}` : ''}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{detailLoading ? (
|
||||
<div className="mt-6 space-y-3">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
) : projectDetail ? (
|
||||
<div className="mt-6 space-y-6">
|
||||
{/* Stats summary */}
|
||||
{projectDetail.stats && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">Avg Score</p>
|
||||
<p className="mt-1 text-lg font-semibold">
|
||||
{projectDetail.stats.averageGlobalScore?.toFixed(1) ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">Pass Rate</p>
|
||||
<p className="mt-1 text-lg font-semibold">
|
||||
{projectDetail.stats.totalEvaluations > 0
|
||||
? `${Math.round((projectDetail.stats.yesVotes / projectDetail.stats.totalEvaluations) * 100)}%`
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">Evaluators</p>
|
||||
<p className="mt-1 text-lg font-semibold">
|
||||
{projectDetail.stats.totalEvaluations}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-juror evaluations */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-semibold">Juror Evaluations</h4>
|
||||
{(() => {
|
||||
const submitted = projectDetail.assignments.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED' && a.round.id === roundId,
|
||||
)
|
||||
if (submitted.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No submitted evaluations for this round.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{submitted.map((a) => (
|
||||
<div key={a.id} className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium truncate">{a.user.name ?? a.user.email}</p>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{a.evaluation?.globalScore !== null && a.evaluation?.globalScore !== undefined && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Score: {a.evaluation.globalScore.toFixed(1)}
|
||||
</Badge>
|
||||
)}
|
||||
{a.evaluation?.binaryDecision !== null && a.evaluation?.binaryDecision !== undefined && (
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-xs',
|
||||
a.evaluation.binaryDecision
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-100'
|
||||
: 'bg-red-100 text-red-700 hover:bg-red-100',
|
||||
)}
|
||||
>
|
||||
{a.evaluation.binaryDecision ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{a.evaluation?.feedbackText && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{a.evaluation.feedbackText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -174,6 +174,18 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
|
||||
onCheckedChange={(v) => update('peerReviewEnabled', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="showJurorProgressDashboard">Juror Progress Dashboard</Label>
|
||||
<p className="text-xs text-muted-foreground">Show jurors a dashboard with their past evaluations, scores, and advance decisions</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="showJurorProgressDashboard"
|
||||
checked={(config.showJurorProgressDashboard as boolean) ?? false}
|
||||
onCheckedChange={(v) => update('showJurorProgressDashboard', v)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -51,10 +51,11 @@ import {
|
||||
Heading,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
ArrowUpCircle,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'
|
||||
export type CriterionType = 'numeric' | 'text' | 'boolean' | 'advance' | 'section_header'
|
||||
|
||||
export interface CriterionCondition {
|
||||
criterionId: string
|
||||
@@ -107,6 +108,8 @@ function createDefaultCriterion(type: CriterionType = 'numeric'): Criterion {
|
||||
return { ...base, maxLength: 1000, placeholder: '', required: true }
|
||||
case 'boolean':
|
||||
return { ...base, trueLabel: 'Yes', falseLabel: 'No', required: true }
|
||||
case 'advance':
|
||||
return { ...base, label: 'Advance to next round?', trueLabel: 'Yes', falseLabel: 'No', required: true }
|
||||
case 'section_header':
|
||||
return { ...base, required: false }
|
||||
default:
|
||||
@@ -236,7 +239,7 @@ export function EvaluationFormBuilder({
|
||||
{/* Type indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{CRITERION_TYPE_OPTIONS.find((t) => t.value === (editDraft.type || 'numeric'))?.label ?? 'Numeric Score'}
|
||||
{editDraft.type === 'advance' ? 'Advance to Next Round?' : (CRITERION_TYPE_OPTIONS.find((t) => t.value === (editDraft.type || 'numeric'))?.label ?? 'Numeric Score')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -413,8 +416,33 @@ export function EvaluationFormBuilder({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Condition builder - available for all types except section_header */}
|
||||
{(editDraft.type || 'numeric') !== 'section_header' && criteria.filter((c) => c.id !== editDraft.id).length > 0 && (
|
||||
{editDraft.type === 'advance' && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`trueLabel-${criterion.id}`}>Yes Label</Label>
|
||||
<Input
|
||||
id={`trueLabel-${criterion.id}`}
|
||||
value={editDraft.trueLabel || 'Yes'}
|
||||
onChange={(e) => updateDraft({ trueLabel: e.target.value })}
|
||||
placeholder="Yes"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`falseLabel-${criterion.id}`}>No Label</Label>
|
||||
<Input
|
||||
id={`falseLabel-${criterion.id}`}
|
||||
value={editDraft.falseLabel || 'No'}
|
||||
onChange={(e) => updateDraft({ falseLabel: e.target.value })}
|
||||
placeholder="No"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Condition builder - available for all types except section_header and advance */}
|
||||
{(editDraft.type || 'numeric') !== 'section_header' && editDraft.type !== 'advance' && criteria.filter((c) => c.id !== editDraft.id).length > 0 && (
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Conditional Visibility</Label>
|
||||
@@ -554,11 +582,11 @@ export function EvaluationFormBuilder({
|
||||
</span>
|
||||
{(() => {
|
||||
const type = criterion.type || 'numeric'
|
||||
const TypeIcon = CRITERION_TYPE_OPTIONS.find((t) => t.value === type)?.icon ?? Hash
|
||||
const TypeIcon = type === 'advance' ? ArrowUpCircle : (CRITERION_TYPE_OPTIONS.find((t) => t.value === type)?.icon ?? Hash)
|
||||
return (
|
||||
<Badge variant="secondary" className="shrink-0 text-xs gap-1">
|
||||
<TypeIcon className="h-3 w-3" />
|
||||
{type === 'numeric' ? `1-${criterion.scale ?? 5}` : CRITERION_TYPE_OPTIONS.find((t) => t.value === type)?.label}
|
||||
{type === 'numeric' ? `1-${criterion.scale ?? 5}` : type === 'advance' ? 'Advance to Next Round?' : CRITERION_TYPE_OPTIONS.find((t) => t.value === type)?.label}
|
||||
</Badge>
|
||||
)
|
||||
})()}
|
||||
@@ -684,6 +712,22 @@ export function EvaluationFormBuilder({
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addCriterion('advance')}
|
||||
disabled={editingId !== null || criteria.some((c) => c.type === 'advance')}
|
||||
title={criteria.some((c) => c.type === 'advance') ? 'Only one advance criterion allowed per form' : undefined}
|
||||
className={cn(
|
||||
'border-brand-blue/40 text-brand-blue hover:bg-brand-blue/5',
|
||||
criteria.some((c) => c.type === 'advance') && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<ArrowUpCircle className="mr-1 h-4 w-4" />
|
||||
Advance to Next Round?
|
||||
</Button>
|
||||
|
||||
{criteria.length > 0 && (
|
||||
<PreviewDialog criteria={criteria} />
|
||||
)}
|
||||
@@ -796,6 +840,18 @@ function PreviewDialog({ criteria }: { criteria: Criterion[] }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{type === 'advance' && (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 h-14 rounded-lg border-2 border-emerald-300 bg-emerald-50/50 flex items-center justify-center text-sm font-semibold text-emerald-700">
|
||||
<ThumbsUp className="mr-2 h-4 w-4" />
|
||||
{criterion.trueLabel || 'Yes'}
|
||||
</div>
|
||||
<div className="flex-1 h-14 rounded-lg border-2 border-red-300 bg-red-50/50 flex items-center justify-center text-sm font-semibold text-red-700">
|
||||
<ThumbsDown className="mr-2 h-4 w-4" />
|
||||
{criterion.falseLabel || 'No'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ import { toast } from 'sonner'
|
||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||
|
||||
// Define criterion type from the evaluation form JSON
|
||||
type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'
|
||||
type CriterionType = 'numeric' | 'text' | 'boolean' | 'advance' | 'section_header'
|
||||
|
||||
interface CriterionCondition {
|
||||
criterionId: string
|
||||
@@ -96,7 +96,7 @@ const createEvaluationSchema = (criteria: Criterion[]) => {
|
||||
criterionFields[c.id] = z.number()
|
||||
} else if (type === 'text') {
|
||||
criterionFields[c.id] = z.string()
|
||||
} else if (type === 'boolean') {
|
||||
} else if (type === 'boolean' || type === 'advance') {
|
||||
criterionFields[c.id] = z.boolean()
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,7 @@ export function EvaluationForm({
|
||||
defaultCriterionScores[c.id] = typeof existing === 'number' ? existing : Math.ceil((c.scale ?? 5) / 2)
|
||||
} else if (type === 'text') {
|
||||
defaultCriterionScores[c.id] = typeof existing === 'string' ? existing : ''
|
||||
} else if (type === 'boolean') {
|
||||
} else if (type === 'boolean' || type === 'advance') {
|
||||
defaultCriterionScores[c.id] = typeof existing === 'boolean' ? existing : false
|
||||
}
|
||||
})
|
||||
@@ -225,7 +225,7 @@ export function EvaluationForm({
|
||||
} else if (type === 'text') {
|
||||
const val = watchedScores?.[c.id]
|
||||
if (typeof val === 'string' && val.length > 0) criteriaDone++
|
||||
} else if (type === 'boolean') {
|
||||
} else if (type === 'boolean' || type === 'advance') {
|
||||
if (touchedCriteria.has(c.id)) criteriaDone++
|
||||
}
|
||||
}
|
||||
@@ -470,6 +470,19 @@ export function EvaluationForm({
|
||||
)
|
||||
}
|
||||
|
||||
// Advance (prominent boolean-valued criterion)
|
||||
if (type === 'advance') {
|
||||
return (
|
||||
<AdvanceCriterionField
|
||||
key={criterion.id}
|
||||
criterion={criterion}
|
||||
control={control}
|
||||
disabled={isReadOnly}
|
||||
onTouch={onCriterionTouch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</CardContent>
|
||||
@@ -947,6 +960,88 @@ function BooleanCriterionField({
|
||||
)
|
||||
}
|
||||
|
||||
// Advance criterion field component (prominent boolean — "should this project advance?")
|
||||
function AdvanceCriterionField({
|
||||
criterion,
|
||||
control,
|
||||
disabled,
|
||||
onTouch,
|
||||
}: {
|
||||
criterion: Criterion
|
||||
control: any
|
||||
disabled: boolean
|
||||
onTouch: (criterionId: string) => void
|
||||
}) {
|
||||
const trueLabel = criterion.trueLabel || 'Yes'
|
||||
const falseLabel = criterion.falseLabel || 'No'
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`criterionScores.${criterion.id}`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="space-y-3 p-5 border-2 border-brand-blue/30 rounded-xl bg-brand-blue/5">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base font-semibold text-brand-blue">{criterion.label}</Label>
|
||||
{criterion.required && (
|
||||
<Badge variant="destructive" className="text-xs">Required</Badge>
|
||||
)}
|
||||
</div>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{criterion.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant={field.value === true ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'flex-1 h-14 rounded-xl border-2 text-base font-semibold transition-all',
|
||||
field.value === true
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200 hover:bg-emerald-100'
|
||||
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
field.onChange(true)
|
||||
onTouch(criterion.id)
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-5 w-5" />
|
||||
{trueLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={field.value === false ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'flex-1 h-14 rounded-xl border-2 text-base font-semibold transition-all',
|
||||
field.value === false
|
||||
? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200 hover:bg-red-100'
|
||||
: 'border-border hover:border-red-300 hover:bg-red-50/50'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
field.onChange(false)
|
||||
onTouch(criterion.id)
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-5 w-5" />
|
||||
{falseLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Progress indicator component
|
||||
function ProgressIndicator({
|
||||
percentage,
|
||||
|
||||
116
src/components/jury/juror-progress-dashboard.tsx
Normal file
116
src/components/jury/juror-progress-dashboard.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ChevronDown, ChevronUp, ThumbsUp, ThumbsDown } from 'lucide-react'
|
||||
|
||||
export function JurorProgressDashboard({ roundId }: { roundId: string }) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const { data, isLoading } = trpc.evaluation.getMyProgress.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-32 w-full" />
|
||||
}
|
||||
|
||||
if (!data || data.total === 0) return null
|
||||
|
||||
const pct = Math.round((data.completed / data.total) * 100)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Your Progress</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={() => setExpanded(!expanded)}>
|
||||
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{data.completed} / {data.total} evaluated
|
||||
</span>
|
||||
<span className="font-medium">{pct}%</span>
|
||||
</div>
|
||||
<Progress value={pct} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* Advance summary */}
|
||||
{(data.advanceCounts.yes > 0 || data.advanceCounts.no > 0) && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">Advance:</span>
|
||||
<Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200">
|
||||
<ThumbsUp className="mr-1 h-3 w-3" />
|
||||
{data.advanceCounts.yes} Yes
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">
|
||||
<ThumbsDown className="mr-1 h-3 w-3" />
|
||||
{data.advanceCounts.no} No
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submissions table */}
|
||||
{expanded && data.submissions.length > 0 && (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/30">
|
||||
<th className="text-left px-3 py-2 font-medium">Project</th>
|
||||
<th className="text-center px-3 py-2 font-medium">Avg Score</th>
|
||||
{data.submissions[0]?.criterionScores.map((cs, i) => (
|
||||
<th key={i} className="text-center px-2 py-2 font-medium text-xs max-w-[80px] truncate" title={cs.label}>
|
||||
{cs.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="text-center px-3 py-2 font-medium">Advance</th>
|
||||
<th className="text-right px-3 py-2 font-medium">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.submissions.map((s) => (
|
||||
<tr key={s.projectId} className="border-b last:border-0 hover:bg-muted/20">
|
||||
<td className="px-3 py-2 font-medium truncate max-w-[200px]">{s.projectName}</td>
|
||||
<td className="text-center px-3 py-2">
|
||||
{s.numericAverage != null ? (
|
||||
<span className="font-semibold">{s.numericAverage}</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
{s.criterionScores.map((cs, i) => (
|
||||
<td key={i} className="text-center px-2 py-2 text-muted-foreground">{cs.value}</td>
|
||||
))}
|
||||
<td className="text-center px-3 py-2">
|
||||
{s.advanceDecision === true ? (
|
||||
<Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200 text-xs">YES</Badge>
|
||||
) : s.advanceDecision === false ? (
|
||||
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200 text-xs">NO</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-2 text-muted-foreground text-xs whitespace-nowrap">
|
||||
{s.submittedAt ? new Date(s.submittedAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -737,7 +737,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||
|
||||
if (type === 'section_header') return null
|
||||
|
||||
if (type === 'boolean') {
|
||||
if (type === 'boolean' || type === 'advance') {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { logoRouter } from './logo'
|
||||
import { applicationRouter } from './application'
|
||||
import { mentorRouter } from './mentor'
|
||||
import { filteringRouter } from './filtering'
|
||||
import { rankingRouter } from './ranking'
|
||||
import { specialAwardRouter } from './specialAward'
|
||||
import { notificationRouter } from './notification'
|
||||
// Feature expansion routers
|
||||
@@ -83,6 +84,7 @@ export const appRouter = router({
|
||||
application: applicationRouter,
|
||||
mentor: mentorRouter,
|
||||
filtering: filteringRouter,
|
||||
ranking: rankingRouter,
|
||||
specialAward: specialAwardRouter,
|
||||
notification: notificationRouter,
|
||||
// Feature expansion routers
|
||||
|
||||
@@ -809,7 +809,14 @@ export const assignmentRouter = router({
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, expertiseTags: true } },
|
||||
project: { select: { id: true, title: true, tags: true } },
|
||||
evaluation: { select: { status: true, submittedAt: true } },
|
||||
evaluation: {
|
||||
select: {
|
||||
status: true,
|
||||
submittedAt: true,
|
||||
criterionScoresJson: true,
|
||||
form: { select: { criteriaJson: true } },
|
||||
},
|
||||
},
|
||||
conflictOfInterest: { select: { hasConflict: true, conflictType: true, reviewAction: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
|
||||
@@ -6,6 +6,99 @@ import { notifyAdmins, NotificationTypes } from '../services/in-app-notification
|
||||
import { reassignAfterCOI } from './assignment'
|
||||
import { sendManualReminders } from '../services/evaluation-reminders'
|
||||
import { generateSummary } from '@/server/services/ai-evaluation-summary'
|
||||
import { quickRank as aiQuickRank } from '../services/ai-ranking'
|
||||
import type { EvaluationConfig } from '@/types/competition-configs'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* Auto-trigger AI ranking if all required assignments for the round are complete.
|
||||
* MUST be called fire-and-forget (void). Never awaited from submission mutation.
|
||||
* Implements RANK-09 cooldown guard (5-minute window).
|
||||
*/
|
||||
async function triggerAutoRankIfComplete(
|
||||
roundId: string,
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 1. Check if round is fully evaluated
|
||||
const [total, completed] = await Promise.all([
|
||||
prisma.assignment.count({ where: { roundId, isRequired: true } }),
|
||||
prisma.assignment.count({ where: { roundId, isRequired: true, isCompleted: true } }),
|
||||
])
|
||||
if (total === 0 || total !== completed) return
|
||||
|
||||
// 2. Check round config for auto-ranking settings
|
||||
const round = await prisma.round.findUnique({
|
||||
where: { id: roundId },
|
||||
select: { id: true, name: true, configJson: true, competition: { select: { id: true, name: true } } },
|
||||
})
|
||||
if (!round) return
|
||||
|
||||
const config = (round.configJson as EvaluationConfig | null) ?? ({} as EvaluationConfig)
|
||||
const criteriaText = config?.rankingCriteria ?? null
|
||||
const autoRankEnabled = config?.autoRankOnComplete ?? false
|
||||
|
||||
if (!autoRankEnabled || !criteriaText) {
|
||||
// Auto-ranking not configured for this round — skip silently
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Cooldown check: skip if AUTO snapshot created in last 5 minutes
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000)
|
||||
const recentSnapshot = await prisma.rankingSnapshot.findFirst({
|
||||
where: { roundId, triggerType: 'AUTO', createdAt: { gte: fiveMinutesAgo } },
|
||||
select: { id: true },
|
||||
})
|
||||
if (recentSnapshot) {
|
||||
// Duplicate auto-trigger within cooldown window — skip
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Execute ranking (takes 10-30s, do not await from submission)
|
||||
const result = await aiQuickRank(criteriaText, roundId, prisma, userId)
|
||||
|
||||
// 5. Save snapshot
|
||||
await prisma.rankingSnapshot.create({
|
||||
data: {
|
||||
roundId,
|
||||
triggeredById: null, // auto-triggered, no specific user
|
||||
triggerType: 'AUTO',
|
||||
criteriaText,
|
||||
parsedRulesJson: result.parsedRules as unknown as import('@prisma/client').Prisma.InputJsonValue,
|
||||
startupRankingJson: result.startup.rankedProjects as unknown as import('@prisma/client').Prisma.InputJsonValue,
|
||||
conceptRankingJson: result.concept.rankedProjects as unknown as import('@prisma/client').Prisma.InputJsonValue,
|
||||
mode: 'QUICK',
|
||||
status: 'COMPLETED',
|
||||
},
|
||||
})
|
||||
|
||||
// 6. Notify admins of success
|
||||
const startupCount = result.startup.rankedProjects.length
|
||||
const conceptCount = result.concept.rankedProjects.length
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.AI_RANKING_COMPLETE,
|
||||
title: 'AI Ranking Complete',
|
||||
message: `Rankings generated for "${round.name}" — ${startupCount} STARTUP, ${conceptCount} BUSINESS_CONCEPT projects ranked.`,
|
||||
linkUrl: `/admin/competitions/${round.competition.id}/rounds/${roundId}?tab=ranking`,
|
||||
linkLabel: 'View Rankings',
|
||||
priority: 'normal',
|
||||
})
|
||||
} catch (error) {
|
||||
// Auto-trigger must never crash the calling mutation
|
||||
try {
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.AI_RANKING_FAILED,
|
||||
title: 'AI Ranking Failed',
|
||||
message: `Auto-ranking failed for round (ID: ${roundId}). Please trigger manually.`,
|
||||
priority: 'high',
|
||||
})
|
||||
} catch {
|
||||
// Even notification failure must not propagate
|
||||
}
|
||||
console.error('[auto-rank] triggerAutoRankIfComplete failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export const evaluationRouter = router({
|
||||
/**
|
||||
@@ -247,7 +340,7 @@ export const evaluationRouter = router({
|
||||
if (!scores) return true
|
||||
const val = scores[c.id]
|
||||
// Boolean criteria store true/false, numeric criteria store numbers
|
||||
if (c.type === 'boolean') return typeof val !== 'boolean'
|
||||
if (c.type === 'boolean' || c.type === 'advance') return typeof val !== 'boolean'
|
||||
return typeof val !== 'number'
|
||||
})
|
||||
if (missingCriteria.length > 0) {
|
||||
@@ -281,6 +374,9 @@ export const evaluationRouter = router({
|
||||
}),
|
||||
])
|
||||
|
||||
// Auto-trigger ranking if all assignments complete (fire-and-forget, never awaited)
|
||||
void triggerAutoRankIfComplete(evaluation.assignment.roundId, ctx.prisma, ctx.user.id)
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
@@ -1227,7 +1323,7 @@ export const evaluationRouter = router({
|
||||
id: z.string(),
|
||||
label: z.string().min(1).max(255),
|
||||
description: z.string().max(2000).optional(),
|
||||
type: z.enum(['numeric', 'text', 'boolean', 'section_header']).optional(),
|
||||
type: z.enum(['numeric', 'text', 'boolean', 'advance', 'section_header']).optional(),
|
||||
// Numeric fields
|
||||
weight: z.number().min(0).max(100).optional(),
|
||||
minScore: z.number().int().min(0).optional(),
|
||||
@@ -1255,6 +1351,15 @@ export const evaluationRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { roundId, criteria } = input
|
||||
|
||||
// Enforce max one advance criterion per form
|
||||
const advanceCount = criteria.filter((c) => c.type === 'advance').length
|
||||
if (advanceCount > 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Only one advance criterion is allowed per evaluation form',
|
||||
})
|
||||
}
|
||||
|
||||
// Verify round exists
|
||||
await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId } })
|
||||
|
||||
@@ -1299,6 +1404,14 @@ export const evaluationRouter = router({
|
||||
falseLabel: c.falseLabel || 'No',
|
||||
}
|
||||
}
|
||||
if (type === 'advance') {
|
||||
return {
|
||||
...base,
|
||||
required: true, // always required, override any input
|
||||
trueLabel: c.trueLabel || 'Yes',
|
||||
falseLabel: c.falseLabel || 'No',
|
||||
}
|
||||
}
|
||||
// section_header
|
||||
return base
|
||||
})
|
||||
@@ -1571,4 +1684,100 @@ export const evaluationRouter = router({
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the current juror's progress for a round
|
||||
*/
|
||||
getMyProgress: juryProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { roundId } = input
|
||||
const userId = ctx.user.id
|
||||
|
||||
// Get all assignments for this juror in this round
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId, userId },
|
||||
include: {
|
||||
project: { select: { id: true, title: true } },
|
||||
evaluation: {
|
||||
include: { form: { select: { criteriaJson: true } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const total = assignments.length
|
||||
let completed = 0
|
||||
let advanceYes = 0
|
||||
let advanceNo = 0
|
||||
|
||||
const submissions: Array<{
|
||||
projectId: string
|
||||
projectName: string
|
||||
submittedAt: Date | null
|
||||
advanceDecision: boolean | null
|
||||
criterionScores: Array<{ label: string; value: number }>
|
||||
numericAverage: number | null
|
||||
}> = []
|
||||
|
||||
for (const a of assignments) {
|
||||
const ev = a.evaluation
|
||||
if (!ev || ev.status !== 'SUBMITTED') continue
|
||||
completed++
|
||||
|
||||
const criteria = (ev.form?.criteriaJson ?? []) as Array<{
|
||||
id: string; label: string; type?: string; weight?: number
|
||||
}>
|
||||
const scores = (ev.criterionScoresJson ?? {}) as Record<string, unknown>
|
||||
|
||||
// Find the advance criterion
|
||||
const advanceCriterion = criteria.find((c) => c.type === 'advance')
|
||||
let advanceDecision: boolean | null = null
|
||||
if (advanceCriterion) {
|
||||
const val = scores[advanceCriterion.id]
|
||||
if (typeof val === 'boolean') {
|
||||
advanceDecision = val
|
||||
if (val) advanceYes++
|
||||
else advanceNo++
|
||||
}
|
||||
}
|
||||
|
||||
// Collect numeric criterion scores
|
||||
const numericScores: Array<{ label: string; value: number }> = []
|
||||
for (const c of criteria) {
|
||||
if (c.type === 'numeric' || (!c.type && c.weight !== undefined)) {
|
||||
const val = scores[c.id]
|
||||
if (typeof val === 'number') {
|
||||
numericScores.push({ label: c.label, value: val })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const numericAverage = numericScores.length > 0
|
||||
? Math.round((numericScores.reduce((sum, s) => sum + s.value, 0) / numericScores.length) * 10) / 10
|
||||
: null
|
||||
|
||||
submissions.push({
|
||||
projectId: a.project.id,
|
||||
projectName: a.project.title,
|
||||
submittedAt: ev.submittedAt,
|
||||
advanceDecision,
|
||||
criterionScores: numericScores,
|
||||
numericAverage,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by most recent first
|
||||
submissions.sort((a, b) => {
|
||||
if (!a.submittedAt) return 1
|
||||
if (!b.submittedAt) return -1
|
||||
return b.submittedAt.getTime() - a.submittedAt.getTime()
|
||||
})
|
||||
|
||||
return {
|
||||
total,
|
||||
completed,
|
||||
advanceCounts: { yes: advanceYes, no: advanceNo },
|
||||
submissions,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
390
src/server/routers/ranking.ts
Normal file
390
src/server/routers/ranking.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import { router, adminProcedure } from '../trpc'
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import {
|
||||
parseRankingCriteria,
|
||||
executeAIRanking,
|
||||
quickRank as aiQuickRank,
|
||||
fetchAndRankCategory,
|
||||
type ParsedRankingRule,
|
||||
} from '../services/ai-ranking'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import type { EvaluationConfig } from '@/types/competition-configs'
|
||||
|
||||
// ─── Local Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
type ReorderEvent = {
|
||||
category: 'STARTUP' | 'BUSINESS_CONCEPT'
|
||||
orderedProjectIds: string[]
|
||||
reorderedBy: string
|
||||
reorderedAt: string
|
||||
}
|
||||
|
||||
// ─── Zod Schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
const ParsedRuleSchema = z.object({
|
||||
step: z.number().int(),
|
||||
type: z.enum(['filter', 'sort', 'limit']),
|
||||
description: z.string(),
|
||||
field: z.enum(['pass_rate', 'avg_score', 'evaluator_count']).nullable(),
|
||||
operator: z.enum(['gte', 'lte', 'eq', 'top_n']).nullable(),
|
||||
value: z.number().nullable(),
|
||||
dataAvailable: z.boolean(),
|
||||
})
|
||||
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const rankingRouter = router({
|
||||
/**
|
||||
* Parse natural-language criteria into structured rules (preview mode).
|
||||
* RANK-01, RANK-02, RANK-03 — admin reviews parsed rules before executing.
|
||||
*/
|
||||
parseRankingCriteria: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
criteriaText: z.string().min(1).max(5000),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }): Promise<ParsedRankingRule[]> => {
|
||||
const rules = await parseRankingCriteria(input.criteriaText, ctx.user.id, input.roundId)
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'RANKING_CRITERIA_PARSED',
|
||||
entityType: 'Round',
|
||||
entityId: input.roundId,
|
||||
detailsJson: { ruleCount: rules.length },
|
||||
})
|
||||
|
||||
return rules
|
||||
}),
|
||||
|
||||
/**
|
||||
* Execute ranking using pre-parsed rules (confirmed mode).
|
||||
* Fetches both categories in parallel, persists a RankingSnapshot.
|
||||
* RANK-05, RANK-06, RANK-08.
|
||||
*/
|
||||
executeRanking: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
criteriaText: z.string(),
|
||||
parsedRules: z.array(ParsedRuleSchema),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Cast to service type — validated by Zod above
|
||||
const rules = input.parsedRules as ParsedRankingRule[]
|
||||
|
||||
// Fetch and rank both categories in parallel
|
||||
const [startup, concept] = await Promise.all([
|
||||
fetchAndRankCategory('STARTUP', rules, input.roundId, ctx.prisma, ctx.user.id),
|
||||
fetchAndRankCategory('BUSINESS_CONCEPT', rules, input.roundId, ctx.prisma, ctx.user.id),
|
||||
])
|
||||
|
||||
// Persist snapshot
|
||||
const snapshot = await ctx.prisma.rankingSnapshot.create({
|
||||
data: {
|
||||
roundId: input.roundId,
|
||||
triggeredById: ctx.user.id,
|
||||
triggerType: 'MANUAL',
|
||||
mode: 'CONFIRMED',
|
||||
status: 'COMPLETED',
|
||||
criteriaText: input.criteriaText,
|
||||
parsedRulesJson: rules as unknown as Prisma.InputJsonValue,
|
||||
startupRankingJson: startup.rankedProjects as unknown as Prisma.InputJsonValue,
|
||||
conceptRankingJson: concept.rankedProjects as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'RANKING_EXECUTED',
|
||||
entityType: 'RankingSnapshot',
|
||||
entityId: snapshot.id,
|
||||
detailsJson: {
|
||||
roundId: input.roundId,
|
||||
startupCount: startup.rankedProjects.length,
|
||||
conceptCount: concept.rankedProjects.length,
|
||||
},
|
||||
})
|
||||
|
||||
return { snapshotId: snapshot.id, startup, concept }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Quick rank: parse criteria and execute in one step (RANK-04).
|
||||
* Persists a RankingSnapshot with mode=QUICK.
|
||||
*/
|
||||
quickRank: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
roundId: z.string(),
|
||||
criteriaText: z.string().min(1).max(5000),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { startup, concept, parsedRules } = await aiQuickRank(
|
||||
input.criteriaText,
|
||||
input.roundId,
|
||||
ctx.prisma,
|
||||
ctx.user.id,
|
||||
)
|
||||
|
||||
// Persist snapshot
|
||||
const snapshot = await ctx.prisma.rankingSnapshot.create({
|
||||
data: {
|
||||
roundId: input.roundId,
|
||||
triggeredById: ctx.user.id,
|
||||
triggerType: 'QUICK',
|
||||
mode: 'QUICK',
|
||||
status: 'COMPLETED',
|
||||
criteriaText: input.criteriaText,
|
||||
parsedRulesJson: parsedRules as unknown as Prisma.InputJsonValue,
|
||||
startupRankingJson: startup.rankedProjects as unknown as Prisma.InputJsonValue,
|
||||
conceptRankingJson: concept.rankedProjects as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'RANKING_QUICK_EXECUTED',
|
||||
entityType: 'RankingSnapshot',
|
||||
entityId: snapshot.id,
|
||||
detailsJson: {
|
||||
roundId: input.roundId,
|
||||
startupCount: startup.rankedProjects.length,
|
||||
conceptCount: concept.rankedProjects.length,
|
||||
},
|
||||
})
|
||||
|
||||
return { snapshotId: snapshot.id, startup, concept, parsedRules }
|
||||
}),
|
||||
|
||||
/**
|
||||
* List all ranking snapshots for a round (Phase 2 dashboard prep).
|
||||
* Ordered by createdAt desc — newest first.
|
||||
*/
|
||||
listSnapshots: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.rankingSnapshot.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
triggeredById: true,
|
||||
triggerType: true,
|
||||
mode: true,
|
||||
status: true,
|
||||
criteriaText: true,
|
||||
tokensUsed: true,
|
||||
createdAt: true,
|
||||
triggeredBy: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a single ranking snapshot by ID (Phase 2 dashboard prep).
|
||||
* Returns full snapshot including ranking results.
|
||||
*/
|
||||
getSnapshot: adminProcedure
|
||||
.input(z.object({ snapshotId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const snapshot = await ctx.prisma.rankingSnapshot.findUnique({
|
||||
where: { id: input.snapshotId },
|
||||
})
|
||||
|
||||
if (!snapshot) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: `Ranking snapshot ${input.snapshotId} not found`,
|
||||
})
|
||||
}
|
||||
|
||||
return snapshot
|
||||
}),
|
||||
|
||||
/** Persist admin drag-reorder to RankingSnapshot.reordersJson. Append-only — never overwrites old entries. DASH-02, DASH-03. */
|
||||
saveReorder: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
snapshotId: z.string(),
|
||||
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']),
|
||||
orderedProjectIds: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const snapshot = await ctx.prisma.rankingSnapshot.findUniqueOrThrow({
|
||||
where: { id: input.snapshotId },
|
||||
select: { reordersJson: true },
|
||||
})
|
||||
|
||||
const existingReorders = (snapshot.reordersJson as ReorderEvent[] | null) ?? []
|
||||
|
||||
const newReorder: ReorderEvent = {
|
||||
category: input.category,
|
||||
orderedProjectIds: input.orderedProjectIds,
|
||||
reorderedBy: ctx.user.id,
|
||||
reorderedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
await ctx.prisma.rankingSnapshot.update({
|
||||
where: { id: input.snapshotId },
|
||||
data: { reordersJson: [...existingReorders, newReorder] as unknown as Prisma.InputJsonValue },
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* RANK-09 — Manual trigger for auto-rank (admin button on round detail page).
|
||||
* Reads ranking criteria from round configJson and executes quickRank.
|
||||
*/
|
||||
triggerAutoRank: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { roundId } = input
|
||||
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: roundId },
|
||||
select: { id: true, name: true, configJson: true, competition: { select: { id: true } } },
|
||||
})
|
||||
|
||||
const config = (round.configJson as EvaluationConfig | null) ?? ({} as EvaluationConfig)
|
||||
const criteriaText = config?.rankingCriteria ?? null
|
||||
|
||||
if (!criteriaText) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No ranking criteria configured for this round. Add criteria in round settings first.',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await aiQuickRank(criteriaText, roundId, ctx.prisma, ctx.user.id)
|
||||
|
||||
const snapshot = await ctx.prisma.rankingSnapshot.create({
|
||||
data: {
|
||||
roundId,
|
||||
triggeredById: ctx.user.id,
|
||||
triggerType: 'MANUAL',
|
||||
criteriaText,
|
||||
parsedRulesJson: result.parsedRules as unknown as Prisma.InputJsonValue,
|
||||
startupRankingJson: result.startup.rankedProjects as unknown as Prisma.InputJsonValue,
|
||||
conceptRankingJson: result.concept.rankedProjects as unknown as Prisma.InputJsonValue,
|
||||
mode: 'QUICK',
|
||||
status: 'COMPLETED',
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'RANKING_MANUAL_TRIGGERED',
|
||||
entityType: 'RankingSnapshot',
|
||||
entityId: snapshot.id,
|
||||
detailsJson: { roundId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return { snapshotId: snapshot.id, startup: result.startup, concept: result.concept }
|
||||
}),
|
||||
|
||||
/**
|
||||
* RANK-10 — Retroactive scan: finds all active/closed rounds with autoRankOnComplete
|
||||
* configured but no RETROACTIVE snapshot yet, then executes ranking for each.
|
||||
* Runs sequentially to avoid hammering OpenAI.
|
||||
*/
|
||||
retroactiveScan: adminProcedure
|
||||
.input(z.object({}))
|
||||
.mutation(async ({ ctx }) => {
|
||||
const rounds = await ctx.prisma.round.findMany({
|
||||
where: { status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] } },
|
||||
select: { id: true, name: true, configJson: true },
|
||||
})
|
||||
|
||||
const results: Array<{ roundId: string; triggered: boolean; reason?: string }> = []
|
||||
|
||||
for (const round of rounds) {
|
||||
const config = (round.configJson as EvaluationConfig | null) ?? ({} as EvaluationConfig)
|
||||
const autoRankEnabled = config?.autoRankOnComplete ?? false
|
||||
const criteriaText = config?.rankingCriteria ?? null
|
||||
|
||||
if (!autoRankEnabled || !criteriaText) {
|
||||
results.push({ roundId: round.id, triggered: false, reason: 'auto-rank not configured' })
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if fully evaluated
|
||||
const [total, completed] = await Promise.all([
|
||||
ctx.prisma.assignment.count({ where: { roundId: round.id, isRequired: true } }),
|
||||
ctx.prisma.assignment.count({ where: { roundId: round.id, isRequired: true, isCompleted: true } }),
|
||||
])
|
||||
|
||||
if (total === 0 || total !== completed) {
|
||||
results.push({
|
||||
roundId: round.id,
|
||||
triggered: false,
|
||||
reason: `${completed}/${total} assignments complete`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if a RETROACTIVE snapshot already exists
|
||||
const existing = await ctx.prisma.rankingSnapshot.findFirst({
|
||||
where: { roundId: round.id, triggerType: 'RETROACTIVE' },
|
||||
select: { id: true },
|
||||
})
|
||||
if (existing) {
|
||||
results.push({ roundId: round.id, triggered: false, reason: 'retroactive snapshot already exists' })
|
||||
continue
|
||||
}
|
||||
|
||||
// Execute ranking sequentially to avoid rate limits
|
||||
try {
|
||||
const result = await aiQuickRank(criteriaText, round.id, ctx.prisma, ctx.user.id)
|
||||
await ctx.prisma.rankingSnapshot.create({
|
||||
data: {
|
||||
roundId: round.id,
|
||||
triggeredById: ctx.user.id,
|
||||
triggerType: 'RETROACTIVE',
|
||||
criteriaText,
|
||||
parsedRulesJson: result.parsedRules as unknown as Prisma.InputJsonValue,
|
||||
startupRankingJson: result.startup.rankedProjects as unknown as Prisma.InputJsonValue,
|
||||
conceptRankingJson: result.concept.rankedProjects as unknown as Prisma.InputJsonValue,
|
||||
mode: 'QUICK',
|
||||
status: 'COMPLETED',
|
||||
},
|
||||
})
|
||||
results.push({ roundId: round.id, triggered: true })
|
||||
} catch (err) {
|
||||
results.push({ roundId: round.id, triggered: false, reason: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'RANKING_RETROACTIVE_SCAN',
|
||||
entityType: 'Round',
|
||||
detailsJson: { results },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return {
|
||||
results,
|
||||
total: results.length,
|
||||
triggered: results.filter((r) => r.triggered).length,
|
||||
}
|
||||
}),
|
||||
})
|
||||
428
src/server/services/ai-ranking.ts
Normal file
428
src/server/services/ai-ranking.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* AI Ranking Service
|
||||
*
|
||||
* Parses natural-language ranking criteria into structured rules and
|
||||
* executes per-category project ranking using OpenAI.
|
||||
*
|
||||
* GDPR Compliance:
|
||||
* - All project data is anonymized before AI processing (P001, P002, …)
|
||||
* - No personal identifiers or real project IDs in prompts or responses
|
||||
*
|
||||
* Design decisions:
|
||||
* - Per-category processing (STARTUP / BUSINESS_CONCEPT) — two parallel AI calls
|
||||
* - Projects with zero submitted evaluations are excluded (not ranked last)
|
||||
* - compositeScore = 50% normalised avgGlobalScore + 50% passRate + tiny tiebreak
|
||||
*/
|
||||
|
||||
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
|
||||
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
||||
import { classifyAIError, logAIError } from './ai-errors'
|
||||
import { sanitizeUserInput } from '@/server/services/ai-prompt-guard'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Internal shape of a project before anonymization
|
||||
interface ProjectForRanking {
|
||||
id: string
|
||||
competitionCategory: CompetitionCategory
|
||||
avgGlobalScore: number | null // average of submitted Evaluation.globalScore
|
||||
passRate: number // proportion of binaryDecision=true among SUBMITTED evaluations
|
||||
evaluatorCount: number // count of SUBMITTED evaluations
|
||||
}
|
||||
|
||||
// Anonymized shape sent to OpenAI
|
||||
interface AnonymizedProjectForRanking {
|
||||
project_id: string // "P001", "P002", etc. — never real IDs
|
||||
avg_score: number | null
|
||||
pass_rate: number // 0–1
|
||||
evaluator_count: number
|
||||
category: string
|
||||
}
|
||||
|
||||
// A single parsed rule returned by the criteria parser
|
||||
export interface ParsedRankingRule {
|
||||
step: number
|
||||
type: 'filter' | 'sort' | 'limit'
|
||||
description: string // Human-readable rule text
|
||||
field: 'pass_rate' | 'avg_score' | 'evaluator_count' | null
|
||||
operator: 'gte' | 'lte' | 'eq' | 'top_n' | null
|
||||
value: number | null
|
||||
dataAvailable: boolean // false = rule references unavailable data; UI should warn
|
||||
}
|
||||
|
||||
// A single project entry in the ranked output
|
||||
export interface RankedProjectEntry {
|
||||
projectId: string // Real project ID (de-anonymized)
|
||||
rank: number // 1-indexed
|
||||
compositeScore: number // 0–1 floating point
|
||||
avgGlobalScore: number | null
|
||||
passRate: number
|
||||
evaluatorCount: number
|
||||
aiRationale?: string // Optional: AI explanation for this project's rank
|
||||
}
|
||||
|
||||
// Full result for one category
|
||||
export interface RankingResult {
|
||||
category: CompetitionCategory
|
||||
rankedProjects: RankedProjectEntry[]
|
||||
parsedRules: ParsedRankingRule[]
|
||||
totalEligible: number
|
||||
}
|
||||
|
||||
// ─── System Prompts ────────────────────────────────────────────────────────────
|
||||
|
||||
const CRITERIA_PARSING_SYSTEM_PROMPT = `You are a ranking criteria interpreter for an ocean conservation project competition (Monaco Ocean Protection Challenge).
|
||||
|
||||
Admin will describe how they want projects ranked in natural language. Parse this into structured rules.
|
||||
|
||||
Available data fields for ranking:
|
||||
- avg_score: average jury evaluation score (1–10 scale, null if not scored)
|
||||
- pass_rate: proportion of jury members who voted to advance the project (0–1)
|
||||
- evaluator_count: number of jury members who submitted evaluations (tiebreak)
|
||||
|
||||
Return JSON only:
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"step": 1,
|
||||
"type": "filter | sort | limit",
|
||||
"description": "Human-readable description of this rule",
|
||||
"field": "pass_rate | avg_score | evaluator_count | null",
|
||||
"operator": "gte | lte | eq | top_n | null",
|
||||
"value": <number or null>,
|
||||
"dataAvailable": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Set dataAvailable=false if the rule requires data not in the available fields list above. Do NOT invent new fields.
|
||||
Rules with dataAvailable=false will be shown as warnings to the admin — still include them.
|
||||
Order rules so filters come first, sorts next, limits last.`
|
||||
|
||||
const RANKING_SYSTEM_PROMPT = `You are a project ranking engine for an ocean conservation competition.
|
||||
|
||||
You will receive a list of anonymized projects with numeric scores and a set of parsed ranking rules.
|
||||
Apply the rules in order and return the final ranked list.
|
||||
|
||||
Return JSON only:
|
||||
{
|
||||
"ranked": [
|
||||
{
|
||||
"project_id": "P001",
|
||||
"rank": 1,
|
||||
"rationale": "Brief explanation"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Apply filter rules first (remove projects that fail the filter)
|
||||
- Apply sort rules next (order remaining projects)
|
||||
- Apply limit rules last (keep only top N)
|
||||
- Projects not in the ranked output are considered excluded (not ranked last)
|
||||
- Use the project_id values exactly as given — do not change them`
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function computeCompositeScore(
|
||||
avgGlobalScore: number | null,
|
||||
passRate: number,
|
||||
evaluatorCount: number,
|
||||
maxEvaluatorCount: number,
|
||||
): number {
|
||||
const normalizedScore = avgGlobalScore != null ? (avgGlobalScore - 1) / 9 : 0.5
|
||||
const composite = normalizedScore * 0.5 + passRate * 0.5
|
||||
// Tiebreak: tiny bonus for more evaluators (won't change rank unless composite is equal)
|
||||
const tiebreakBonus = maxEvaluatorCount > 0
|
||||
? (evaluatorCount / maxEvaluatorCount) * 0.0001
|
||||
: 0
|
||||
return composite + tiebreakBonus
|
||||
}
|
||||
|
||||
function anonymizeProjectsForRanking(
|
||||
projects: ProjectForRanking[],
|
||||
): { anonymized: AnonymizedProjectForRanking[]; idMap: Map<string, string> } {
|
||||
const idMap = new Map<string, string>()
|
||||
const anonymized = projects.map((p, i) => {
|
||||
const anonId = `P${String(i + 1).padStart(3, '0')}`
|
||||
idMap.set(anonId, p.id)
|
||||
return {
|
||||
project_id: anonId,
|
||||
avg_score: p.avgGlobalScore,
|
||||
pass_rate: p.passRate,
|
||||
evaluator_count: p.evaluatorCount,
|
||||
category: p.competitionCategory,
|
||||
}
|
||||
})
|
||||
return { anonymized, idMap }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute pass rate from Evaluation records.
|
||||
* Handles both legacy binaryDecision boolean and future dedicated field.
|
||||
* Falls back to binaryDecision if no future field exists.
|
||||
*/
|
||||
function computePassRate(evaluations: Array<{ binaryDecision: boolean | null }>): number {
|
||||
if (evaluations.length === 0) return 0
|
||||
const passCount = evaluations.filter((e) => e.binaryDecision === true).length
|
||||
return passCount / evaluations.length
|
||||
}
|
||||
|
||||
// ─── Exported Functions ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse natural-language ranking criteria into structured rules.
|
||||
* Returns ParsedRankingRule[] for admin review (preview mode — RANK-02, RANK-03).
|
||||
*/
|
||||
export async function parseRankingCriteria(
|
||||
criteriaText: string,
|
||||
userId?: string,
|
||||
entityId?: string,
|
||||
): Promise<ParsedRankingRule[]> {
|
||||
const { sanitized: safeCriteria } = sanitizeUserInput(criteriaText)
|
||||
|
||||
const openai = await getOpenAI()
|
||||
if (!openai) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'OpenAI not configured' })
|
||||
}
|
||||
|
||||
const model = await getConfiguredModel()
|
||||
const params = buildCompletionParams(model, {
|
||||
messages: [
|
||||
{ role: 'system', content: CRITERIA_PARSING_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: safeCriteria },
|
||||
],
|
||||
jsonMode: true,
|
||||
temperature: 0.1,
|
||||
maxTokens: 1000,
|
||||
})
|
||||
|
||||
let response: Awaited<ReturnType<typeof openai.chat.completions.create>>
|
||||
try {
|
||||
response = await openai.chat.completions.create(params)
|
||||
} catch (error) {
|
||||
const classified = classifyAIError(error)
|
||||
logAIError('Ranking', 'parseRankingCriteria', classified)
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: classified.message })
|
||||
}
|
||||
|
||||
const usage = extractTokenUsage(response)
|
||||
await logAIUsage({
|
||||
userId,
|
||||
action: 'RANKING',
|
||||
entityType: 'Round',
|
||||
entityId,
|
||||
model,
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
totalTokens: usage.totalTokens,
|
||||
itemsProcessed: 1,
|
||||
status: 'SUCCESS',
|
||||
})
|
||||
|
||||
const content = response.choices[0]?.message?.content
|
||||
if (!content) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Empty response from AI' })
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content) as { rules: ParsedRankingRule[] }
|
||||
return parsed.rules ?? []
|
||||
} catch {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to parse AI response as JSON' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute AI ranking for one category using pre-parsed rules.
|
||||
* Returns RankingResult with ranked project list (RANK-05, RANK-06).
|
||||
*
|
||||
* projects: raw data queried from Prisma, already filtered to one category
|
||||
* parsedRules: from parseRankingCriteria()
|
||||
*/
|
||||
export async function executeAIRanking(
|
||||
parsedRules: ParsedRankingRule[],
|
||||
projects: ProjectForRanking[],
|
||||
category: CompetitionCategory,
|
||||
userId?: string,
|
||||
entityId?: string,
|
||||
): Promise<RankingResult> {
|
||||
if (projects.length === 0) {
|
||||
return { category, rankedProjects: [], parsedRules, totalEligible: 0 }
|
||||
}
|
||||
|
||||
const maxEvaluatorCount = Math.max(...projects.map((p) => p.evaluatorCount))
|
||||
const { anonymized, idMap } = anonymizeProjectsForRanking(projects)
|
||||
|
||||
const openai = await getOpenAI()
|
||||
if (!openai) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'OpenAI not configured' })
|
||||
}
|
||||
|
||||
const model = await getConfiguredModel()
|
||||
|
||||
const userPrompt = JSON.stringify({
|
||||
rules: parsedRules.filter((r) => r.dataAvailable),
|
||||
projects: anonymized,
|
||||
})
|
||||
|
||||
const params = buildCompletionParams(model, {
|
||||
messages: [
|
||||
{ role: 'system', content: RANKING_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
jsonMode: true,
|
||||
temperature: 0,
|
||||
maxTokens: 2000,
|
||||
})
|
||||
|
||||
let response: Awaited<ReturnType<typeof openai.chat.completions.create>>
|
||||
try {
|
||||
response = await openai.chat.completions.create(params)
|
||||
} catch (error) {
|
||||
const classified = classifyAIError(error)
|
||||
logAIError('Ranking', 'executeAIRanking', classified)
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: classified.message })
|
||||
}
|
||||
|
||||
const usage = extractTokenUsage(response)
|
||||
await logAIUsage({
|
||||
userId,
|
||||
action: 'RANKING',
|
||||
entityType: 'Round',
|
||||
entityId,
|
||||
model,
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
totalTokens: usage.totalTokens,
|
||||
itemsProcessed: projects.length,
|
||||
status: 'SUCCESS',
|
||||
})
|
||||
|
||||
const content = response.choices[0]?.message?.content
|
||||
if (!content) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Empty ranking response from AI' })
|
||||
|
||||
let aiRanked: Array<{ project_id: string; rank: number; rationale?: string }>
|
||||
try {
|
||||
const parsed = JSON.parse(content) as { ranked: typeof aiRanked }
|
||||
aiRanked = parsed.ranked ?? []
|
||||
} catch {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to parse ranking response as JSON' })
|
||||
}
|
||||
|
||||
// Build a lookup by anonymousId for project data
|
||||
const projectByAnonId = new Map(
|
||||
anonymized.map((a) => [a.project_id, projects.find((p) => p.id === idMap.get(a.project_id))!])
|
||||
)
|
||||
|
||||
const rankedProjects: RankedProjectEntry[] = aiRanked
|
||||
.filter((entry) => idMap.has(entry.project_id))
|
||||
.map((entry) => {
|
||||
const realId = idMap.get(entry.project_id)!
|
||||
const proj = projectByAnonId.get(entry.project_id)!
|
||||
return {
|
||||
projectId: realId,
|
||||
rank: entry.rank,
|
||||
compositeScore: computeCompositeScore(
|
||||
proj.avgGlobalScore,
|
||||
proj.passRate,
|
||||
proj.evaluatorCount,
|
||||
maxEvaluatorCount,
|
||||
),
|
||||
avgGlobalScore: proj.avgGlobalScore,
|
||||
passRate: proj.passRate,
|
||||
evaluatorCount: proj.evaluatorCount,
|
||||
aiRationale: entry.rationale,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
|
||||
return {
|
||||
category,
|
||||
rankedProjects,
|
||||
parsedRules,
|
||||
totalEligible: projects.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick-rank: parse criteria and execute ranking in one step.
|
||||
* Returns results for all categories (RANK-04).
|
||||
* The prisma parameter is used to fetch project evaluation data.
|
||||
*/
|
||||
export async function quickRank(
|
||||
criteriaText: string,
|
||||
roundId: string,
|
||||
prisma: PrismaClient,
|
||||
userId?: string,
|
||||
): Promise<{ startup: RankingResult; concept: RankingResult; parsedRules: ParsedRankingRule[] }> {
|
||||
const parsedRules = await parseRankingCriteria(criteriaText, userId, roundId)
|
||||
const [startup, concept] = await Promise.all([
|
||||
fetchAndRankCategory('STARTUP', parsedRules, roundId, prisma, userId),
|
||||
fetchAndRankCategory('BUSINESS_CONCEPT', parsedRules, roundId, prisma, userId),
|
||||
])
|
||||
return { startup, concept, parsedRules }
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper: fetch eligible projects for one category and execute ranking.
|
||||
* Excluded: withdrawn projects and projects with zero submitted evaluations (locked decision).
|
||||
*
|
||||
* Exported so the tRPC router can call it separately when executing pre-parsed rules.
|
||||
*/
|
||||
export async function fetchAndRankCategory(
|
||||
category: CompetitionCategory,
|
||||
parsedRules: ParsedRankingRule[],
|
||||
roundId: string,
|
||||
prisma: PrismaClient,
|
||||
userId?: string,
|
||||
): Promise<RankingResult> {
|
||||
// Query submitted evaluations grouped by projectId for this category
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: {
|
||||
roundId,
|
||||
isRequired: true,
|
||||
project: {
|
||||
competitionCategory: category,
|
||||
// Exclude withdrawn projects
|
||||
projectRoundStates: {
|
||||
none: { roundId, state: 'WITHDRAWN' },
|
||||
},
|
||||
},
|
||||
evaluation: {
|
||||
status: 'SUBMITTED', // Only count completed evaluations
|
||||
},
|
||||
},
|
||||
include: {
|
||||
evaluation: {
|
||||
select: { globalScore: true, binaryDecision: true },
|
||||
},
|
||||
project: {
|
||||
select: { id: true, competitionCategory: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Group by projectId
|
||||
const byProject = new Map<string, Array<{ globalScore: number | null; binaryDecision: boolean | null }>>()
|
||||
for (const a of assignments) {
|
||||
if (!a.evaluation) continue
|
||||
const list = byProject.get(a.project.id) ?? []
|
||||
list.push({ globalScore: a.evaluation.globalScore, binaryDecision: a.evaluation.binaryDecision })
|
||||
byProject.set(a.project.id, list)
|
||||
}
|
||||
|
||||
// Build ProjectForRanking, excluding projects with zero submitted evaluations
|
||||
const projects: ProjectForRanking[] = []
|
||||
for (const [projectId, evals] of byProject.entries()) {
|
||||
if (evals.length === 0) continue // Exclude: no submitted evaluations
|
||||
const avgGlobalScore = evals.some((e) => e.globalScore != null)
|
||||
? evals.filter((e) => e.globalScore != null).reduce((sum, e) => sum + e.globalScore!, 0) /
|
||||
evals.filter((e) => e.globalScore != null).length
|
||||
: null
|
||||
const passRate = computePassRate(evals)
|
||||
projects.push({ id: projectId, competitionCategory: category, avgGlobalScore, passRate, evaluatorCount: evals.length })
|
||||
}
|
||||
|
||||
return executeAIRanking(parsedRules, projects, category, userId, roundId)
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export const NotificationTypes = {
|
||||
DEADLINE_1H: 'DEADLINE_1H',
|
||||
ROUND_AUTO_CLOSED: 'ROUND_AUTO_CLOSED',
|
||||
EXPORT_READY: 'EXPORT_READY',
|
||||
AI_RANKING_COMPLETE: 'AI_RANKING_COMPLETE',
|
||||
AI_RANKING_FAILED: 'AI_RANKING_FAILED',
|
||||
SYSTEM_ERROR: 'SYSTEM_ERROR',
|
||||
|
||||
// Jury notifications
|
||||
@@ -121,6 +123,8 @@ export const NotificationIcons: Record<string, string> = {
|
||||
[NotificationTypes.WINNER_ANNOUNCEMENT]: 'Trophy',
|
||||
[NotificationTypes.AWARD_VOTING_OPEN]: 'Vote',
|
||||
[NotificationTypes.AWARD_RESULTS]: 'Trophy',
|
||||
[NotificationTypes.AI_RANKING_COMPLETE]: 'BarChart3',
|
||||
[NotificationTypes.AI_RANKING_FAILED]: 'AlertTriangle',
|
||||
}
|
||||
|
||||
// Priority by notification type
|
||||
@@ -143,6 +147,8 @@ export const NotificationPriorities: Record<string, NotificationPriority> = {
|
||||
[NotificationTypes.ADVANCED_FINAL]: 'high',
|
||||
[NotificationTypes.WINNER_ANNOUNCEMENT]: 'high',
|
||||
[NotificationTypes.AWARD_VOTING_OPEN]: 'high',
|
||||
[NotificationTypes.AI_RANKING_COMPLETE]: 'normal',
|
||||
[NotificationTypes.AI_RANKING_FAILED]: 'high',
|
||||
}
|
||||
|
||||
interface CreateNotificationParams {
|
||||
|
||||
@@ -20,6 +20,7 @@ export type AIAction =
|
||||
| 'EVALUATION_SUMMARY'
|
||||
| 'ROUTING'
|
||||
| 'SHORTLIST'
|
||||
| 'RANKING'
|
||||
|
||||
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ export const EvaluationConfigSchema = z.object({
|
||||
coiRequired: z.boolean().default(true),
|
||||
|
||||
peerReviewEnabled: z.boolean().default(false),
|
||||
showJurorProgressDashboard: z.boolean().default(false),
|
||||
anonymizationLevel: z
|
||||
.enum(['fully_anonymous', 'show_initials', 'named'])
|
||||
.default('fully_anonymous'),
|
||||
@@ -136,6 +137,11 @@ export const EvaluationConfigSchema = z.object({
|
||||
.default('admin_decides'),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
// Ranking (Phase 1)
|
||||
rankingEnabled: z.boolean().default(false),
|
||||
rankingCriteria: z.string().optional(),
|
||||
autoRankOnComplete: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export type EvaluationConfig = z.infer<typeof EvaluationConfigSchema>
|
||||
|
||||
Reference in New Issue
Block a user