Compare commits
268 Commits
4c0efb232c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b40fe7726 | |||
| 1c78ecf21d | |||
| 1356809cb1 | |||
| 1ebdf5f9c9 | |||
| a68ec3fb45 | |||
| 6f55fdf81f | |||
| 94cbfec70a | |||
| b85a9b9a7b | |||
| a8b8643936 | |||
| 0390d05727 | |||
| ec30dc83d6 | |||
| 37351044ed | |||
| a1e758bc39 | |||
| a556732b46 | |||
| e7b99fff63 | |||
| 3180bfa946 | |||
| d4c946470a | |||
| 2e8ab91e07 | |||
| 60426c1f56 | |||
| 8427999578 | |||
| a358e9940d | |||
| 34fc0b81e0 | |||
| ea46d7293f | |||
| 0d9a985377 | |||
| 6852278f92 | |||
| 22731e7978 | |||
| 0d94ee1fe8 | |||
| ffe12a9e85 | |||
| 94814bd505 | |||
| 6b6f5e33f5 | |||
| 67670472f7 | |||
| 461551b489 | |||
| b7905a82e1 | |||
| fd2624f198 | |||
| 2be7318cb9 | |||
| 8d6f3ca11f | |||
| 12e4864d36 | |||
| abb6e6df83 | |||
| 8cdcc85555 | |||
| ee8e90132e | |||
| b6ba5d7145 | |||
| c6d0f90038 | |||
| 78334676d0 | |||
| 335c736219 | |||
| ca888b4eb7 | |||
| 27ecbc40b3 | |||
| 875c2e8f48 | |||
| 13f125af28 | |||
| c8c26beed2 | |||
| 503a375701 | |||
| 79ac60dc1e | |||
| 6c52e519e5 | |||
| b1a994a9d6 | |||
| f0d5599167 | |||
| 43e21c6c6e | |||
| af03c12ae5 | |||
| 267d26581d | |||
| a39e27f6ff | |||
| 1103d42439 | |||
| f24bea3df2 | |||
| 8f2f054c57 | |||
| 5854aa37a9 | |||
| ebc6331d1f | |||
| d183d98d9a | |||
| 84d90e1978 | |||
| daf50831f1 | |||
| 1d4e31ddd1 | |||
| 924f8071e1 | |||
| f79a6d1341 | |||
| 050836d522 | |||
| 43801340f8 | |||
| 2be3f9d02f | |||
| cfee3bc8a9 | |||
| 7735f3ecdf | |||
| 0285622fe1 | |||
| c0f2b9bd38 | |||
| 8c5f4998a8 | |||
| 761a203063 | |||
| cb688ba3e6 | |||
| ac86e025e2 | |||
| 5a3f8d9837 | |||
| 2bccb52a16 | |||
| 1f4f29c2cc | |||
| 8db9c72f4c | |||
| 80a7bedddc | |||
| d2e0dbdc94 | |||
| 36045bef9d | |||
| 2df9c54de2 | |||
| 19b58e4434 | |||
| c6ebd169dd | |||
| 49e706f2cf | |||
| 68aa393559 | |||
| 9b3a9f6cbf | |||
| dd004baf79 | |||
| 2f1136646e | |||
| 36560a1837 | |||
| 25e06e11e4 | |||
| f200eda692 | |||
| ba7f068b1e | |||
| 28ae934c57 | |||
| 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 | |||
| 0a96960ae2 | |||
| f3fd9eebee | |||
| 230347005c | |||
| 91563f3f47 | |||
| 5ece50268b | |||
| 61c4d0eb75 | |||
| 3bc6552f47 | |||
| ab2c73bad2 | |||
| feccd269f7 | |||
| 95d51e7de3 | |||
| 49e9405e01 | |||
| c1b3a6ade3 | |||
| f26ee3f076 | |||
| f7bc3b4dd2 | |||
| 09cc49d920 | |||
| 351d8144d9 | |||
| 5a609457c2 | |||
| ee2f10e080 | |||
| f42b452899 | |||
| 161cd1684a | |||
| 2e4b95f29c | |||
| ee3bfec8b0 | |||
| 8e607478d5 | |||
| 6d4ee93ab3 | |||
| 350e9b96e8 | |||
| 533d8cb8e5 | |||
| 4f73ba5a0e | |||
| 26e8830df2 | |||
| 6e697cb5d8 | |||
| a714c56e81 | |||
| a6b6763fa4 | |||
| d717040f03 | |||
| 9f7b76b3cb | |||
| 213efdba87 | |||
| 5eea430ebd | |||
| 8125ca6567 | |||
| 77cbc64b33 | |||
|
|
03c59c188e | ||
|
|
f1062f4805 | ||
|
|
34fdd0ba8e | ||
|
|
0d0571ebf2 | ||
|
|
0607d79484 | ||
|
|
57a16d089d | ||
|
|
fbcbf895be | ||
|
|
4519bc6080 | ||
|
|
bf02684736 | ||
|
|
d9d6a63e4a | ||
|
|
c7f20e2f32 | ||
|
|
d3a63b0354 | ||
|
|
9d945c33f9 | ||
|
|
8ae8145d86 | ||
|
|
0ff84686f0 | ||
|
|
1dcc7a5990 | ||
|
|
725d88fec2 | ||
|
|
c62a335424 | ||
|
|
baca483fcb | ||
|
|
ee8b12e59c | ||
|
|
51e18870b6 | ||
|
|
ae1685179c | ||
|
|
d117090fca | ||
|
|
099157bf74 | ||
| 1308c3ba87 | |||
| aa1bf564ee | |||
|
|
6838b01724 | ||
|
|
735b841f4a | ||
|
|
7c3f041892 | ||
|
|
998ffe3af8 | ||
|
|
6abf962fa0 | ||
|
|
8bbdc31d17 | ||
|
|
a212bde51b | ||
|
|
7e85348a6d | ||
|
|
cab311fbbb | ||
|
|
9c19661400 | ||
|
|
8d28104d51 | ||
|
|
0f6473c999 | ||
|
|
9ce56f13fd | ||
|
|
73759eaddd | ||
|
|
f814cf6dc4 | ||
|
|
9b1b319362 | ||
|
|
7b16873b9c | ||
|
|
fc7a37094b | ||
|
|
35f30af7ce | ||
|
|
6e9fcda45a | ||
| 1ec2247295 | |||
| 1c68512598 | |||
| 04c54b6794 | |||
| d02b0b91b9 | |||
| 8a7da0fd93 | |||
| 70d24036f9 | |||
| 619206c03f | |||
| 1fe6667400 | |||
|
|
4fa3ca0bb6 | ||
|
|
cf1508f856 | ||
|
|
bed444e5f4 | ||
|
|
a4ff278db2 | ||
|
|
1c6961355b | ||
|
|
a02ed59158 | ||
|
|
6743119c4d | ||
|
|
a7b6031f4d | ||
|
|
a62f511d7f | ||
|
|
cef4709444 | ||
|
|
cf3c7631cb | ||
|
|
b3b3bbb8b3 | ||
|
|
bfdbd0fc6a | ||
|
|
ef1bf24388 | ||
|
|
f9016168e7 | ||
|
|
a006c6505c | ||
|
|
d80043c4aa | ||
|
|
1a0525c108 | ||
|
|
842e79e319 | ||
|
|
ed5e782f61 | ||
|
|
c9640c6086 | ||
|
|
771f35c695 | ||
|
|
fbeec846a3 | ||
|
|
cfeef9a601 | ||
|
|
fcee8761b9 | ||
| 7b98b64c1c | |||
|
|
09049d2911 | ||
|
|
3fb0d128a1 | ||
|
|
5965f7889d | ||
|
|
b2279067e2 | ||
|
|
014bb15890 | ||
|
|
f12c29103c | ||
|
|
65a22e6f19 | ||
|
|
989db4dc14 | ||
|
|
5e0c8b2dfe | ||
|
|
85a0fa5016 | ||
|
|
c707899179 | ||
|
|
4d40afec6e | ||
|
|
effc078918 | ||
|
|
763b2ef0f5 | ||
|
|
86fa542371 | ||
|
|
079468d2ca | ||
| de73a6f080 | |||
| 80c9e35971 | |||
| 93f4ad4b31 | |||
| 8e5fc18da6 | |||
| 845554fdb8 | |||
| 7f334ed095 | |||
| f572336781 | |||
| 2fb26d4734 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -58,3 +58,7 @@ build-output.txt
|
|||||||
# Misc
|
# Misc
|
||||||
*.log
|
*.log
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
# Private keys and secrets
|
||||||
|
private/
|
||||||
|
public/build-id.json
|
||||||
|
|||||||
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*
|
||||||
@@ -11,7 +11,7 @@ RUN apk add --no-cache libc6-compat
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* .npmrc* ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
# Rebuild the source code only when needed
|
||||||
@@ -23,9 +23,9 @@ COPY . .
|
|||||||
# Generate Prisma client
|
# Generate Prisma client
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Build Next.js
|
# Build Next.js — mount .next/cache as a Docker build cache for faster rebuilds
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
RUN npm run build
|
RUN --mount=type=cache,target=/app/.next/cache npm run build
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Production image, copy all the files and run next
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
@@ -69,5 +69,8 @@ EXPOSE 7600
|
|||||||
ENV PORT=7600
|
ENV PORT=7600
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:7600/api/health || exit 1
|
||||||
|
|
||||||
# Run via entrypoint (migrate then start)
|
# Run via entrypoint (migrate then start)
|
||||||
CMD ["/app/docker-entrypoint.sh"]
|
CMD ["/app/docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ WORKDIR /app
|
|||||||
RUN apk add --no-cache libc6-compat openssl
|
RUN apk add --no-cache libc6-compat openssl
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* .npmrc* ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm install && npm install tailwindcss-animate
|
RUN npm install && npm install tailwindcss-animate
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: mopc-postgres-dev
|
container_name: mopc-postgres-dev
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5433:5432"
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=${POSTGRES_USER:-mopc}
|
- POSTGRES_USER=${POSTGRES_USER:-mopc}
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-devpassword}
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-devpassword}
|
||||||
@@ -68,7 +68,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-mopc}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-mopc}
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-mopc}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-mopc}?connection_limit=10&pool_timeout=30
|
||||||
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
|
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
|
||||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dev-secret-key-for-local-development-only}
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dev-secret-key-for-local-development-only}
|
||||||
- AUTH_SECRET=${AUTH_SECRET:-dev-secret-key-for-local-development-only}
|
- AUTH_SECRET=${AUTH_SECRET:-dev-secret-key-for-local-development-only}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc
|
- DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc?connection_limit=10&pool_timeout=30
|
||||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||||
- AUTH_SECRET=${NEXTAUTH_SECRET}
|
- AUTH_SECRET=${NEXTAUTH_SECRET}
|
||||||
@@ -50,6 +50,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- mopc-network
|
- mopc-network
|
||||||
|
- minio-external
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:7600/api/health').then(r=>{if(!r.ok)throw r;process.exit(0)}).catch(()=>process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://localhost:7600/api/health').then(r=>{if(!r.ok)throw r;process.exit(0)}).catch(()=>process.exit(1))"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -82,3 +83,6 @@ volumes:
|
|||||||
networks:
|
networks:
|
||||||
mopc-network:
|
mopc-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
minio-external:
|
||||||
|
external: true
|
||||||
|
name: minio_mopc-minio
|
||||||
|
|||||||
@@ -33,5 +33,44 @@ else
|
|||||||
echo "==> Database already seeded ($USER_COUNT users found), skipping seed."
|
echo "==> Database already seeded ($USER_COUNT users found), skipping seed."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Always sync notification email settings (upsert — safe for existing data)
|
||||||
|
echo "==> Syncing notification email settings..."
|
||||||
|
npx tsx prisma/seed-notification-settings.ts || echo "WARNING: Notification settings sync failed."
|
||||||
|
|
||||||
|
# Sync team lead links only if there are unlinked submitters
|
||||||
|
UNLINKED_COUNT=$(node -e "
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient();
|
||||||
|
p.\$queryRaw\`
|
||||||
|
SELECT COUNT(*)::int AS c FROM \"Project\" p
|
||||||
|
WHERE p.\"submittedByUserId\" IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM \"TeamMember\" tm
|
||||||
|
WHERE tm.\"projectId\" = p.id AND tm.\"userId\" = p.\"submittedByUserId\"
|
||||||
|
)
|
||||||
|
\`.then(r => { console.log(r[0].c); p.\$disconnect(); }).catch(() => { console.log('0'); p.\$disconnect(); });
|
||||||
|
" 2>/dev/null || echo "0")
|
||||||
|
|
||||||
|
if [ "$UNLINKED_COUNT" != "0" ]; then
|
||||||
|
echo "==> Syncing ${UNLINKED_COUNT} unlinked team lead links..."
|
||||||
|
npx tsx prisma/seed-team-leads.ts || echo "WARNING: Team lead sync failed."
|
||||||
|
else
|
||||||
|
echo "==> Team lead links already synced, skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Starting application..."
|
echo "==> Starting application..."
|
||||||
exec node server.js
|
|
||||||
|
# Graceful shutdown: forward SIGTERM/SIGINT to the Node process
|
||||||
|
# so in-flight requests can complete before the container exits.
|
||||||
|
shutdown() {
|
||||||
|
echo "==> Received shutdown signal, stopping gracefully..."
|
||||||
|
kill -TERM "$NODE_PID" 2>/dev/null
|
||||||
|
wait "$NODE_PID"
|
||||||
|
exit $?
|
||||||
|
}
|
||||||
|
|
||||||
|
trap shutdown TERM INT
|
||||||
|
|
||||||
|
node server.js &
|
||||||
|
NODE_PID=$!
|
||||||
|
wait "$NODE_PID"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
Jury 1 attribués,Full name,Téléphone,Lien Whatsapp,E-mail,Project's name,Team members,Country,Tri par zone,University,Category,Date of creation,Issue,"Comment ",Mentorship,How did you hear about MOPC?,Application status,PHASE 1 - Submission,PHASE 2 - Submission
|
Jury 1 attribués,Full name,Téléphone,Lien Whatsapp,E-mail,Project's name,Team members,Country,Tri par zone,University,Category,Date of creation,Issue,"Comment ",Mentorship,How did you hear about MOPC?,Application status,PHASE 1 - Submission,PHASE 2 - Submission
|
||||||
,Mutave Nelly,+254704458380,Envoyer le message,mutavenelly.mn@gmail.com,Revamp Flips,Nthatisi Lesala,"Nairobi, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,1997-01-24,Technology & innovations,Hrkb,true,Friend shared link,,,
|
,Mutave Nelly,+254704458380,Envoyer le message,mutavenelly.mn@gmail.com,Revamp Flips,Nthatisi Lesala,"Nairobi, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,1997-01-24,Technology & innovations,Hrkb,true,Friend shared link,,,
|
||||||
,Omoding Olinga Simon,+256773351242,Envoyer le message,simonomoding.ace@gmail.com,Dagim Fisheries (U) Ltd,"Omoding Simon, Ilukat Musa, Omiel Peter, Omongole Richard, Fellista Nakatabirwa","Kampala, Ouganda","Africa, Ouganda",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-01-05,Sustainable fishing and aquaculture & blue food,"Dagim Fisheries directly advances equitable access to safe, nutritious, affordable food while improving planetary health through zero-waste processing and sustainable fishing. Our multidisciplinary approach integrates nutrition science, food engineering, supply chain management, environmental conservation, and economics. We address malnutrition, reduce waste, empower fishing communities, and protect Lake Victoria's and Kyoga's ecosystem creating regenerative food systems scalable across East Africa toward the billion-lives impact goal.",true,Through Linkedinn social media,,,
|
,Omoding Olinga Simon,+256773351242,Envoyer le message,simonomoding.ace@gmail.com,Dagim Fisheries (U) Ltd,"Omoding Simon, Ilukat Musa, Omiel Peter, Omongole Richard, Fellista Nakatabirwa","Kampala, Ouganda","Africa, Ouganda",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-01-05,Sustainable fishing and aquaculture & blue food,"Dagim Fisheries directly advances equitable access to safe, nutritious, affordable food while improving planetary health through zero-waste processing and sustainable fishing. Our multidisciplinary approach integrates nutrition science, food engineering, supply chain management, environmental conservation, and economics. We address malnutrition, reduce waste, empower fishing communities, and protect Lake Victoria's and Kyoga's ecosystem creating regenerative food systems scalable across East Africa toward the billion-lives impact goal.",true,Through Linkedinn social media,Received,https://drive.google.com/drive/folders/1uwb8O2na8OvbEp-3-QiUSQoPCVUbCYgE?usp=drive_link,
|
||||||
,SENI Abd-Ramane,+2290161149564,Envoyer le message,seniramane@gmail.com,OceanClean Tech,"SENI Abd-Ramane, DJIBRIL Samir, SOULÉ SEIDOU Mansoura","Banikoara, Bénin","Africa, Bénin",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-12-29,Reduction of pollution (plastics chemicals noise light...),"_Project Title_: OceanClean Tech
|
,SENI Abd-Ramane,+2290161149564,Envoyer le message,seniramane@gmail.com,OceanClean Tech,"SENI Abd-Ramane, DJIBRIL Samir, SOULÉ SEIDOU Mansoura","Banikoara, Bénin","Africa, Bénin",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-12-29,Reduction of pollution (plastics chemicals noise light...),"_Project Title_: OceanClean Tech
|
||||||
|
|
||||||
_Objective_: The OceanClean Tech project aims to reduce plastic pollution in the oceans by developing a marine plastic waste collection system. The main goal is to clean up polluted marine areas and prevent new plastic waste from entering marine ecosystems.
|
_Objective_: The OceanClean Tech project aims to reduce plastic pollution in the oceans by developing a marine plastic waste collection system. The main goal is to clean up polluted marine areas and prevent new plastic waste from entering marine ecosystems.
|
||||||
@@ -84,12 +84,12 @@ Pelagos aims to transform oceans into self-healing climate engines while creatin
|
|||||||
,Yahuza Sani Hudu,+2348146036089,Envoyer le message,ysanihudu@gmail.com,CleanUp MDC,"Abner Ayuba Atuga, Ameer Saeed","Zaria, Kaduna, Nigéria","Africa, Nigeria",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-02-26,Reduction of pollution (plastics chemicals noise light...),"CleanUp Multi Dyna mic Concept (CleanUp MDC) is a Nigeria-based social enterprise advancing inclusive climate-tech solutions within the circular economy. Our flagship innovation, JoliTrash, is a toll-free, AI-powered, voice-based recycling platform that allows households and informal waste actors to sort and sell recyclable waste using a simple AI phone call in their local language without the need for smartphones, internet access, or digital literacy. Nigeria generates about 2.5 million tons of plastic waste annually, yet less than 10% is recycled (World Bank). At the same time, over 70% of Nigerians lack easy access to recycling facilities, locations, or clear recycling processes (NESREA, 2022), and 48% of the population has poor or no internet connectivity (NCC, 2023), making most app-based recycling platforms inaccessible to low-income and marginalized communities. CleanUp MDC was created to bridge this gap by enabling users to dial a toll-free number on any basic phone (cell-phone) and interact with our AI in Hausa, Yoruba, Igbo, Pidgin, or English with no language barrier, our AI identify users location, connect with nearby verified waste collectors, and user earn income from recyclables. Our target market includes low-income households, women, youth, informal waste pickers, and underserved urban and peri-urban communities across Nigeria, as well as recycling agents and aggregators seeking reliable recyclable feedstock. To date, we have onboarded over 30,163 active users from underserved communities, 17,907 of them women, and facilitated the recovery of more than 10,000 tons of plastic waste, positioning our operations to contribute to an estimated 25,000 tons of CO₂ emissions reduction annually, equivalent to removing about 4,000 fuel-powered cars from the road each year. We partner with the Waste Pickers Association of Nigeria (WAPAN), we are scaling nationwide with the long-term goal of expanding across Africa. Our main goals are to expand access to recycli",true,"The Commissioner for Environment and Natural Resources of Kaduna State Government, Nigeria Share's the link with my startup",Received,https://drive.google.com/drive/folders/1yuSmCXSrORvgjYkHSpb5fFz9qFNe7FJD?usp=drive_link,
|
,Yahuza Sani Hudu,+2348146036089,Envoyer le message,ysanihudu@gmail.com,CleanUp MDC,"Abner Ayuba Atuga, Ameer Saeed","Zaria, Kaduna, Nigéria","Africa, Nigeria",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-02-26,Reduction of pollution (plastics chemicals noise light...),"CleanUp Multi Dyna mic Concept (CleanUp MDC) is a Nigeria-based social enterprise advancing inclusive climate-tech solutions within the circular economy. Our flagship innovation, JoliTrash, is a toll-free, AI-powered, voice-based recycling platform that allows households and informal waste actors to sort and sell recyclable waste using a simple AI phone call in their local language without the need for smartphones, internet access, or digital literacy. Nigeria generates about 2.5 million tons of plastic waste annually, yet less than 10% is recycled (World Bank). At the same time, over 70% of Nigerians lack easy access to recycling facilities, locations, or clear recycling processes (NESREA, 2022), and 48% of the population has poor or no internet connectivity (NCC, 2023), making most app-based recycling platforms inaccessible to low-income and marginalized communities. CleanUp MDC was created to bridge this gap by enabling users to dial a toll-free number on any basic phone (cell-phone) and interact with our AI in Hausa, Yoruba, Igbo, Pidgin, or English with no language barrier, our AI identify users location, connect with nearby verified waste collectors, and user earn income from recyclables. Our target market includes low-income households, women, youth, informal waste pickers, and underserved urban and peri-urban communities across Nigeria, as well as recycling agents and aggregators seeking reliable recyclable feedstock. To date, we have onboarded over 30,163 active users from underserved communities, 17,907 of them women, and facilitated the recovery of more than 10,000 tons of plastic waste, positioning our operations to contribute to an estimated 25,000 tons of CO₂ emissions reduction annually, equivalent to removing about 4,000 fuel-powered cars from the road each year. We partner with the Waste Pickers Association of Nigeria (WAPAN), we are scaling nationwide with the long-term goal of expanding across Africa. Our main goals are to expand access to recycli",true,"The Commissioner for Environment and Natural Resources of Kaduna State Government, Nigeria Share's the link with my startup",Received,https://drive.google.com/drive/folders/1yuSmCXSrORvgjYkHSpb5fFz9qFNe7FJD?usp=drive_link,
|
||||||
,Nesphory Mwambai,+254714520023,Envoyer le message,mwambai@seamo.earth,seamo.earth,"Nesphory Mwambai, Lewis Kimaru","Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-08-22,Restoration of marine habitats & ecosystems,"seamo.earth initiative focused on utilizing artificial intelligence (AI) to explore, document, monitor, and preserve the mariculture and seascapes of the Pwani regions. This project aims to enhance our understanding and protection of marine environments through the development of eco-friendly and climate adaptive technologies.",true,email news letter,Received,https://drive.google.com/drive/folders/1eOyDGZwwlNNAzbwwC-CUVmJi4gM3kDLI?usp=drive_link,
|
,Nesphory Mwambai,+254714520023,Envoyer le message,mwambai@seamo.earth,seamo.earth,"Nesphory Mwambai, Lewis Kimaru","Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-08-22,Restoration of marine habitats & ecosystems,"seamo.earth initiative focused on utilizing artificial intelligence (AI) to explore, document, monitor, and preserve the mariculture and seascapes of the Pwani regions. This project aims to enhance our understanding and protection of marine environments through the development of eco-friendly and climate adaptive technologies.",true,email news letter,Received,https://drive.google.com/drive/folders/1eOyDGZwwlNNAzbwwC-CUVmJi4gM3kDLI?usp=drive_link,
|
||||||
,Fiona McOmish,+447722083419,Envoyer le message,fiona.mcomish@algae-scope.com,Algae Scope,Natasha Yamamura; Alejandra Noren; Farshid Pahlevani,"Venise, Vénétie, Italie","Europe, Italia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-12-16,Technology & innovations,We replace toxic PFAS chemicals in textiles with a water- and fire-resistant coating made 100% from seaweed. We sell our high-performing solution to textile manufacturers and formulators in a 'drop-in' format.,true,LinkedIn,Received,https://drive.google.com/drive/folders/1KA4GZXWQhMCodASq_15Ksx5yzak5AwpT?usp=drive_link,
|
,Fiona McOmish,+447722083419,Envoyer le message,fiona.mcomish@algae-scope.com,Algae Scope,Natasha Yamamura; Alejandra Noren; Farshid Pahlevani,"Venise, Vénétie, Italie","Europe, Italia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-12-16,Technology & innovations,We replace toxic PFAS chemicals in textiles with a water- and fire-resistant coating made 100% from seaweed. We sell our high-performing solution to textile manufacturers and formulators in a 'drop-in' format.,true,LinkedIn,Received,https://drive.google.com/drive/folders/1KA4GZXWQhMCodASq_15Ksx5yzak5AwpT?usp=drive_link,
|
||||||
,Veronica Nzuu,+254748488312,Envoyer le message,veramichael2000@gmail.com,Furies,Angelo Mulu,"Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-05-29,Consumer awareness and education,"My project focuses on empowering children and youth in my community to take action on plastic pollution through simple, community led learning and action. The objective is to build awareness, responsibility, and leadership by combining environmental education with practical activities such as waste segregation, plastic collection, creative upcycling, and community dialogue. By using participatory and inclusive approaches, especially for girls and marginalized youth, the project aims to strengthen community ownership of sustainability solutions and inspire long term behavior change at the local level.",true,Social Media linked in,,,
|
,Veronica Nzuu,+254748488312,Envoyer le message,veramichael2000@gmail.com,Furies,Angelo Mulu,"Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-05-29,Consumer awareness and education,"My project focuses on empowering children and youth in my community to take action on plastic pollution through simple, community led learning and action. The objective is to build awareness, responsibility, and leadership by combining environmental education with practical activities such as waste segregation, plastic collection, creative upcycling, and community dialogue. By using participatory and inclusive approaches, especially for girls and marginalized youth, the project aims to strengthen community ownership of sustainability solutions and inspire long term behavior change at the local level.",true,Social Media linked in,Received,https://drive.google.com/drive/folders/1rRGp6lWNe0jbNVMdZKEawVitpEEv63rM?usp=drive_link,
|
||||||
,Neville Agesa,+254796438122,Envoyer le message,agesanevil@gmail.com,Sustainable Blue Food & Livelihoods Innovation,"Robert Meya,Hannah Mathenge,JohnChaka","Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-02-01,Sustainable fishing and aquaculture & blue food,"The Tsunza Community, located on Kenya’s South Coast in Kwale County, is a vital ecological hub linking mangrove forests, wetlands, and the Mwache River estuary. These interconnected ecosystems support fisheries, biodiversity, and local livelihoods but face increasing pressure from degradation, pollution, and declining fish stocks.
|
,Neville Agesa,+254796438122,Envoyer le message,agesanevil@gmail.com,Sustainable Blue Food & Livelihoods Innovation,"Robert Meya,Hannah Mathenge,JohnChaka","Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-02-01,Sustainable fishing and aquaculture & blue food,"The Tsunza Community, located on Kenya’s South Coast in Kwale County, is a vital ecological hub linking mangrove forests, wetlands, and the Mwache River estuary. These interconnected ecosystems support fisheries, biodiversity, and local livelihoods but face increasing pressure from degradation, pollution, and declining fish stocks.
|
||||||
|
|
||||||
This project aims to protect and restore mangrove and wetland ecosystems while strengthening sustainable blue livelihoods. Through community-led mangrove restoration, marine pollution awareness, and youth and women engagement in sustainable fisheries and aquaculture practices, the project promotes ocean protection alongside economic resilience.
|
This project aims to protect and restore mangrove and wetland ecosystems while strengthening sustainable blue livelihoods. Through community-led mangrove restoration, marine pollution awareness, and youth and women engagement in sustainable fisheries and aquaculture practices, the project promotes ocean protection alongside economic resilience.
|
||||||
|
|
||||||
By integrating nature-based solutions, environmental education, and livelihood innovation, the initiative positions Tsunza as a scalable model for community-driven ocean conservation and sustainable development.",true,Gensea opportunities,,,
|
By integrating nature-based solutions, environmental education, and livelihood innovation, the initiative positions Tsunza as a scalable model for community-driven ocean conservation and sustainable development.",true,Gensea opportunities,Received,https://drive.google.com/drive/folders/1K87ubq9S801uVUVmSj79cmygKmrmd6ig?usp=drive_link,
|
||||||
,Anshika Sarraf,+917897130506,Envoyer le message,anshika.sarraf_ug2024@ashoka.edu.in,Auralis Blue,Anshika Sarraf,"Sonipat, Haryana, Inde",Asia,Ashoka University + Sonipat,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"Auralis Blue is tackling a problem few people see but that is harming our oceans: underwater noise pollution. Ships, ports, and offshore construction create constant sound that travels far underwater, interfering with how whales, dolphins, and fish communicate, migrate, and reproduce. Auralis Blue measures this invisible threat and turns it into clear, actionable data, helping maritime stakeholders protect marine life while continuing sustainable operations.
|
,Anshika Sarraf,+917897130506,Envoyer le message,anshika.sarraf_ug2024@ashoka.edu.in,Auralis Blue,Anshika Sarraf,"Sonipat, Haryana, Inde",Asia,Ashoka University + Sonipat,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"Auralis Blue is tackling a problem few people see but that is harming our oceans: underwater noise pollution. Ships, ports, and offshore construction create constant sound that travels far underwater, interfering with how whales, dolphins, and fish communicate, migrate, and reproduce. Auralis Blue measures this invisible threat and turns it into clear, actionable data, helping maritime stakeholders protect marine life while continuing sustainable operations.
|
||||||
|
|
||||||
Underwater noise is an invisible threat, but its effects are very real: studies show that marine mammals rely on sound to survive, and high noise levels can cause stress, confusion, and even death in fish populations. Despite this, there are almost no tools that measure or manage noise systematically. Auralis Blue fills this gap, providing a science-based, scalable solution that can protect marine ecosystems worldwide.
|
Underwater noise is an invisible threat, but its effects are very real: studies show that marine mammals rely on sound to survive, and high noise levels can cause stress, confusion, and even death in fish populations. Despite this, there are almost no tools that measure or manage noise systematically. Auralis Blue fills this gap, providing a science-based, scalable solution that can protect marine ecosystems worldwide.
|
||||||
@@ -119,7 +119,7 @@ The project delivers a flood-resilient, decentralized sanitation s",true,BFA Glo
|
|||||||
,Christian Mwijage,+255711457346,Envoyer le message,chrissmwijage@gmail.com,ECOACT Tanzania,"• Mr. Bernard Ernest, Technical Director overseeing all production activities, holds a Master of Engineering in Biochemical Engineering. Mr. Christian Mwijage, Managing Director responsible for overall operations, holds a Bachelor’s degree in Business Administration and Marketing. Ms. Elineca Ndowo, Chief Finance Officer, holds a Master’s degree in Project Management and Financing from the University of Dar es Salaam.","Dar es Salam, Tanzanie","Africa, Tanzania",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-12-21,Reduction of pollution (plastics chemicals noise light...),"Every year, 9 million tonnes of plastic waste enter our oceans, polluting marine ecosystems and threatening ocean life. At this rate, by 2050 the ocean could contain more plastic than fish. At the same time, the world loses over 2 billion trees annually to meet the demand for timber in the furniture and construction industries—making deforestation the second leading driver of climate change.
|
,Christian Mwijage,+255711457346,Envoyer le message,chrissmwijage@gmail.com,ECOACT Tanzania,"• Mr. Bernard Ernest, Technical Director overseeing all production activities, holds a Master of Engineering in Biochemical Engineering. Mr. Christian Mwijage, Managing Director responsible for overall operations, holds a Bachelor’s degree in Business Administration and Marketing. Ms. Elineca Ndowo, Chief Finance Officer, holds a Master’s degree in Project Management and Financing from the University of Dar es Salaam.","Dar es Salam, Tanzanie","Africa, Tanzania",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-12-21,Reduction of pollution (plastics chemicals noise light...),"Every year, 9 million tonnes of plastic waste enter our oceans, polluting marine ecosystems and threatening ocean life. At this rate, by 2050 the ocean could contain more plastic than fish. At the same time, the world loses over 2 billion trees annually to meet the demand for timber in the furniture and construction industries—making deforestation the second leading driver of climate change.
|
||||||
We address both crises through a chemical-free, energy-efficient, AI-powered technology that transforms ocean-bound plastics and post-consumer packaging waste into high-quality, sustainable materials for furniture, building, and construction applications. By converting low-value, hard-to-recycle multi-layer plastic (MLP) waste into durable products, we are advancing the circular economy and giving new life to materials that would otherwise damage the environment.
|
We address both crises through a chemical-free, energy-efficient, AI-powered technology that transforms ocean-bound plastics and post-consumer packaging waste into high-quality, sustainable materials for furniture, building, and construction applications. By converting low-value, hard-to-recycle multi-layer plastic (MLP) waste into durable products, we are advancing the circular economy and giving new life to materials that would otherwise damage the environment.
|
||||||
We address one of the most persistent challenges in the plastics value chain: waste streams that lack viable conventional recycling pathways. We focus specifically on two difficult-to-recycle categories - multi-layer plastics (MLP), which combine multiple plastic layers and/or aluminum foil, and mixed plastic waste that cannot be economically or efficiently segregated. Globally, an estimated 6 billion tons of plastic waste have been generated, approximately 14% of which consists of MLP. Due to technical and economic limitations, these materials are typically landfilled, incinerated, or left uncollected, contributing significantly to environmental pollution and ecosystem degradation.",true,Social Media,Received,https://drive.google.com/drive/folders/1xCJ_8EpTEdBORiJHYwIRZO22z8e54fbx?usp=drive_link,
|
We address one of the most persistent challenges in the plastics value chain: waste streams that lack viable conventional recycling pathways. We focus specifically on two difficult-to-recycle categories - multi-layer plastics (MLP), which combine multiple plastic layers and/or aluminum foil, and mixed plastic waste that cannot be economically or efficiently segregated. Globally, an estimated 6 billion tons of plastic waste have been generated, approximately 14% of which consists of MLP. Due to technical and economic limitations, these materials are typically landfilled, incinerated, or left uncollected, contributing significantly to environmental pollution and ecosystem degradation.",true,Social Media,Received,https://drive.google.com/drive/folders/1xCJ_8EpTEdBORiJHYwIRZO22z8e54fbx?usp=drive_link,
|
||||||
,Olaleye Rofiat Olayinka,+2348038877293,Envoyer le message,olaleyerofiatyinka@gmail.com,Eco Heroes Nigeria limited,Olaleye Rofiat Olayinka Salaam Lateef Oladimeji Akinsanya Dorcas Olaleye Hassan Ogundairo Ganiyat,"Lagos, Nigéria","Africa, Nigeria",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-11-08,Reduction of pollution (plastics chemicals noise light...),"Eco Heroes is an incentive-based, tech-enabled recycling solution that prevents ocean-bound plastic waste from entering rivers and marine ecosystems. The project mobilizes communities to collect and exchange post-consumer plastic for rewards such as cash and essential services, creating a reliable supply of recovered plastic while improving livelihoods. Recovered materials are recycled and transformed into value-added products, including sewing threads, ensuring financial sustainability and scalable impact. The objective is to measurably reduce plastic pollution, create local economic value, and build a replicable model for coastal and river-connected communities.",true,I learned about the Monaco Ocean Protection Challenge through my involvement in an entrepreneurship and innovation programs focused on the blue economy and plastic pollution solutions.,,,
|
,Olaleye Rofiat Olayinka,+2348038877293,Envoyer le message,olaleyerofiatyinka@gmail.com,Eco Heroes Nigeria limited,Olaleye Rofiat Olayinka Salaam Lateef Oladimeji Akinsanya Dorcas Olaleye Hassan Ogundairo Ganiyat,"Lagos, Nigéria","Africa, Nigeria",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-11-08,Reduction of pollution (plastics chemicals noise light...),"Eco Heroes is an incentive-based, tech-enabled recycling solution that prevents ocean-bound plastic waste from entering rivers and marine ecosystems. The project mobilizes communities to collect and exchange post-consumer plastic for rewards such as cash and essential services, creating a reliable supply of recovered plastic while improving livelihoods. Recovered materials are recycled and transformed into value-added products, including sewing threads, ensuring financial sustainability and scalable impact. The objective is to measurably reduce plastic pollution, create local economic value, and build a replicable model for coastal and river-connected communities.",true,I learned about the Monaco Ocean Protection Challenge through my involvement in an entrepreneurship and innovation programs focused on the blue economy and plastic pollution solutions.,Received,https://drive.google.com/drive/folders/1RSk7W3_ay-xnsBy5UE1xKaT2uyxlOmfH?usp=drive_link,
|
||||||
,Shamim Wasii Nyanda,+255764190074,Envoyer le message,shamim@sunwaveltd.com,SUNWAVE,Ridhiwan Mseya,"Dar es Salam, Tanzanie","Africa, Tanzania",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-03-01,Sustainable fishing and aquaculture & blue food,"SUNWAVE provides small-scale fishers in Tanzania with solar-powered ice-making units to reduce fish spoilage. These machines, powered by solar energy, offer a sustainable and cost-effective solution to fish preservation, especially in remote areas where access to the power grid is limited. By keeping fish fresh for longer, these units help fishers reduce spoilage, maintain higher-quality products, and increase income. The ice-making machines are operated by trained personnel to ensure proper use and efficiency.",true,It was shared by SUNWAVE's Advisory Board member.,Received,https://drive.google.com/drive/folders/1qoWF1b-GIhjylYcGloZy1xnMZRjB0XEO?usp=drive_link,
|
,Shamim Wasii Nyanda,+255764190074,Envoyer le message,shamim@sunwaveltd.com,SUNWAVE,Ridhiwan Mseya,"Dar es Salam, Tanzanie","Africa, Tanzania",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-03-01,Sustainable fishing and aquaculture & blue food,"SUNWAVE provides small-scale fishers in Tanzania with solar-powered ice-making units to reduce fish spoilage. These machines, powered by solar energy, offer a sustainable and cost-effective solution to fish preservation, especially in remote areas where access to the power grid is limited. By keeping fish fresh for longer, these units help fishers reduce spoilage, maintain higher-quality products, and increase income. The ice-making machines are operated by trained personnel to ensure proper use and efficiency.",true,It was shared by SUNWAVE's Advisory Board member.,Received,https://drive.google.com/drive/folders/1qoWF1b-GIhjylYcGloZy1xnMZRjB0XEO?usp=drive_link,
|
||||||
,Lorna Mudegu,+254718059337,Envoyer le message,lornaafwandi@gmail.com,WAVU,Don Okoth | Vincent Oduor | Chris Munialo | Loise Mudegu,"Kisumu, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-07-30,Sustainable fishing and aquaculture & blue food,"WAVU is a market and aggregation platform that connects verified aquaculture producers to buyers through organised, predictable supply chains.
|
,Lorna Mudegu,+254718059337,Envoyer le message,lornaafwandi@gmail.com,WAVU,Don Okoth | Vincent Oduor | Chris Munialo | Loise Mudegu,"Kisumu, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-07-30,Sustainable fishing and aquaculture & blue food,"WAVU is a market and aggregation platform that connects verified aquaculture producers to buyers through organised, predictable supply chains.
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ Objectives: Reduce waste; add value for fishers; create local jobs; supply resta
|
|||||||
|
|
||||||
Key details: Source = local landings; partners = fishers + certified processor + food-safety lab; compliance = HACCP/food regs; go-to-market = horeca, gourmet stores, e-commerce; pilot → scale path.",false,Through Fondation Prince Albert II de Monaco.,Received,https://drive.google.com/drive/folders/1Pbf4FwTfAfqklel_a94CYA7dZsmvPfGH?usp=drive_link,
|
Key details: Source = local landings; partners = fishers + certified processor + food-safety lab; compliance = HACCP/food regs; go-to-market = horeca, gourmet stores, e-commerce; pilot → scale path.",false,Through Fondation Prince Albert II de Monaco.,Received,https://drive.google.com/drive/folders/1Pbf4FwTfAfqklel_a94CYA7dZsmvPfGH?usp=drive_link,
|
||||||
,Jonas Wüst,+41766307924,Envoyer le message,jonas@tethys-robotics.ch,Tethys Robotics,Pragash Sivananthaguru,"Zurich, Suisse","Europe, Switzerland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-08-15,Technology & innovations,"Tethys Robotics builds compact autonomous underwater robots that replace emission-intensive vessel operations with remote, low-impact subsea inspection. Our goal is to make offshore maintenance safer and more sustainable by reducing CO₂ emissions, preventing environmental damage through early detection, and improving the reliability of renewable marine infrastructure.",false,BRIDGE by Innosuisse forward us.,,,
|
,Jonas Wüst,+41766307924,Envoyer le message,jonas@tethys-robotics.ch,Tethys Robotics,Pragash Sivananthaguru,"Zurich, Suisse","Europe, Switzerland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-08-15,Technology & innovations,"Tethys Robotics builds compact autonomous underwater robots that replace emission-intensive vessel operations with remote, low-impact subsea inspection. Our goal is to make offshore maintenance safer and more sustainable by reducing CO₂ emissions, preventing environmental damage through early detection, and improving the reliability of renewable marine infrastructure.",false,BRIDGE by Innosuisse forward us.,,,
|
||||||
,James Kalo Malau,+6787774965,Envoyer le message,malau_jk@hotmail.com,Coral Reforestation,"John Maliu, Josue Jimmy, Nalo Samuel, Manu Roy, James Sulu","Port Vila, Shefa, Vanuatu",Oceania,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-01,Sustainable fishing and aquaculture & blue food,,true,Funds for NGOs Premium,,,
|
,James Kalo Malau,+6787774965,Envoyer le message,malau_jk@hotmail.com,Coral Reforestation,"John Maliu, Josue Jimmy, Nalo Samuel, Manu Roy, James Sulu","Port Vila, Shefa, Vanuatu",Oceania,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-01,Sustainable fishing and aquaculture & blue food,,true,Funds for NGOs Premium,Ignore,,
|
||||||
,Amelia Martin,+18606824426,Envoyer le message,amelia@mudratsurf.com,Mud Rat,"Jack Tarka, Patricio Acevedo, Brian Lassy","Mansfield, CT, États-Unis",US,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-06-13,Reduction of pollution (plastics chemicals noise light...),We manufacture an eco-friendly alternative to marine foam (marine grade styrofoam).,true,Google!,Ignore,,
|
,Amelia Martin,+18606824426,Envoyer le message,amelia@mudratsurf.com,Mud Rat,"Jack Tarka, Patricio Acevedo, Brian Lassy","Mansfield, CT, États-Unis",US,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-06-13,Reduction of pollution (plastics chemicals noise light...),We manufacture an eco-friendly alternative to marine foam (marine grade styrofoam).,true,Google!,Ignore,,
|
||||||
,Rasmus Borgstrøm,+4527117113,Envoyer le message,rasmus@blueplanetinnovators.com,FlowMinerals,"Rasmus Borgstrøm, Esben Jessen","Copenhague, Région de la Capitale, Danemark","Denmark, Europe",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-09-24,Mitigation of ocean acidification,"FlowMinerals captures CO₂ from seawater and converts it into fossil-free calcium carbonate, contributing to the mitigation of ocean acidification while reducing reliance on land-based limestone mining. The solution enables industrial decarbonization using ocean-compatible materials, with a strong focus on environmental safety and minimal marine impact.
|
,Rasmus Borgstrøm,+4527117113,Envoyer le message,rasmus@blueplanetinnovators.com,FlowMinerals,"Rasmus Borgstrøm, Esben Jessen","Copenhague, Région de la Capitale, Danemark","Denmark, Europe",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-09-24,Mitigation of ocean acidification,"FlowMinerals captures CO₂ from seawater and converts it into fossil-free calcium carbonate, contributing to the mitigation of ocean acidification while reducing reliance on land-based limestone mining. The solution enables industrial decarbonization using ocean-compatible materials, with a strong focus on environmental safety and minimal marine impact.
|
||||||
www.FlowMinerals.com",true,LinkedIn,,,
|
www.FlowMinerals.com",true,LinkedIn,,,
|
||||||
@@ -160,13 +160,13 @@ The database is designed for policymakers and academic institutions, offering pr
|
|||||||
Key features include the development of a Fisheries Atlas and a Blue Carbon Biodiversity initiative focused on Africa’s landing beaches, providing strategic recommendations for the establishment of Marine Protected Areas (MPAs). The project also promotes data sharing among local indigenous fishermen and enhances understanding aligned with the UN Ocean Decade objectives. It will create a comprehensive data repository covering various marine species, including fish, mangroves, algae, and seaweeds.
|
Key features include the development of a Fisheries Atlas and a Blue Carbon Biodiversity initiative focused on Africa’s landing beaches, providing strategic recommendations for the establishment of Marine Protected Areas (MPAs). The project also promotes data sharing among local indigenous fishermen and enhances understanding aligned with the UN Ocean Decade objectives. It will create a comprehensive data repository covering various marine species, including fish, mangroves, algae, and seaweeds.
|
||||||
|
|
||||||
Links:
|
Links:
|
||||||
https://oceandecade.org/action",true,MOPC Linkedin.,,,
|
https://oceandecade.org/action",true,MOPC Linkedin.,Received,https://drive.google.com/drive/folders/1nLGw1as7LT440vYA-_Bs__lyPD-Uo3rr?usp=drive_link,
|
||||||
,Carol Nkawaga Moonga,+260979164462,Envoyer le message,moongacaroln@gmail.com,Kacachi General Dealers,Cathrine Kapesha,Zambie,"Africa, Zambia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-07-11,Sustainable fishing and aquaculture & blue food,,true,I saw an advertisement on LinkedIn,Received,https://drive.google.com/drive/folders/1wEWiGREhq-dWPkFqGqmSK89PcuOjhsXX?usp=drive_link,
|
,Carol Nkawaga Moonga,+260979164462,Envoyer le message,moongacaroln@gmail.com,Kacachi General Dealers,Cathrine Kapesha,Zambie,"Africa, Zambia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-07-11,Sustainable fishing and aquaculture & blue food,,true,I saw an advertisement on LinkedIn,Received,https://drive.google.com/drive/folders/1wEWiGREhq-dWPkFqGqmSK89PcuOjhsXX?usp=drive_link,
|
||||||
,Coral Bisson,+377643915342,Envoyer le message,coralbisson@icloud.com,Corali,Coral Bisson,"St Helier, Jersey","Europe, Jersey",International University of Monaco,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),- Reduction of ocean plastics through development of swimwear using recycled ocean plastics,true,University,,,
|
,Coral Bisson,+377643915342,Envoyer le message,coralbisson@icloud.com,Corali,Coral Bisson,"St Helier, Jersey","Europe, Jersey",International University of Monaco,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),- Reduction of ocean plastics through development of swimwear using recycled ocean plastics,true,University,,,
|
||||||
,Pavel Kartashov,+38975588771,Envoyer le message,pavel.k@wavespark.co,WaveSpark Green Marine Energies,"Pavel Kartashov, Rodrigo Caba, Francisco Perez, Glib Ivanov","Skopje, Macédoine du Nord","Europe, Macedonia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-03-05,Technology & innovations,"Scalable and capital-light hybrid ocean energy platforms harvesting wave, sun and wind energy in near-shore areas for shore and offshore energy end-users",false,Social media post,Received,https://drive.google.com/drive/folders/1vdcWHlPUURdN69T-Ek7wsqOTrLNaODq0?usp=drive_link,
|
,Pavel Kartashov,+38975588771,Envoyer le message,pavel.k@wavespark.co,WaveSpark Green Marine Energies,"Pavel Kartashov, Rodrigo Caba, Francisco Perez, Glib Ivanov","Skopje, Macédoine du Nord","Europe, Macedonia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-03-05,Technology & innovations,"Scalable and capital-light hybrid ocean energy platforms harvesting wave, sun and wind energy in near-shore areas for shore and offshore energy end-users",false,Social media post,Received,https://drive.google.com/drive/folders/1vdcWHlPUURdN69T-Ek7wsqOTrLNaODq0?usp=drive_link,
|
||||||
,Raphaëlle Guénard,+33663688277,Envoyer le message,contact@filae.eu,Filae,Raphaëlle Guénard & Killian Bossé,"Marseille, Provence-Alpes-Côte d'Azur, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-03-21,Reduction of pollution (plastics chemicals noise light...),"Filae transforms end-of-life fishing nets into ultra-light, modular supports for plant-based shading and greening (façades and canopies), helping cool down dense urban areas without heavy structures.
|
,Raphaëlle Guénard,+33663688277,Envoyer le message,contact@filae.eu,Filae,Raphaëlle Guénard & Killian Bossé,"Marseille, Provence-Alpes-Côte d'Azur, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-03-21,Reduction of pollution (plastics chemicals noise light...),"Filae transforms end-of-life fishing nets into ultra-light, modular supports for plant-based shading and greening (façades and canopies), helping cool down dense urban areas without heavy structures.
|
||||||
Our goal is to scale a Mediterranean circular model, from local net collection to on-site deployment, reducing waste and embodied carbon while boosting thermal comfort and biodiversity through real-world pilots.",true,"from Marine Jacq-Pietri, Coordinatrice du Monaco Ocean Protection Challenge",Received,https://drive.google.com/drive/folders/1QriiO8XKqx97efs-dAPqHDMhcSy9HjHH?usp=drive_link,
|
Our goal is to scale a Mediterranean circular model, from local net collection to on-site deployment, reducing waste and embodied carbon while boosting thermal comfort and biodiversity through real-world pilots.",true,"from Marine Jacq-Pietri, Coordinatrice du Monaco Ocean Protection Challenge",Received,https://drive.google.com/drive/folders/1QriiO8XKqx97efs-dAPqHDMhcSy9HjHH?usp=drive_link,
|
||||||
,Anastasiia,+380680650309,Envoyer le message,grozdova.anastasiia@gmail.com,Innovations in ocean environment,Darina Mitina,Ukraine,"Europe, Ukraine",,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,Take technology onto another level,true,Social media marketing,,,
|
,Anastasiia,+380680650309,Envoyer le message,grozdova.anastasiia@gmail.com,Innovations in ocean environment,Darina Mitina,Ukraine,"Europe, Ukraine",,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,Take technology onto another level,true,Social media marketing,Ignore,,
|
||||||
,Raismin Kotta,+6281342018565,Envoyer le message,raisminkotta88@gmail.com,The Pearls cultuvation & Pearls jewelry,"Raismin Kotta, aya sophia, Lalu harianza,asril junaidy","Lombok, Indonesie",Asia,"45 University, Mataram Indonesia",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,Sustainability fisheries and Aquaculture,true,I hear and read MOPC in website and interested to apply,,,
|
,Raismin Kotta,+6281342018565,Envoyer le message,raisminkotta88@gmail.com,The Pearls cultuvation & Pearls jewelry,"Raismin Kotta, aya sophia, Lalu harianza,asril junaidy","Lombok, Indonesie",Asia,"45 University, Mataram Indonesia",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,Sustainability fisheries and Aquaculture,true,I hear and read MOPC in website and interested to apply,,,
|
||||||
,Vincent Kneefel,+31622514465,Envoyer le message,vincent@vitalocean.io,Vital Ocean,Joi Danielson,"Amsterdam, Hollande Septentrionale, Pays-Bas","Europe, Netherland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-04-16,Technology & innovations,,true,Linkedin,,,
|
,Vincent Kneefel,+31622514465,Envoyer le message,vincent@vitalocean.io,Vital Ocean,Joi Danielson,"Amsterdam, Hollande Septentrionale, Pays-Bas","Europe, Netherland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-04-16,Technology & innovations,,true,Linkedin,,,
|
||||||
,Maaire Gyengne Francis,+233208397960,Envoyer le message,gyengnefrancis90@gmail.com,WasteTrack,"Frank Faarkuu, Prosper Dorfiah","Accra, Ghana","Africa, Ghana",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-01-01,Reduction of pollution (plastics chemicals noise light...),"Problem and Solution
|
,Maaire Gyengne Francis,+233208397960,Envoyer le message,gyengnefrancis90@gmail.com,WasteTrack,"Frank Faarkuu, Prosper Dorfiah","Accra, Ghana","Africa, Ghana",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-01-01,Reduction of pollution (plastics chemicals noise light...),"Problem and Solution
|
||||||
@@ -176,7 +176,7 @@ Urban cities across Africa face a severe plastic waste crisis driven by rapid po
|
|||||||
Our solution is to develop an AI-powered platform that helps urban households dispose of plastic waste by connecting them with local collectors through image, video, or weight-based pricing and cashless payments. It tackles severe plastic pollution in African cities caused by limited collection capacity and unsafe disposal practices. With millions of households generating increasing waste, the market potential is vast across Ghana and other rapidly urbanizing regions. Once consistent collection volumes are reached, WasteTrack will expand into a global plastic trading marketplace, enabling recyclers worldwide to buy verified, traceable plastic waste - positioning the startup as a major player in the circular plastics economy.
|
Our solution is to develop an AI-powered platform that helps urban households dispose of plastic waste by connecting them with local collectors through image, video, or weight-based pricing and cashless payments. It tackles severe plastic pollution in African cities caused by limited collection capacity and unsafe disposal practices. With millions of households generating increasing waste, the market potential is vast across Ghana and other rapidly urbanizing regions. Once consistent collection volumes are reached, WasteTrack will expand into a global plastic trading marketplace, enabling recyclers worldwide to buy verified, traceable plastic waste - positioning the startup as a major player in the circular plastics economy.
|
||||||
|
|
||||||
Our AI-driven waste management and digital payment solution is designed to make plastic disposal easy, convenient, and traceable for urban households. Key features will include photo, video, or weight-based AI analysis to estimate disposal fees; secure digital payments; GPS-linked pickup requests; and unique tracking codes for every waste package. The platform also supports community micro-dumpsites for flexible drop-off and pr",true,Google search,Received,https://drive.google.com/drive/folders/1Rv9W6h5zQESX7A68bQio5JWy5TML86rH?usp=drive_link,
|
Our AI-driven waste management and digital payment solution is designed to make plastic disposal easy, convenient, and traceable for urban households. Key features will include photo, video, or weight-based AI analysis to estimate disposal fees; secure digital payments; GPS-linked pickup requests; and unique tracking codes for every waste package. The platform also supports community micro-dumpsites for flexible drop-off and pr",true,Google search,Received,https://drive.google.com/drive/folders/1Rv9W6h5zQESX7A68bQio5JWy5TML86rH?usp=drive_link,
|
||||||
,Shelby Thomas,+13866897675,Envoyer le message,admin@oceanrescuealliance.org,Coastal Resilience Solutions: WeRestore,"Dr. Shelby Thomas, Dr. David Weinstein, Lindsay Humbles,","FL, États-Unis",US,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2019-12-01,Restoration of marine habitats & ecosystems,"Ocean Rescue Alliance International, through its Coastal Resilience Solutions for-profit arm and the We Restore initiative, deploys scalable living shoreline and hybrid reef technologies to restore degraded coastal and marine ecosystems while enhancing climate resilience for vulnerable communities. The project’s objective is to deliver measurable ocean biodiversity recovery, erosion reduction, and carbon co-benefits through science-based, nature-positive infrastructure that can be replicated regionally and globally.",true,via Email Newsletter,,,
|
,Shelby Thomas,+13866897675,Envoyer le message,admin@oceanrescuealliance.org,Coastal Resilience Solutions: WeRestore,"Dr. Shelby Thomas, Dr. David Weinstein, Lindsay Humbles,","FL, États-Unis",US,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2019-12-01,Restoration of marine habitats & ecosystems,"Ocean Rescue Alliance International, through its Coastal Resilience Solutions for-profit arm and the We Restore initiative, deploys scalable living shoreline and hybrid reef technologies to restore degraded coastal and marine ecosystems while enhancing climate resilience for vulnerable communities. The project’s objective is to deliver measurable ocean biodiversity recovery, erosion reduction, and carbon co-benefits through science-based, nature-positive infrastructure that can be replicated regionally and globally.",true,via Email Newsletter,Received,https://drive.google.com/drive/folders/1wO7CWNMl75fa9A-sm1ei9fEFGWqWv-vM?usp=drive_link,
|
||||||
,Danail Marinov,+359895497694,Envoyer le message,dmarinov@redget.io,RedGet.io,"Danail Marinov, Dobromir Balabanov, Alexander Valchev","Sofia, Bulgarie","Bulgaria, Europe",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-12-01,Technology & innovations,"What it is (TRL 4–5): Pilot-ready, AI collaborative platform for GHG emissions Scope 1–3 monitoring, compliance reporting and forecasting for ports/terminals/shipping companies. RedGet.io was among the selected companies and participated in ADT4Blue, EY Startup Academy Germany, Blue Readiness Assistance and Green Marine Med (by Port of Barcelona) programs.
|
,Danail Marinov,+359895497694,Envoyer le message,dmarinov@redget.io,RedGet.io,"Danail Marinov, Dobromir Balabanov, Alexander Valchev","Sofia, Bulgarie","Bulgaria, Europe",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-12-01,Technology & innovations,"What it is (TRL 4–5): Pilot-ready, AI collaborative platform for GHG emissions Scope 1–3 monitoring, compliance reporting and forecasting for ports/terminals/shipping companies. RedGet.io was among the selected companies and participated in ADT4Blue, EY Startup Academy Germany, Blue Readiness Assistance and Green Marine Med (by Port of Barcelona) programs.
|
||||||
Value: Up to 60% reduction in reporting efforts and costs, emission forecasting for EU-ETS regulations, AI maritime assistant and decision-ready visibility to plan and verify decarbonization.
|
Value: Up to 60% reduction in reporting efforts and costs, emission forecasting for EU-ETS regulations, AI maritime assistant and decision-ready visibility to plan and verify decarbonization.
|
||||||
Status & partners: Confirmed pilot with Port of Gdynia (Jan 2026) and Port of Talling (Jan 2026); negotiations with Port of Valencia, Port of Huelva, and EY Bulgaria.",false,A friend of mine shared this opportunity to me,Received,https://drive.google.com/drive/folders/1xElP9xEg6r2R0lDElZD9n6v4FOW9CCEp?usp=drive_link,
|
Status & partners: Confirmed pilot with Port of Gdynia (Jan 2026) and Port of Talling (Jan 2026); negotiations with Port of Valencia, Port of Huelva, and EY Bulgaria.",false,A friend of mine shared this opportunity to me,Received,https://drive.google.com/drive/folders/1xElP9xEg6r2R0lDElZD9n6v4FOW9CCEp?usp=drive_link,
|
||||||
@@ -215,7 +215,7 @@ Objectives:
|
|||||||
|
|
||||||
2. Guide boaters away from seagrass using a digital map and reservation system that clearly marks no-anchor zones and available eco-moorings.
|
2. Guide boaters away from seagrass using a digital map and reservation system that clearly marks no-anchor zones and available eco-moorings.
|
||||||
|
|
||||||
3. Measure and monetise impact by estimating hectares of seagrass protected and associated blue-carbon storage and ecosystem-service value, creating reporting for marinas, municipalities and impact investors.",true,Through my University.,,,
|
3. Measure and monetise impact by estimating hectares of seagrass protected and associated blue-carbon storage and ecosystem-service value, creating reporting for marinas, municipalities and impact investors.",true,Through my University.,Received,https://drive.google.com/drive/folders/1mDEkYkvinYZ4eJzMqYSCMcDxpbCr8Rs-?usp=drive_link,
|
||||||
,ssentubiro billy,+256708630034,Envoyer le message,lemanfoundation16@gmail.com,schoolarships,Nakayulu Grace and ssentubiro billy,"Kampala, Ouganda","Africa, Ouganda",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2016-08-13,Capacity building for coastal communities,allow needy children access quality education,true,via social media,,,
|
,ssentubiro billy,+256708630034,Envoyer le message,lemanfoundation16@gmail.com,schoolarships,Nakayulu Grace and ssentubiro billy,"Kampala, Ouganda","Africa, Ouganda",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2016-08-13,Capacity building for coastal communities,allow needy children access quality education,true,via social media,,,
|
||||||
,Hasan Noor Ahmed,+491737752964,Envoyer le message,biland.awdal.org@gmail.com,BlueGuard Africa – Community-Driven Ocean & Coastal Protection Innovation Hub,"Hasan Noor Ahmed – Chairman & Founder Amina Abdillahi Ibrahim – Program Director (Health & Nutrition) Mohamed Abdi Warsame – Finance & Administration Officer Hodan Ismail Ali – Climate & Environment Program Lead Abdirahman Yusuf Farah – Monitoring, Evaluation & Learning Officer Fardowsa Ahmed Jama – Community Outreach & Protection Coordinator","Boorama, Awdal, Somalie","Africa, Somalia",Bilan Awdal Organization – Training & Capacity Development Unit,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"The Blue Coast Guardians Initiative is a youth-led, community-centered program designed by Bilan Awdal Organization to combat coastal pollution, restore marine ecosystems, and create sustainable blue-economy opportunities along the Somaliland/Somalia coastline.
|
,Hasan Noor Ahmed,+491737752964,Envoyer le message,biland.awdal.org@gmail.com,BlueGuard Africa – Community-Driven Ocean & Coastal Protection Innovation Hub,"Hasan Noor Ahmed – Chairman & Founder Amina Abdillahi Ibrahim – Program Director (Health & Nutrition) Mohamed Abdi Warsame – Finance & Administration Officer Hodan Ismail Ali – Climate & Environment Program Lead Abdirahman Yusuf Farah – Monitoring, Evaluation & Learning Officer Fardowsa Ahmed Jama – Community Outreach & Protection Coordinator","Boorama, Awdal, Somalie","Africa, Somalia",Bilan Awdal Organization – Training & Capacity Development Unit,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"The Blue Coast Guardians Initiative is a youth-led, community-centered program designed by Bilan Awdal Organization to combat coastal pollution, restore marine ecosystems, and create sustainable blue-economy opportunities along the Somaliland/Somalia coastline.
|
||||||
Our approach combines innovative low-cost technologies, community livelihoods, and education, enabling coastal communities to protect the ocean while improving their economic resilience.
|
Our approach combines innovative low-cost technologies, community livelihoods, and education, enabling coastal communities to protect the ocean while improving their economic resilience.
|
||||||
@@ -341,7 +341,7 @@ Objectives:
|
|||||||
• Increase resilient incomes by training and supporting community micro-enterprises (waste-to-value, eco-services, sustainable seafood handling) and link them to off takers.
|
• Increase resilient incomes by training and supporting community micro-enterprises (waste-to-value, eco-services, sustainable seafood handling) and link them to off takers.
|
||||||
• Restore natural coastal defenses through mangrove/coastal habitat restoration tied to local stewardship incentives and verified survival rates.
|
• Restore natural coastal defenses through mangrove/coastal habitat restoration tied to local stewardship incentives and verified survival rates.
|
||||||
• Create a repeatable “Hub-in-a-box” model that can scale across coastal regions quickly with clear KPIs and partner networks delivering positive, measurable ocean impact in the short to medium term, consistent with MOPC’s focus on ocean-positive business concepts",true,United Nations SDGs Newsletter.,Received,https://drive.google.com/drive/folders/1ph6DBmqeSGvSSqxQkymPx9rlU-ZmGFnr?usp=drive_link,
|
• Create a repeatable “Hub-in-a-box” model that can scale across coastal regions quickly with clear KPIs and partner networks delivering positive, measurable ocean impact in the short to medium term, consistent with MOPC’s focus on ocean-positive business concepts",true,United Nations SDGs Newsletter.,Received,https://drive.google.com/drive/folders/1ph6DBmqeSGvSSqxQkymPx9rlU-ZmGFnr?usp=drive_link,
|
||||||
,Veronica Nzuu,+254748488312,Envoyer le message,veramichael2000@gmail.com,Furies,Angelo Mulu,"Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-05-23,Consumer awareness and education,"My project focuses on community-based climate and ocean education for children and youth, using storytelling, play, and interactive learning to build awareness around plastic pollution, waste segregation, and environmental responsibility. The objective is to transform how young people and families understand and relate to plastic consumption moving from awareness to everyday action. Through games, facilitated sessions, and community learning spaces, the project empowers children to become informed advocates within their households and neighborhoods, strengthening long-term behavior change and community ownership of sustainability solutions.",true,Social media linked in,,,
|
,Veronica Nzuu,+254748488312,Envoyer le message,veramichael2000@gmail.com,Furies,Angelo Mulu,"Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-05-23,Consumer awareness and education,"My project focuses on community-based climate and ocean education for children and youth, using storytelling, play, and interactive learning to build awareness around plastic pollution, waste segregation, and environmental responsibility. The objective is to transform how young people and families understand and relate to plastic consumption moving from awareness to everyday action. Through games, facilitated sessions, and community learning spaces, the project empowers children to become informed advocates within their households and neighborhoods, strengthening long-term behavior change and community ownership of sustainability solutions.",true,Social media linked in,Doublon,,
|
||||||
,Cristiano da Silva Palma,+5511978020540,Envoyer le message,cristianospalma@yahoo.com.br,Tabernacle Space Islands,Cristiano da Silva Palma,"São José dos Campos, SP, Brésil",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-08-09,Technology & innovations,"Project Idea & Scientific Context
|
,Cristiano da Silva Palma,+5511978020540,Envoyer le message,cristianospalma@yahoo.com.br,Tabernacle Space Islands,Cristiano da Silva Palma,"São José dos Campos, SP, Brésil",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-08-09,Technology & innovations,"Project Idea & Scientific Context
|
||||||
|
|
||||||
The project develops a next-generation modular OTEC (Ocean Thermal Energy Conversion) system, combining innovative deep-ocean structures, ultra-optimized thermodynamic cycles, and AI-based monitoring to deliver continuous (24/7) clean energy, with initial pilot operation targeted from 2027. The system is designed for scalable deployment in tropical and island regions, validated through a pilot-scale OTEC unit operating with deep-water intake (~1000 m or more) and real-time intelligent control.
|
The project develops a next-generation modular OTEC (Ocean Thermal Energy Conversion) system, combining innovative deep-ocean structures, ultra-optimized thermodynamic cycles, and AI-based monitoring to deliver continuous (24/7) clean energy, with initial pilot operation targeted from 2027. The system is designed for scalable deployment in tropical and island regions, validated through a pilot-scale OTEC unit operating with deep-water intake (~1000 m or more) and real-time intelligent control.
|
||||||
@@ -365,7 +365,7 @@ Tech stack: Next.js frontend, Supabase backend (PostgreSQL + Auth + Storage), Op
|
|||||||
|
|
||||||
Timeline: 90 days to launch following MVP development, beta testing with 100 users, then public launch.
|
Timeline: 90 days to launch following MVP development, beta testing with 100 users, then public launch.
|
||||||
|
|
||||||
Initial budget required: 15-30K EUR covering development, educational content creation, and marketing expenses.",true,Linkedin,,,
|
Initial budget required: 15-30K EUR covering development, educational content creation, and marketing expenses.",true,Linkedin,Received,https://drive.google.com/drive/folders/1P0Js2H_6QStp7z-GaXLw6ODuTz0XOhqY?usp=drive_link,
|
||||||
,Christopher Enriquez Urban,+4915679760251,Envoyer le message,christopher@algrid.tech,Algrid,Valentina Iunosheva,"Francfort-sur-le-Main, Hesse, Allemagne","Europe, Germany","University of Leeds, Leeds, UK",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Other,"Project: AI-powered offshore infrastructure for Sargassum monitoring, harvesting, and conversion into industrial biomass.
|
,Christopher Enriquez Urban,+4915679760251,Envoyer le message,christopher@algrid.tech,Algrid,Valentina Iunosheva,"Francfort-sur-le-Main, Hesse, Allemagne","Europe, Germany","University of Leeds, Leeds, UK",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Other,"Project: AI-powered offshore infrastructure for Sargassum monitoring, harvesting, and conversion into industrial biomass.
|
||||||
|
|
||||||
Problem: Massive Sargassum blooms devastate Caribbean coasts but remain unused due to unpredictable availability and high logistics costs.
|
Problem: Massive Sargassum blooms devastate Caribbean coasts but remain unused due to unpredictable availability and high logistics costs.
|
||||||
@@ -380,7 +380,7 @@ Our advanced material development and manufacturing ensure durability, aesthetic
|
|||||||
,Francesca Rose Turner Prichard,+34671298357,Envoyer le message,francescaroseturner@gmail.com,Residensea,"Francesca Turner, Aoife Martin, Alberto Rangel","Palma, Iles Baléares, Espagne","Europe, Spain",Southampton Solent University,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"Restore society’s relationship with the ocean by building connections between women and the ocean through sport, art and conservation activities.",true,Linkedin,,,
|
,Francesca Rose Turner Prichard,+34671298357,Envoyer le message,francescaroseturner@gmail.com,Residensea,"Francesca Turner, Aoife Martin, Alberto Rangel","Palma, Iles Baléares, Espagne","Europe, Spain",Southampton Solent University,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"Restore society’s relationship with the ocean by building connections between women and the ocean through sport, art and conservation activities.",true,Linkedin,,,
|
||||||
,Brian Ochieng Aliech,+254757008417,Envoyer le message,ochiengaliech@gmail.com,NOLA AFRICA,"Brian Aliech, Charles Okutah, Hussein Hezekiah, Kevin Onsongo, Lidah Makena","Nairobi, Kenya","Africa, Kenya","University of Nairobi, Nairobi",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"Our project seeks to address the pervasive challenge of plastic pollution that has afflicted urban centers and aquatic ecosystems across the globe.
|
,Brian Ochieng Aliech,+254757008417,Envoyer le message,ochiengaliech@gmail.com,NOLA AFRICA,"Brian Aliech, Charles Okutah, Hussein Hezekiah, Kevin Onsongo, Lidah Makena","Nairobi, Kenya","Africa, Kenya","University of Nairobi, Nairobi",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"Our project seeks to address the pervasive challenge of plastic pollution that has afflicted urban centers and aquatic ecosystems across the globe.
|
||||||
By converting discarded plastic into durable construction materials, we aim to alleviate the strain on finite natural resources traditionally employed in the building industry, thereby reducing both costs and inefficiencies.
|
By converting discarded plastic into durable construction materials, we aim to alleviate the strain on finite natural resources traditionally employed in the building industry, thereby reducing both costs and inefficiencies.
|
||||||
In addition, this initiative seeks to generate meaningful employment opportunities for young people in underserved communities, empowering them to achieve economic stability and dignity. Ultimately, our vision is nothing less than to contribute to the preservation and renewal of our planet.",true,Through a friend.,,,
|
In addition, this initiative seeks to generate meaningful employment opportunities for young people in underserved communities, empowering them to achieve economic stability and dignity. Ultimately, our vision is nothing less than to contribute to the preservation and renewal of our planet.",true,Through a friend.,Received,https://drive.google.com/drive/folders/1IqXSPxuTPOJaQomXwDT9oKzkthgSpyOw?usp=drive_link,
|
||||||
,Adhithi Mugundha Kumar,+447512296331,Envoyer le message,Adhithimukhundh@gmail.com,Blue crabs,Xenia Anagnostou,"Plymouth, Angleterre, Royaume-Uni",UK,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-06,Sustainable fishing and aquaculture & blue food,We aim to provide a solution to invasive blue crabs in the Mediterranean by developing a bait-induced fishing method.,true,,Received,https://drive.google.com/drive/folders/1wAQ7xulHAa-nRAOzZrCgCWO96CW3IWuy?usp=drive_link,
|
,Adhithi Mugundha Kumar,+447512296331,Envoyer le message,Adhithimukhundh@gmail.com,Blue crabs,Xenia Anagnostou,"Plymouth, Angleterre, Royaume-Uni",UK,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-06,Sustainable fishing and aquaculture & blue food,We aim to provide a solution to invasive blue crabs in the Mediterranean by developing a bait-induced fishing method.,true,,Received,https://drive.google.com/drive/folders/1wAQ7xulHAa-nRAOzZrCgCWO96CW3IWuy?usp=drive_link,
|
||||||
,THIERRY BOUSSION,+33621220023,Envoyer le message,t.boussion@yuniboat.com,Yuniboat,Thierry Boussion,"Le Pouliguen, Pays de la Loire, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-06-01,Reduction of pollution (plastics chemicals noise light...),"Yuniboat develops an industrial model dedicated to the eco-reconditioning of leisure and professional boats, designed to significantly reduce the environmental impact of boating activities.
|
,THIERRY BOUSSION,+33621220023,Envoyer le message,t.boussion@yuniboat.com,Yuniboat,Thierry Boussion,"Le Pouliguen, Pays de la Loire, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-06-01,Reduction of pollution (plastics chemicals noise light...),"Yuniboat develops an industrial model dedicated to the eco-reconditioning of leisure and professional boats, designed to significantly reduce the environmental impact of boating activities.
|
||||||
|
|
||||||
@@ -397,9 +397,9 @@ Make boating more compatible with ocean preservation.
|
|||||||
Support professionals (fishing, rental fleets) in meeting decarbonation goals by 2030.
|
Support professionals (fishing, rental fleets) in meeting decarbonation goals by 2030.
|
||||||
Offer a sustainable, economically viable alternative to new boat construction.
|
Offer a sustainable, economically viable alternative to new boat construction.
|
||||||
Deploy a scalable industrial model capable of transforming the nautical and maritime sectors.
|
Deploy a scalable industrial model capable of transforming the nautical and maritime sectors.
|
||||||
Yuniboat’s ambition is to position eco-reconditioning as a key lever for ocean protection, combining circular economy, innovation, and long-term impact on marine biodiversity.",true,we follow your activities on Linkedin and Instagram,,,
|
Yuniboat’s ambition is to position eco-reconditioning as a key lever for ocean protection, combining circular economy, innovation, and long-term impact on marine biodiversity.",true,we follow your activities on Linkedin and Instagram,Received,https://drive.google.com/drive/folders/1CxO_JDBexB0DVCo8SZPek_ggZXtn279f?usp=drive_link,
|
||||||
,Daniele Tassara,+393466376215,Envoyer le message,daniele.tassara@outlook.com,MareNetto,"Giambattista Figari, Giorgio Mussini","Santa Margherita Ligure, Ligurie, Italie","Europe, Italia","Universita di Genova, Genova",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,"MareNetto is a yacht-focused climate platform that automatically calculates and offsets superyacht CO2 emissions from AIS/MMSI data, then issues verifiable certificates that owners and charter managers use for marketing and ESG compliance",true,I lived in Monaco and i knew about this project,,,
|
,Daniele Tassara,+393466376215,Envoyer le message,daniele.tassara@outlook.com,MareNetto,"Giambattista Figari, Giorgio Mussini","Santa Margherita Ligure, Ligurie, Italie","Europe, Italia","Universita di Genova, Genova",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,"MareNetto is a yacht-focused climate platform that automatically calculates and offsets superyacht CO2 emissions from AIS/MMSI data, then issues verifiable certificates that owners and charter managers use for marketing and ESG compliance",true,I lived in Monaco and i knew about this project,Received,https://drive.google.com/drive/folders/1TKm7VEt5kt2JrSJjIePDXtDILQmLqY-t?usp=drive_link,
|
||||||
,Gaia Minopoli,+393393499607,Envoyer le message,gaia.minopoli@ogyre.com,Ogyre,Agnese Antoci Alessandro Serra Alice Casella Andrea Faldella Andrea Scatolero Antonio Augeri Chiara Maggiolini Davide Brugola Filippo Ferraris Gaia Minopoli Gian Piero Seregni Lorenzo Gastaldo Matteo Quaglio Mattia De Serio Michele Migliau Alessandro Sciarpelletti Francesco Carletto francesco notari Irene Eustazio Jurgen Ametaj Lorenzo Varas Marta Berardini Lucrezia Napoletano Gabriele Cusimano Enrica Sandigliano,"Gênes, Ligurie, Italie","Europe, Italia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2020-01-21,Reduction of pollution (plastics chemicals noise light...),"Ogyre is a startup tackling marine plastic pollution through a Fishing for Litter model, working directly with fishing communities worldwide. Its mission is to clean the Ocean while turning plastic waste into a resource. By financially supporting fishers to recover marine litter during their daily activities, and by involving local partners for sorting and recycling, Ogyre delivers measurable environmental and social impact. The entire process is fully traceable through a blockchain-enabled platform, allowing companies to monitor progress and impact in real time. Active across Europe, South America, Africa, and Asia, Ogyre has already recovered over 800 tons of marine waste and proven a financially sustainable model—now scaling its impact globally to reach 30M kg of cumulated collection by 2030!",true,Scientific attaché of Italian Embassy in Paris,,,
|
,Gaia Minopoli,+393393499607,Envoyer le message,gaia.minopoli@ogyre.com,Ogyre,Agnese Antoci Alessandro Serra Alice Casella Andrea Faldella Andrea Scatolero Antonio Augeri Chiara Maggiolini Davide Brugola Filippo Ferraris Gaia Minopoli Gian Piero Seregni Lorenzo Gastaldo Matteo Quaglio Mattia De Serio Michele Migliau Alessandro Sciarpelletti Francesco Carletto francesco notari Irene Eustazio Jurgen Ametaj Lorenzo Varas Marta Berardini Lucrezia Napoletano Gabriele Cusimano Enrica Sandigliano,"Gênes, Ligurie, Italie","Europe, Italia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2020-01-21,Reduction of pollution (plastics chemicals noise light...),"Ogyre is a startup tackling marine plastic pollution through a Fishing for Litter model, working directly with fishing communities worldwide. Its mission is to clean the Ocean while turning plastic waste into a resource. By financially supporting fishers to recover marine litter during their daily activities, and by involving local partners for sorting and recycling, Ogyre delivers measurable environmental and social impact. The entire process is fully traceable through a blockchain-enabled platform, allowing companies to monitor progress and impact in real time. Active across Europe, South America, Africa, and Asia, Ogyre has already recovered over 800 tons of marine waste and proven a financially sustainable model—now scaling its impact globally to reach 30M kg of cumulated collection by 2030!",true,Scientific attaché of Italian Embassy in Paris,Received,https://drive.google.com/drive/folders/1oRYYsGxF-urjoO74GFaMMQ9sg8ZCVdw9?usp=drive_link,
|
||||||
,Yajaira Cristina Alquinga Salazar,+541136132787,Envoyer le message,cristinalquinga@gmail.com,Dynamics of Coastal Dune Fields in the Southwest of Buenos Aires Province,"Bsc. Yajaira Cristina Alquinga Salazar, Dr. Gerardo M. E. Perillo and Dr Sibila A. Genchi","Bahía Blanca, Buenos Aires, Argentine",South America,Universidad Nacional del Sur,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Mitigation of climate change and sea-level rise,"The general objective of this research plan is to study the dynamics of coastal dunes in the southwest of Buenos Aires Province, with special emphasis on the foredune, and its relationship with climatic, oceanographic, and anthropogenic factors. In particular, the study aims to determine the degree of influence of each of these factors, especially in areas where urban settlements have been established over the last 80 years, in comparison with adjacent sectors subjected to similar environmental conditions but without anthropogenic influence.",true,LinkedIn,Received,https://drive.google.com/drive/folders/1y8Z1QYa6a7MeN1qs7ILCELvWHY1mRfr2?usp=drive_link,
|
,Yajaira Cristina Alquinga Salazar,+541136132787,Envoyer le message,cristinalquinga@gmail.com,Dynamics of Coastal Dune Fields in the Southwest of Buenos Aires Province,"Bsc. Yajaira Cristina Alquinga Salazar, Dr. Gerardo M. E. Perillo and Dr Sibila A. Genchi","Bahía Blanca, Buenos Aires, Argentine",South America,Universidad Nacional del Sur,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Mitigation of climate change and sea-level rise,"The general objective of this research plan is to study the dynamics of coastal dunes in the southwest of Buenos Aires Province, with special emphasis on the foredune, and its relationship with climatic, oceanographic, and anthropogenic factors. In particular, the study aims to determine the degree of influence of each of these factors, especially in areas where urban settlements have been established over the last 80 years, in comparison with adjacent sectors subjected to similar environmental conditions but without anthropogenic influence.",true,LinkedIn,Received,https://drive.google.com/drive/folders/1y8Z1QYa6a7MeN1qs7ILCELvWHY1mRfr2?usp=drive_link,
|
||||||
,Jovana,+381645655226,Envoyer le message,jovanaperisic059@gmail.com,EcoMath,Jovana Perišić,Kraljevo,"Europe, Serbia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-07,Technology & innovations,"Project name: Symphony of the Blue
|
,Jovana,+381645655226,Envoyer le message,jovanaperisic059@gmail.com,EcoMath,Jovana Perišić,Kraljevo,"Europe, Serbia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-07,Technology & innovations,"Project name: Symphony of the Blue
|
||||||
|
|
||||||
@@ -441,12 +441,12 @@ Objectives:
|
|||||||
- Scalable nature-inclusive designs and eco-structure integration for offshore oil & gas and offshore wind infrastructure to enhance ecological performance and biodiversity protection.
|
- Scalable nature-inclusive designs and eco-structure integration for offshore oil & gas and offshore wind infrastructure to enhance ecological performance and biodiversity protection.
|
||||||
|
|
||||||
Other relevant details:
|
Other relevant details:
|
||||||
We are currently testing prototypes in Australia and developing an autonomous monitoring system, with early results showing very positive outcomes and remarkable improvements in biodiversity.",true,Linkedin,,,
|
We are currently testing prototypes in Australia and developing an autonomous monitoring system, with early results showing very positive outcomes and remarkable improvements in biodiversity.",true,Linkedin,Received,https://drive.google.com/drive/folders/1lktP9O-QU69NHylfTXEhJzbrRxHZ04JE?usp=drive_link,
|
||||||
,Lee patrick EKOUAGUET,+33778199372,Envoyer le message,ogoouecorpstechnologies@gmail.com,OGOOUE CORPS TECHNOLOGIES,"ANDRE BIAYOUMOU, NGABOU PASCAL XAVIER, LYNDA NGARBAHAM, DUPUIS NOUILE NICOLAS","Bordeaux, Nouvelle-Aquitaine, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-10-23,Technology & innovations,"OCEAN-PATCH is an intelligent, autonomous maritime safety patch designed to protect human lives at sea.
|
,Lee patrick EKOUAGUET,+33778199372,Envoyer le message,ogoouecorpstechnologies@gmail.com,OGOOUE CORPS TECHNOLOGIES,"ANDRE BIAYOUMOU, NGABOU PASCAL XAVIER, LYNDA NGARBAHAM, DUPUIS NOUILE NICOLAS","Bordeaux, Nouvelle-Aquitaine, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-10-23,Technology & innovations,"OCEAN-PATCH is an intelligent, autonomous maritime safety patch designed to protect human lives at sea.
|
||||||
It detects critical situations (man overboard, distress, abnormal conditions) in real time and transmits alerts and data without batteries, using body or environmental energy.
|
It detects critical situations (man overboard, distress, abnormal conditions) in real time and transmits alerts and data without batteries, using body or environmental energy.
|
||||||
|
|
||||||
The project aims to improve maritime safety while generating valuable ocean data to support prevention, monitoring, and smarter decision-making through AI.",true,Through online research and innovation platforms focused on ocean protection and blue tech.,Received,https://drive.google.com/drive/folders/1BEc9s5h5H41vf2bRxpqvz4AHBWMZS1Xm?usp=drive_link,
|
The project aims to improve maritime safety while generating valuable ocean data to support prevention, monitoring, and smarter decision-making through AI.",true,Through online research and innovation platforms focused on ocean protection and blue tech.,Received,https://drive.google.com/drive/folders/1BEc9s5h5H41vf2bRxpqvz4AHBWMZS1Xm?usp=drive_link,
|
||||||
,Tshephiso Kola,+27671509841,Envoyer le message,kolatshepisho@gmail.com,Luminet,Tshephiso Kola,"Johannesburg, Gauteng, Afrique du Sud",Africa,"University of the Witwatersrand, Johannesburg",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"LumiNet is our solution to the fishing industry’s two biggest headaches: catching the wrong fish and losing expensive gear that pollutes the ocean forever. We are replacing standard nylon nets with a smart, dual-action material that actually works with nature. First, our nets glow with a specific light underwater that sharks and turtles instinctively avoid, which keeps them out of the net while the target fish swim right in. Second, we’ve solved the ghost gear problem with a built-in fail-safe: as long as the net is used in the sun, it stays strong, but if it gets lost and sinks into the dark ocean, it rapidly breaks down and turns into fish food. Our goal is simple: to stop plastic pollution at the source and make fishing more efficient, saving marine life and money at the same time.",true,Social Media,,,
|
,Tshephiso Kola,+27671509841,Envoyer le message,kolatshepisho@gmail.com,Luminet,Tshephiso Kola,"Johannesburg, Gauteng, Afrique du Sud",Africa,"University of the Witwatersrand, Johannesburg",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"LumiNet is our solution to the fishing industry’s two biggest headaches: catching the wrong fish and losing expensive gear that pollutes the ocean forever. We are replacing standard nylon nets with a smart, dual-action material that actually works with nature. First, our nets glow with a specific light underwater that sharks and turtles instinctively avoid, which keeps them out of the net while the target fish swim right in. Second, we’ve solved the ghost gear problem with a built-in fail-safe: as long as the net is used in the sun, it stays strong, but if it gets lost and sinks into the dark ocean, it rapidly breaks down and turns into fish food. Our goal is simple: to stop plastic pollution at the source and make fishing more efficient, saving marine life and money at the same time.",true,Social Media,Received,https://drive.google.com/drive/folders/1TD0eL9lEAMapQk7z65du-7l8VVC46aRm?usp=drive_link,
|
||||||
,Eric & Aurélie Viard,+33695360436,Envoyer le message,eric@biovie.fr,Algues au quotidien,"Eric Viard, Aurélie Viard","Nîmes, Occitanie, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2007-03-11,Consumer awareness and education,Use of organic edible seaweeds in daily food and gastronomy,true,We have been invited directly by Marine Jacq-Pietri to submit our project,Received,https://drive.google.com/drive/folders/1KUwDOwvZxoiXJB0cHtwVeIPjdYAYbOV0?usp=drive_link,
|
,Eric & Aurélie Viard,+33695360436,Envoyer le message,eric@biovie.fr,Algues au quotidien,"Eric Viard, Aurélie Viard","Nîmes, Occitanie, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2007-03-11,Consumer awareness and education,Use of organic edible seaweeds in daily food and gastronomy,true,We have been invited directly by Marine Jacq-Pietri to submit our project,Received,https://drive.google.com/drive/folders/1KUwDOwvZxoiXJB0cHtwVeIPjdYAYbOV0?usp=drive_link,
|
||||||
,BARHOUMI Nawress,+21621898617,Envoyer le message,nawressbarhoumigf@gmail.com,El Makina,"Mustapha Zoghlami, Nawress Barhoumi",Tunisie,"Africa, Tunisia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-05-05,Technology & innovations,"The project aims to develop an autonomous intelligent robot for cleaning marine environments, specifically targeting oil spills, human hair, and other pollutants. It focuses on sustainable technology, environmental protection, and smart control systems. The robot is built using recovered and recycled plastic materials, reinforcing the project’s commitment to circular economy principles and eco-friendly engineering.",false,Newsletters,,,
|
,BARHOUMI Nawress,+21621898617,Envoyer le message,nawressbarhoumigf@gmail.com,El Makina,"Mustapha Zoghlami, Nawress Barhoumi",Tunisie,"Africa, Tunisia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-05-05,Technology & innovations,"The project aims to develop an autonomous intelligent robot for cleaning marine environments, specifically targeting oil spills, human hair, and other pollutants. It focuses on sustainable technology, environmental protection, and smart control systems. The robot is built using recovered and recycled plastic materials, reinforcing the project’s commitment to circular economy principles and eco-friendly engineering.",false,Newsletters,,,
|
||||||
,Yao Yinan,+8615221826163,Envoyer le message,yyn982715367@outlook.com,"BluePulse – Design, Protect, Inspire",Yinan Yao,Xinjiang,Asia,"Communication University of China, Nanjing(Location: Nanjing, China)",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"BluePulse is an innovative project that combines art, design, and technology to raise awareness about ocean pollution and marine conservation. Its main objective is to educate and inspire the public through creative visual campaigns, interactive installations, and sustainable product concepts that highlight the importance of protecting our oceans. The project also explores solutions to reduce plastic and chemical pollution, fostering a culture of environmental responsibility.",true,I found out about the Monaco Ocean Protection Challenge through the organisers listed on the UArctic Congress 2026 website: https://www.uarcticcongress.fo/about,,,
|
,Yao Yinan,+8615221826163,Envoyer le message,yyn982715367@outlook.com,"BluePulse – Design, Protect, Inspire",Yinan Yao,Xinjiang,Asia,"Communication University of China, Nanjing(Location: Nanjing, China)",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"BluePulse is an innovative project that combines art, design, and technology to raise awareness about ocean pollution and marine conservation. Its main objective is to educate and inspire the public through creative visual campaigns, interactive installations, and sustainable product concepts that highlight the importance of protecting our oceans. The project also explores solutions to reduce plastic and chemical pollution, fostering a culture of environmental responsibility.",true,I found out about the Monaco Ocean Protection Challenge through the organisers listed on the UArctic Congress 2026 website: https://www.uarcticcongress.fo/about,,,
|
||||||
@@ -455,7 +455,7 @@ The project aims to improve maritime safety while generating valuable ocean data
|
|||||||
|
|
||||||
Face aux pressions climatiques et anthropiques, il combine des technologies innovantes (capteurs environnementaux, drones, caméras sous-marines et intelligence artificielle) et une approche participative impliquant les communautés de pêcheurs.
|
Face aux pressions climatiques et anthropiques, il combine des technologies innovantes (capteurs environnementaux, drones, caméras sous-marines et intelligence artificielle) et une approche participative impliquant les communautés de pêcheurs.
|
||||||
|
|
||||||
Le projet permettra de suivre la santé des récifs, de restaurer les zones dégradées et d’améliorer la gestion des ressources halieutiques, contribuant ainsi à la conservation des écosystèmes marins, à la sécurité alimentaire et au développement durable des communautés côtières de Belle-Anse.",true,Through my university and professional networks and partnerships,,,
|
Le projet permettra de suivre la santé des récifs, de restaurer les zones dégradées et d’améliorer la gestion des ressources halieutiques, contribuant ainsi à la conservation des écosystèmes marins, à la sécurité alimentaire et au développement durable des communautés côtières de Belle-Anse.",true,Through my university and professional networks and partnerships,Received,https://drive.google.com/drive/folders/1Tg4_Z3MOzYbpuBOAJ1EO3bKpn6Aw7kTW?usp=drive_link,
|
||||||
,Samuel Nnaji,+2348161502448,Envoyer le message,realstard247@gmail.com,Zero Ocean,Benjamin Odusanya,"Enugu, Nigéria","Africa, Nigeria",University of Nigeria,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable shipping & yachting,"Project: Zero Ocean
|
,Samuel Nnaji,+2348161502448,Envoyer le message,realstard247@gmail.com,Zero Ocean,Benjamin Odusanya,"Enugu, Nigéria","Africa, Nigeria",University of Nigeria,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable shipping & yachting,"Project: Zero Ocean
|
||||||
|
|
||||||
Idea: Digital platform for transparent, efficient, and sustainable clean fuel supply chain in maritime
|
Idea: Digital platform for transparent, efficient, and sustainable clean fuel supply chain in maritime
|
||||||
@@ -464,7 +464,7 @@ Objectives:
|
|||||||
- Optimize clean fuel procurement and reduce emissions
|
- Optimize clean fuel procurement and reduce emissions
|
||||||
- Ensure compliance with global regulations
|
- Ensure compliance with global regulations
|
||||||
- Enhance bunkering efficiency and audit trails
|
- Enhance bunkering efficiency and audit trails
|
||||||
Key Features: eBDN, AI-driven analytics, real-time tracking, supplier integration",true,WhatsApp,,,
|
Key Features: eBDN, AI-driven analytics, real-time tracking, supplier integration",true,WhatsApp,Doublon,,
|
||||||
,Hannah Gillespie,+447887479247,Envoyer le message,hggillespie12@gmail.com,SeaBrew Coffee,"Anne Moullier, Joseph Flynn, Hannah Gillespie, Laura Coombs, Ronan Cooney","Cambridge, Angleterre, Royaume-Uni",UK,"University of Cambridge, Cambridge, UK",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"SeaBrew is an early-stage food and drink start-up developing a seaweed-reinforced coffee designed to improve micronutrient intake through an existing daily habit. Our product combines sustainably sourced seaweed with coffee to deliver nutrients such as magnesium, while maintaining taste and consumer acceptability. We have already conducted a blind taste test with positive consumer feedback and recently pitched SeaBrew to EIT Food, where we were awarded second place, which has encouraged us to progress towards more rigorous technical validation and compliance ahead of scaling.",true,The Ocean Opportunity Lab (TOOL),,,
|
,Hannah Gillespie,+447887479247,Envoyer le message,hggillespie12@gmail.com,SeaBrew Coffee,"Anne Moullier, Joseph Flynn, Hannah Gillespie, Laura Coombs, Ronan Cooney","Cambridge, Angleterre, Royaume-Uni",UK,"University of Cambridge, Cambridge, UK",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"SeaBrew is an early-stage food and drink start-up developing a seaweed-reinforced coffee designed to improve micronutrient intake through an existing daily habit. Our product combines sustainably sourced seaweed with coffee to deliver nutrients such as magnesium, while maintaining taste and consumer acceptability. We have already conducted a blind taste test with positive consumer feedback and recently pitched SeaBrew to EIT Food, where we were awarded second place, which has encouraged us to progress towards more rigorous technical validation and compliance ahead of scaling.",true,The Ocean Opportunity Lab (TOOL),,,
|
||||||
,Rhea Thoppil,+33745764372,Envoyer le message,rmthoppil@gmail.com,phytoflight,Rhea Thoppil,"Kerala, Inde",Asia,"Sorbonne University, France",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"PhytOFlight
|
,Rhea Thoppil,+33745764372,Envoyer le message,rmthoppil@gmail.com,phytoflight,Rhea Thoppil,"Kerala, Inde",Asia,"Sorbonne University, France",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"PhytOFlight
|
||||||
Plant-based mitigation of plastic pollution in Kerala’s backwaters
|
Plant-based mitigation of plastic pollution in Kerala’s backwaters
|
||||||
@@ -479,7 +479,7 @@ Reduce macroplastic and microplastic pollution in targeted backwater zones, impr
|
|||||||
PhytOFlight integrates ecological restoration with pollution control, offering a cost-effective, climate-resilient solution tailored to Kerala’s backwaters in India.",true,Through my university,,,
|
PhytOFlight integrates ecological restoration with pollution control, offering a cost-effective, climate-resilient solution tailored to Kerala’s backwaters in India.",true,Through my university,,,
|
||||||
,Ethan Jezek,+18178996766,Envoyer le message,ejezek12@gmail.com,OceanID,Ethan Jezek,"Dallas, TX, États-Unis",US,"I have started this concept myself in Dallas, Texas but I am also a PhD candidate at the University of Waikato in New Zealand",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"I have been developing an AI integrated app called OceanID that helps users identify marine species (vegetation, algae, and animals) by uploading photographs. By doing so, and by providing key and exciting information to users, I have ambitions of improving and better establishing community education and outreach, as well as marine networking in communities around the globe. Upon identifying an organism, users are presented with key ecological and economical information about the organism they captured on camera, recent publications, distribution, and if the species is currently a foodstuffs, will be presented with recipes, information on how to safely and sustainably harvest, and sustainable producers where a user could buy ingredients for said recipe . For higher level users, e.g. ocean users such as fishers, farmers, and researchers, information on permitting, local processors, producers, and developers is also provided (this information is provided for all users but intended to be helpful and beneficial for higher-level users).
|
,Ethan Jezek,+18178996766,Envoyer le message,ejezek12@gmail.com,OceanID,Ethan Jezek,"Dallas, TX, États-Unis",US,"I have started this concept myself in Dallas, Texas but I am also a PhD candidate at the University of Waikato in New Zealand",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"I have been developing an AI integrated app called OceanID that helps users identify marine species (vegetation, algae, and animals) by uploading photographs. By doing so, and by providing key and exciting information to users, I have ambitions of improving and better establishing community education and outreach, as well as marine networking in communities around the globe. Upon identifying an organism, users are presented with key ecological and economical information about the organism they captured on camera, recent publications, distribution, and if the species is currently a foodstuffs, will be presented with recipes, information on how to safely and sustainably harvest, and sustainable producers where a user could buy ingredients for said recipe . For higher level users, e.g. ocean users such as fishers, farmers, and researchers, information on permitting, local processors, producers, and developers is also provided (this information is provided for all users but intended to be helpful and beneficial for higher-level users).
|
||||||
|
|
||||||
Other functions on the app include; a database of all species the app has identified, a community tab that displays the discoveries of nearby and followed users, a map function where users can see community discoveries and the location of permit zones, and key economic players (see above) in relation to their location, and a cookbook that saves all of the recipes that a user has collected.",true,I heard of the MOPC through colleagues I have on LinkedIN,,,
|
Other functions on the app include; a database of all species the app has identified, a community tab that displays the discoveries of nearby and followed users, a map function where users can see community discoveries and the location of permit zones, and key economic players (see above) in relation to their location, and a cookbook that saves all of the recipes that a user has collected.",true,I heard of the MOPC through colleagues I have on LinkedIN,Received,https://drive.google.com/drive/folders/17Wu23H236j-mA4a9UDH80Roh93YY2p5M?usp=drive_link,
|
||||||
,Nnaji Samuel Ebube,+2348161502448,Envoyer le message,nnajisamuel2448@gmail.com,OceanFin,"Ifeoma Odusanya, Benjamin Odusanya","Enugu, Nigéria","Africa, Nigeria",University of Nigeria Nsukka,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"🌟 *Project: OceanFin - Boosting Nigeria's Blue Economy 🌊*
|
,Nnaji Samuel Ebube,+2348161502448,Envoyer le message,nnajisamuel2448@gmail.com,OceanFin,"Ifeoma Odusanya, Benjamin Odusanya","Enugu, Nigéria","Africa, Nigeria",University of Nigeria Nsukka,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"🌟 *Project: OceanFin - Boosting Nigeria's Blue Economy 🌊*
|
||||||
- *Idea*: Empower coastal communities with digital financial services for sustainable ocean-based livelihoods 🐟
|
- *Idea*: Empower coastal communities with digital financial services for sustainable ocean-based livelihoods 🐟
|
||||||
- *Objectives*:
|
- *Objectives*:
|
||||||
@@ -487,14 +487,18 @@ Other functions on the app include; a database of all species the app has identi
|
|||||||
- Improve financial inclusion for fishermen, traders 💸
|
- Improve financial inclusion for fishermen, traders 💸
|
||||||
- Promote sustainable ocean practices 🌿
|
- Promote sustainable ocean practices 🌿
|
||||||
- *Key features*: Digital payments, loans, insurance, FX services, international partnerships 🌍",true,Online,Received,https://drive.google.com/drive/folders/10-Xa_exXqDL83JlnFq4-TnAjcL_jPgT-?usp=drive_link,
|
- *Key features*: Digital payments, loans, insurance, FX services, international partnerships 🌍",true,Online,Received,https://drive.google.com/drive/folders/10-Xa_exXqDL83JlnFq4-TnAjcL_jPgT-?usp=drive_link,
|
||||||
,Sofie Boggio Sella,+61448568796,Envoyer le message,boggiosellasofie@gmail.com,PMRF: Probabilistic Multi Reef Fusion pipeline,"Sofie Boggio Sella, Lily Lewis, Mohammad Jahanbakht","Turin, Piémont, Italie","Europe, Italia",James Cook University Australia,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"The project develops an AI-driven system to predict where coral reefs are most likely to survive under future climate conditions. By fusing seafloor structure, reef imagery, environmental data, and biodiversity indicators into a single probabilistic model, it moves beyond mapping what exists today to forecasting where restoration and protection will be most effective tomorrow. Its objective is to identify climate-resilient “safe havens” and restoration hotspots, providing actionable, uncertainty-aware maps for scientists and conservation practitioners. This enables smarter allocation of limited resources, transforming coral conservation from reactive damage control into a proactive strategy for long-term reef resilience.",true,Linkedln,,,
|
,Sofie Boggio Sella,+61448568796,Envoyer le message,boggiosellasofie@gmail.com,PMRF: Probabilistic Multi Reef Fusion pipeline,"Sofie Boggio Sella, Lily Lewis, Mohammad Jahanbakht","Turin, Piémont, Italie","Europe, Italia",James Cook University Australia,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"The project develops an AI-driven system to predict where coral reefs are most likely to survive under future climate conditions. By fusing seafloor structure, reef imagery, environmental data, and biodiversity indicators into a single probabilistic model, it moves beyond mapping what exists today to forecasting where restoration and protection will be most effective tomorrow. Its objective is to identify climate-resilient “safe havens” and restoration hotspots, providing actionable, uncertainty-aware maps for scientists and conservation practitioners. This enables smarter allocation of limited resources, transforming coral conservation from reactive damage control into a proactive strategy for long-term reef resilience.",true,Linkedln,Doublon,,
|
||||||
,Christine Kurz,+4917622904612,Envoyer le message,christine.a.kurz@gmail.com,Xy,Xy,,,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,,,,,,,
|
,Christine Kurz,+4917622904612,Envoyer le message,christine.a.kurz@gmail.com,METHAPOD,"- Harjasdeep: New Delhi, India
|
||||||
|
- Himanshu: Delhi, India
|
||||||
|
- Rainy: Jakarta, Indonesia
|
||||||
|
- Pranav: Pune, India
|
||||||
|
- Christine: Hamburg, Germany ","Hambourg, Allemagne","Europe, Germany",RWTH Aachen Business School,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable shipping & yachting,"we are still working on the specifics of the solution, which you can see by the Open Questions we identified and are currently investigating. However, the identified problem is quite well researched and we believe we have the right angle to support in the decarbonization of commercial shipping. ",true,"I found the MOPC when I was researching innovation and sustainability in the maritime space, as this is exactly the niche I want to be active in. And with such a strong team, it was obvious we had to join! ",Received,https://drive.google.com/drive/folders/1U1bdexvWnQQUQ0C3lPXW1QFuDYgHzaKE?usp=drive_link,
|
||||||
,Antonella Bongiovanni,+393286093034,Envoyer le message,info@evebiofactory.com,EVE Biofactory,Antonella Bongiovanni - Natasa Zarovni - Mauro Manno - Paolo de Stefanis - Lorenzo Sbizzera - Gabriella Pocsfalvi - Paola Gargano,"Palerme, Sicile, Italie","Europe, Italia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-09-29,Technology & innovations,"EVE Biofactory is a deep-biotech company leveraging microalgae to build the most scalable nano drug-delivery platform on the market.
|
,Antonella Bongiovanni,+393286093034,Envoyer le message,info@evebiofactory.com,EVE Biofactory,Antonella Bongiovanni - Natasa Zarovni - Mauro Manno - Paolo de Stefanis - Lorenzo Sbizzera - Gabriella Pocsfalvi - Paola Gargano,"Palerme, Sicile, Italie","Europe, Italia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-09-29,Technology & innovations,"EVE Biofactory is a deep-biotech company leveraging microalgae to build the most scalable nano drug-delivery platform on the market.
|
||||||
Inefficient drug delivery causes treatment failure, patient harm, and up to $40B in annual losses from underperforming bioactives.
|
Inefficient drug delivery causes treatment failure, patient harm, and up to $40B in annual losses from underperforming bioactives.
|
||||||
Inspired by the smallest ocean organisms, EVE develops Nanoalgosomes: naturally occurring exosomes produced from microalgae, the only delivery system that is scalable, circular, and fully biological.
|
Inspired by the smallest ocean organisms, EVE develops Nanoalgosomes: naturally occurring exosomes produced from microalgae, the only delivery system that is scalable, circular, and fully biological.
|
||||||
Nanoalgosomes are cost-competitive, biologically active, and more efficient than synthetic nanoparticles, enabling lower drug doses and reducing the release of medicines and persistent nanomaterials into wastewater that today impact river and ocean ecosystems.",true,Our mentor Alessandro ROmano pointed out the challenge and recommended our project would be a good fit.,Received,https://drive.google.com/drive/folders/1imjoZvbTn-c-xjqgr4C9J_BP5JZz1gyX?usp=drive_link,
|
Nanoalgosomes are cost-competitive, biologically active, and more efficient than synthetic nanoparticles, enabling lower drug doses and reducing the release of medicines and persistent nanomaterials into wastewater that today impact river and ocean ecosystems.",true,Our mentor Alessandro ROmano pointed out the challenge and recommended our project would be a good fit.,Received,https://drive.google.com/drive/folders/1imjoZvbTn-c-xjqgr4C9J_BP5JZz1gyX?usp=drive_link,
|
||||||
,Justyna Grosjean,+33685638357,Envoyer le message,justyna@cleanoceancoatings.com,Clean Ocean Coatings GmbH,"Christina Linke, Jens Deppe, Friederike Bartels, Johana Chen, Sandra Lötsch, Patricia Greim","Hambourg, Allemagne","Europe, Germany",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-05-11,Sustainable shipping & yachting,"The Antifouling Coating of Tomorrow.
|
,Justyna Grosjean,+33685638357,Envoyer le message,justyna@cleanoceancoatings.com,Clean Ocean Coatings GmbH,"Christina Linke, Jens Deppe, Friederike Bartels, Johana Chen, Sandra Lötsch, Patricia Greim","Hambourg, Allemagne","Europe, Germany",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-05-11,Sustainable shipping & yachting,"The Antifouling Coating of Tomorrow.
|
||||||
Lower Costs. Cleaner Oceans. Decarbonating Shipping.",true,Through the Fondation Prince Albert II de Monaco,,,
|
Lower Costs. Cleaner Oceans. Decarbonating Shipping.",true,Through the Fondation Prince Albert II de Monaco,Received,https://drive.google.com/drive/folders/1CKPi1GhRjyF1dMhQcx0Da8g-nPetoIx6?usp=drive_link,
|
||||||
,Erick Patrick dos Anjos Vilhena,+5596981337237,Envoyer le message,e.vilhena@hotmail.com,sustainable fish leather,Andria Carrilho,"Amapá, AP, Brésil",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-01-01,Sustainable fishing and aquaculture & blue food,"Sustainable fish leather production is more than just an exotic alternative; it addresses critical issues within the fashion and food industries, as well as the environment.
|
,Erick Patrick dos Anjos Vilhena,+5596981337237,Envoyer le message,e.vilhena@hotmail.com,sustainable fish leather,Andria Carrilho,"Amapá, AP, Brésil",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-01-01,Sustainable fishing and aquaculture & blue food,"Sustainable fish leather production is more than just an exotic alternative; it addresses critical issues within the fashion and food industries, as well as the environment.
|
||||||
|
|
||||||
Here are the main problems this solution solves:
|
Here are the main problems this solution solves:
|
||||||
@@ -523,7 +527,7 @@ The Difference: Sustainable fish leather solutions focus on vegetable tanning (u
|
|||||||
4. Durability vs. Aesthetics
|
4. Durability vs. Aesthetics
|
||||||
Many leather alternatives (such as ""synthetic leather"" made of plastic/PU) have low durability and pollute the environment with microplastics.
|
Many leather alternatives (such as ""synthetic leather"" made of plastic/PU) have low durability and pollute the environment with microplastics.
|
||||||
|
|
||||||
The Solution: Fish leather has a cross-fiber structure (unlike the parallel fibers in bovine leather), making it extremely strong and tear-resistant despite being thin. It solves the dilemma for those seeking a material that is delicate, durable, and eco-",true,Linkedln,,,
|
The Solution: Fish leather has a cross-fiber structure (unlike the parallel fibers in bovine leather), making it extremely strong and tear-resistant despite being thin. It solves the dilemma for those seeking a material that is delicate, durable, and eco-",true,Linkedln,Received,https://drive.google.com/drive/folders/1NWL0QlFg3RiyvIsm_hfs7d-nvlpf06f2?usp=drive_link,
|
||||||
,Amaia Rodriguez,+34606655862,Envoyer le message,amaia@thegravitywave.com,GRAVITY WAVE,"Amaia Rodriguez, Julen Rodriguez, Naiara Lopez, Alvaro Garcia, Camila Lago, Norberto De Rodrigo, Irene Hurtado","Madrid, Communauté de Madrid, Espagne","Europe, Spain",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2020-05-18,Reduction of pollution (plastics chemicals noise light...),Clean plastic from the sea with fishermen and transform the waste into materials for construction and architecture.,true,A friend sent it to me,Received,https://drive.google.com/drive/folders/11McKvPyKzbUgYiFd2gfeWvLrlGGPhyP2?usp=sharing,
|
,Amaia Rodriguez,+34606655862,Envoyer le message,amaia@thegravitywave.com,GRAVITY WAVE,"Amaia Rodriguez, Julen Rodriguez, Naiara Lopez, Alvaro Garcia, Camila Lago, Norberto De Rodrigo, Irene Hurtado","Madrid, Communauté de Madrid, Espagne","Europe, Spain",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2020-05-18,Reduction of pollution (plastics chemicals noise light...),Clean plastic from the sea with fishermen and transform the waste into materials for construction and architecture.,true,A friend sent it to me,Received,https://drive.google.com/drive/folders/11McKvPyKzbUgYiFd2gfeWvLrlGGPhyP2?usp=sharing,
|
||||||
,Dr Mumthas Yahiya,+917012789400,Envoyer le message,mumthasy@gmail.com,Migratory birds,Thamanna K,Nil Yucel DDS,US,Kerala,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Mitigation of ocean acidification,"“Nature-Based Solutions for Mitigating Ocean Acidification through Coastal Blue Carbon Ecosystems - Project Idea
|
,Dr Mumthas Yahiya,+917012789400,Envoyer le message,mumthasy@gmail.com,Migratory birds,Thamanna K,Nil Yucel DDS,US,Kerala,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Mitigation of ocean acidification,"“Nature-Based Solutions for Mitigating Ocean Acidification through Coastal Blue Carbon Ecosystems - Project Idea
|
||||||
|
|
||||||
@@ -550,10 +554,10 @@ Reducing coral reef destruction",true,thanks to the oceanography museum,Received
|
|||||||
,Lily Atussa Payton,+13015297789,Envoyer le message,lily.a.payton@gmail.com,Oyster Club NYC,"Lily Payton, Kelsey Burkin, Savannah Harker","New York, NY, États-Unis",US,N/A,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"Oyster Club NYC is a fledgling organization aimed at giving New Yorkers a hands-on connection to their maritime past, present, and future through the lens of ocean sustainability. Rending New Yorkers that NYC is truly their oyster, and that some of the strongest communities are built around the smallest of creatures. Specifically, we create bespoke events aimed at bringing together people to create community, discuss how making small choices can benefit our oceans, such as eating oysters, all while having fun in the process. This has manifested in a Learn-to-Shuck Holiday Party in December and a monthly oyster happy hour at various locations across the city. Our specific objectives are threefold:
|
,Lily Atussa Payton,+13015297789,Envoyer le message,lily.a.payton@gmail.com,Oyster Club NYC,"Lily Payton, Kelsey Burkin, Savannah Harker","New York, NY, États-Unis",US,N/A,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"Oyster Club NYC is a fledgling organization aimed at giving New Yorkers a hands-on connection to their maritime past, present, and future through the lens of ocean sustainability. Rending New Yorkers that NYC is truly their oyster, and that some of the strongest communities are built around the smallest of creatures. Specifically, we create bespoke events aimed at bringing together people to create community, discuss how making small choices can benefit our oceans, such as eating oysters, all while having fun in the process. This has manifested in a Learn-to-Shuck Holiday Party in December and a monthly oyster happy hour at various locations across the city. Our specific objectives are threefold:
|
||||||
- use oysters as a catalyst to expose New Yorkers to sustainable and regenerative food in a social environment,
|
- use oysters as a catalyst to expose New Yorkers to sustainable and regenerative food in a social environment,
|
||||||
- embed an oceans-focused mindset into an island city that often forgets its connection to the water, and
|
- embed an oceans-focused mindset into an island city that often forgets its connection to the water, and
|
||||||
- build a climate-minded community across the five boroughs.",true,Online research,,,
|
- build a climate-minded community across the five boroughs.",true,Online research,Received,https://drive.google.com/drive/folders/1JBMaMkVyzQXRqSyJiw6QOdCqiroFyHVF?usp=drive_link,
|
||||||
,Gary Molano,+12135198233,Envoyer le message,gary@macrobreed.com,MacroBreed,"Scott Lindell, Charles Yarish, Filipe Alberto","New York, NY, États-Unis",US,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-07-19,Sustainable fishing and aquaculture & blue food,"We breed better seaweed using genomic breeding techniques. We started with kelp, and have achieved 4fold harvestable yield gains in 5 years of breeding, a 10x speed advancement compared to breeding efforts in Asia. We are currently targeting traits that increase the value of seaweed, such as lower iodine and higher bioactive composition (fucoidan, alginate, laminarin, etc), to help make farmed kelp more competitive with wild harvests. We also have a breeding scheme that produces ""sterile"" kelp to protect local ecosystems from farmed kelp. This sterile kelp is produced using non-GMO techniques.",true,Through the ocean exchange newsletter,,,
|
,Gary Molano,+12135198233,Envoyer le message,gary@macrobreed.com,MacroBreed,"Scott Lindell, Charles Yarish, Filipe Alberto","New York, NY, États-Unis",US,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-07-19,Sustainable fishing and aquaculture & blue food,"We breed better seaweed using genomic breeding techniques. We started with kelp, and have achieved 4fold harvestable yield gains in 5 years of breeding, a 10x speed advancement compared to breeding efforts in Asia. We are currently targeting traits that increase the value of seaweed, such as lower iodine and higher bioactive composition (fucoidan, alginate, laminarin, etc), to help make farmed kelp more competitive with wild harvests. We also have a breeding scheme that produces ""sterile"" kelp to protect local ecosystems from farmed kelp. This sterile kelp is produced using non-GMO techniques.",true,Through the ocean exchange newsletter,,,
|
||||||
,Qendresa Krasniqi,+4798474602,Envoyer le message,qendresa04@gmail.com,Aegir by Navier USN,"Qendresa Krasniqi, Hedda Collin, Markus Marstad","Horten, Vestfold, Norvège","Europe, Norway",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-10-06,Restoration of marine habitats & ecosystems,"We are developing a specialized ROV designed to restore marine ecosystems. Coastal ecosystems, such as the Oslofjord, are currently threatened by invasive species and marine debris. Specifically, the Pacific oyster is spreading rapidly, requiring efficient and noninvasive methods for removal to protect local biodiversity. Our current prototype is part of a joint venture with 'Matfat Oslofjorden,' where it will harvest invasive oysters from the Oslo Fjord to be repurposed as a sustainable food source. Our solution is efficient, non-invasive, and fully programmable for diverse oceanic habitats and tasks. Navier USN is not starting from scratch with a proven track record in developing autonomous surface vehicles (ASVs), our startup concept expands this expertise into the underwater domain. We are a seasoned technical and commercial team with a proven track record in autonomous maritime technology, including multiple world championship titles. Supported by prominent industry partners, we have the proven competence and scale to transform maritime environmental management",true,1000 Ocean StartUps,,,
|
,Qendresa Krasniqi,+4798474602,Envoyer le message,qendresa04@gmail.com,Aegir by Navier USN,"Qendresa Krasniqi, Hedda Collin, Markus Marstad","Horten, Vestfold, Norvège","Europe, Norway",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-10-06,Restoration of marine habitats & ecosystems,"We are developing a specialized ROV designed to restore marine ecosystems. Coastal ecosystems, such as the Oslofjord, are currently threatened by invasive species and marine debris. Specifically, the Pacific oyster is spreading rapidly, requiring efficient and noninvasive methods for removal to protect local biodiversity. Our current prototype is part of a joint venture with 'Matfat Oslofjorden,' where it will harvest invasive oysters from the Oslo Fjord to be repurposed as a sustainable food source. Our solution is efficient, non-invasive, and fully programmable for diverse oceanic habitats and tasks. Navier USN is not starting from scratch with a proven track record in developing autonomous surface vehicles (ASVs), our startup concept expands this expertise into the underwater domain. We are a seasoned technical and commercial team with a proven track record in autonomous maritime technology, including multiple world championship titles. Supported by prominent industry partners, we have the proven competence and scale to transform maritime environmental management",true,1000 Ocean StartUps,Received,https://drive.google.com/drive/folders/1-upTx0iRxpXg-LE476_mtG92i6TCv2pM?usp=drive_link,
|
||||||
,Dorra Fadhloun,+21629508048,Envoyer le message,dorra.fadhloun@msb.tn,Oceani,Samar,"Tunis, Tunisie","Africa, Tunisia",Mediterranean School of Business,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,PlastiTrack's goal is to turn citizen smartphone photos into citywide microplastic pollution heatmaps that municipalities use to prioritize cleanup investments.,true,LinkedIn,,,
|
,Dorra Fadhloun,+21629508048,Envoyer le message,dorra.fadhloun@msb.tn,Oceani,Samar,"Tunis, Tunisie","Africa, Tunisia",Mediterranean School of Business,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,PlastiTrack's goal is to turn citizen smartphone photos into citywide microplastic pollution heatmaps that municipalities use to prioritize cleanup investments.,true,LinkedIn,Received,https://drive.google.com/drive/folders/12aiatT4nDGJJFMI9Z91gXjeeaSePL7A4?usp=drive_link,
|
||||||
,Ahamed Adhnaf,+94760270097,Envoyer le message,anaadhnaf413@gmail.com,OCEAN RENEW,"Ahamed Adhnaf , Kaveesha Gunarathna, Mohammed Rifath","Colombo, Western, Sri Lanka","Africa, Sri Lanka",National Institute of Social Development - Sri Lanka,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"AquaHorizon is a holistic ocean innovation hub that transforms ocean challenges into solutions while empowering coastal communities. Our objectives are to develop sustainable practices for marine conservation, reduce pollution, provide education and capacity building for coastal populations, and create a collaborative space where innovators can design and implement solutions for a healthier ocean and thriving communities.",true,I learned about the Monaco Ocean Protection Challenge through a friend.,,,
|
,Ahamed Adhnaf,+94760270097,Envoyer le message,anaadhnaf413@gmail.com,OCEAN RENEW,"Ahamed Adhnaf , Kaveesha Gunarathna, Mohammed Rifath","Colombo, Western, Sri Lanka","Africa, Sri Lanka",National Institute of Social Development - Sri Lanka,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"AquaHorizon is a holistic ocean innovation hub that transforms ocean challenges into solutions while empowering coastal communities. Our objectives are to develop sustainable practices for marine conservation, reduce pollution, provide education and capacity building for coastal populations, and create a collaborative space where innovators can design and implement solutions for a healthier ocean and thriving communities.",true,I learned about the Monaco Ocean Protection Challenge through a friend.,,,
|
||||||
,Robert Kunzmann,+441751026862,Envoyer le message,robert.kunzmann@acbiode.com,AC Biode,Robert Kunzmann,Luxembourg,"Europe, Luxembourg",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2019-04-01,Reduction of pollution (plastics chemicals noise light...),"According to the UN, only 9% of 8.3 billion tons of plastic waste have been recycled over the past 65 years.
|
,Robert Kunzmann,+441751026862,Envoyer le message,robert.kunzmann@acbiode.com,AC Biode,Robert Kunzmann,Luxembourg,"Europe, Luxembourg",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2019-04-01,Reduction of pollution (plastics chemicals noise light...),"According to the UN, only 9% of 8.3 billion tons of plastic waste have been recycled over the past 65 years.
|
||||||
Most of the plastic waste ends up burned, burried or in the oceans. This profound impact on our environment is not fully understood yet, but micro plastics have been found in all the way from glaciers, to human placentas.
|
Most of the plastic waste ends up burned, burried or in the oceans. This profound impact on our environment is not fully understood yet, but micro plastics have been found in all the way from glaciers, to human placentas.
|
||||||
@@ -569,11 +573,11 @@ This scalable solution contributes directly to ocean conservation, blue economy
|
|||||||
,Julia Denkmayr,+393203476632,Envoyer le message,julia@nereia-coatings.com,NEREIA,Rimah Darawish,"Salzbourg, Autriche","Austria, Europe",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-04-30,Sustainable shipping & yachting,"NEREIA develops non-toxic antifouling technology using functional surfaces to prevent biofouling on ship hulls, reducing drag and improving fuel efficiency. Eliminating hull fouling could save the shipping industry up to $30bn in fuel costs and 200 Mt of CO2 annually.",false,"Through a Carbon 13 domain expert, Thibaut Monfort Micheo, as well as LinkedIn and other social media.",Received,https://drive.google.com/drive/folders/1vdRP4PIQBCZ3xuRqXk2NHyW-1o0JAbZ9?usp=drive_link,
|
,Julia Denkmayr,+393203476632,Envoyer le message,julia@nereia-coatings.com,NEREIA,Rimah Darawish,"Salzbourg, Autriche","Austria, Europe",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-04-30,Sustainable shipping & yachting,"NEREIA develops non-toxic antifouling technology using functional surfaces to prevent biofouling on ship hulls, reducing drag and improving fuel efficiency. Eliminating hull fouling could save the shipping industry up to $30bn in fuel costs and 200 Mt of CO2 annually.",false,"Through a Carbon 13 domain expert, Thibaut Monfort Micheo, as well as LinkedIn and other social media.",Received,https://drive.google.com/drive/folders/1vdRP4PIQBCZ3xuRqXk2NHyW-1o0JAbZ9?usp=drive_link,
|
||||||
,Tara Lepine,+64273401929,Envoyer le message,tlep171@aucklanduni.ac.nz,OceanSeed,Tara Lepine,"Ottawa, ON, Canada",Canada,"University of Auckland, Auckland, New Zealand",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"OceanSeed deploys mobile hatchery units as emergency response infrastructure to restore marine ecosystems and fisheries after collapse, often caused by climate change. Its objectives are to rapidly produce native juvenile shellfish, rebuild ecosystems and keystone populations, protect biodiversity, and support local livelihoods. OceanSeed can be scaled globally through a network of mobile hatcheries, using aquaculture as a tool for conservation and climate adaptation.",false,Communication from our university department head.,Received,https://drive.google.com/drive/folders/1zNrkuAtAAcqyLPkiyLH8ihV675jWbF1Y?usp=drive_link,
|
,Tara Lepine,+64273401929,Envoyer le message,tlep171@aucklanduni.ac.nz,OceanSeed,Tara Lepine,"Ottawa, ON, Canada",Canada,"University of Auckland, Auckland, New Zealand",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"OceanSeed deploys mobile hatchery units as emergency response infrastructure to restore marine ecosystems and fisheries after collapse, often caused by climate change. Its objectives are to rapidly produce native juvenile shellfish, rebuild ecosystems and keystone populations, protect biodiversity, and support local livelihoods. OceanSeed can be scaled globally through a network of mobile hatcheries, using aquaculture as a tool for conservation and climate adaptation.",false,Communication from our university department head.,Received,https://drive.google.com/drive/folders/1zNrkuAtAAcqyLPkiyLH8ihV675jWbF1Y?usp=drive_link,
|
||||||
,Reid Barnett,+19198010336,Envoyer le message,reidbarnett@ceretunellc.com,Ceretune LLC,Reid Barnett and Blake Parrish,"Mt Olive, NC, États-Unis",US,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-05-01,Reduction of pollution (plastics chemicals noise light...),"Our project is using our proprietary technology to grow plants directly on the surface of natural and manmade surface waters. This allows us to sequester carbon, nutirents, and other chemical pollutants at scale in both ocean systems and the freshwater systems upstream. After the plants are fully grown the entire system, material and biomass, can be pyrolyzed to generate carbon negative energy and lock carbon away in a stable form. This process is highly efficient and extremely inexpensive. When we pyrolyze the system we generate over three times more revenue than the total cost of the system and its operation, while opening up opportunites for blue carbon credits and creating bio-oil which can be refined for various uses.
|
,Reid Barnett,+19198010336,Envoyer le message,reidbarnett@ceretunellc.com,Ceretune LLC,Reid Barnett and Blake Parrish,"Mt Olive, NC, États-Unis",US,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-05-01,Reduction of pollution (plastics chemicals noise light...),"Our project is using our proprietary technology to grow plants directly on the surface of natural and manmade surface waters. This allows us to sequester carbon, nutirents, and other chemical pollutants at scale in both ocean systems and the freshwater systems upstream. After the plants are fully grown the entire system, material and biomass, can be pyrolyzed to generate carbon negative energy and lock carbon away in a stable form. This process is highly efficient and extremely inexpensive. When we pyrolyze the system we generate over three times more revenue than the total cost of the system and its operation, while opening up opportunites for blue carbon credits and creating bio-oil which can be refined for various uses.
|
||||||
This project is about creating an entirely new pathway for pollutants in the environment to redirect them from where they cause harm and towards where they can generate value and do good.",true,We were connected through the team at Ocean Exchange.,,,
|
This project is about creating an entirely new pathway for pollutants in the environment to redirect them from where they cause harm and towards where they can generate value and do good.",true,We were connected through the team at Ocean Exchange.,Received,https://drive.google.com/drive/folders/1xIC5oLItJoIqtP4Eua3P7hk5tQ_6Hudy?usp=drive_link,
|
||||||
,Ryan Borotra,+16479659526,Envoyer le message,ryan@sentrylabs.cc,Sentry Labs,"Ryan Borotra, Martin Chaperot, Andrei Bogza","Montréal, QC, Canada",Canada,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-10-20,Sustainable fishing and aquaculture & blue food,"Sentry Labs is developing graphene field-effect transistor (GFET)–based molecular sensors for sustainable fishing, aquaculture, and blue food systems. The project focuses on real-time, in-situ detection of biologically and chemically relevant signals in seawater to enable earlier identification of environmental and biological risks affecting farmed and wild stocks. Our objective is to provide robust, reproducible sensing systems that support healthier stocks, reduced losses, and more sustainable management of marine food production.",true,LinkedIn,,,
|
,Ryan Borotra,+16479659526,Envoyer le message,ryan@sentrylabs.cc,Sentry Labs,"Ryan Borotra, Martin Chaperot, Andrei Bogza","Montréal, QC, Canada",Canada,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-10-20,Sustainable fishing and aquaculture & blue food,"Sentry Labs is developing graphene field-effect transistor (GFET)–based molecular sensors for sustainable fishing, aquaculture, and blue food systems. The project focuses on real-time, in-situ detection of biologically and chemically relevant signals in seawater to enable earlier identification of environmental and biological risks affecting farmed and wild stocks. Our objective is to provide robust, reproducible sensing systems that support healthier stocks, reduced losses, and more sustainable management of marine food production.",true,LinkedIn,,,
|
||||||
,Nadine Hakim,+573205421979,Envoyer le message,nadinehakimm@gmail.com,SEAMOSS COLOMBIA,Sandra Bessudo and Irene Arroyave,"Bogota, Colombie",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-10-01,Sustainable fishing and aquaculture & blue food,"SEAMOSS is a sustainable biodesign and coastal livelihood project focused on the cultivation and transformation of sea moss (marine macroalgae) as a nature-based solution to environmental and social challenges in coastal communities. The project combines regenerative aquaculture, biomaterial development, and community-led value chains to reduce pressure on marine ecosystems while creating local economic opportunities.
|
,Nadine Hakim,+573205421979,Envoyer le message,nadinehakimm@gmail.com,SEAMOSS COLOMBIA,Sandra Bessudo and Irene Arroyave,"Bogota, Colombie",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-10-01,Sustainable fishing and aquaculture & blue food,"SEAMOSS is a sustainable biodesign and coastal livelihood project focused on the cultivation and transformation of sea moss (marine macroalgae) as a nature-based solution to environmental and social challenges in coastal communities. The project combines regenerative aquaculture, biomaterial development, and community-led value chains to reduce pressure on marine ecosystems while creating local economic opportunities.
|
||||||
|
|
||||||
The core idea is to cultivate native sea moss species using low-impact, regenerative methods and transform the biomass into biodegradable materials and functional products that can replace plastic-based alternatives, particularly in packaging, design, and everyday consumer goods.",true,,,,
|
The core idea is to cultivate native sea moss species using low-impact, regenerative methods and transform the biomass into biodegradable materials and functional products that can replace plastic-based alternatives, particularly in packaging, design, and everyday consumer goods.",true,,Received,https://drive.google.com/drive/folders/1hLqybevHIM2yCrOslFLhh1QSv0--zBKz?usp=drive_link,
|
||||||
,Maria Ester Faiella,+393311538952,Envoyer le message,maria.ester.faiella@gmail.com,ThermoShield,Maria Ester Faiella,"Rome, Latium, Italie","Europe, Italia",The American University of Rome,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"ThermoShield is a modular underwater panel system that passively reduces local heat from coastal infrastructure. Its objective is to prevent thermal stress on sensitive marine ecosystems, protecting coral reefs and seagrass worldwide. The panels are easy to install, require no electricity and provide measurable local temperature reductions of 0.3–0.5°C, making the solution scalable and globally applicable.",true,LinkedIn,Received,https://drive.google.com/drive/folders/11mSSY2USGnxyypDwJa5DYiVLUlo6iyvF?usp=drive_link,
|
,Maria Ester Faiella,+393311538952,Envoyer le message,maria.ester.faiella@gmail.com,ThermoShield,Maria Ester Faiella,"Rome, Latium, Italie","Europe, Italia",The American University of Rome,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"ThermoShield is a modular underwater panel system that passively reduces local heat from coastal infrastructure. Its objective is to prevent thermal stress on sensitive marine ecosystems, protecting coral reefs and seagrass worldwide. The panels are easy to install, require no electricity and provide measurable local temperature reductions of 0.3–0.5°C, making the solution scalable and globally applicable.",true,LinkedIn,Received,https://drive.google.com/drive/folders/11mSSY2USGnxyypDwJa5DYiVLUlo6iyvF?usp=drive_link,
|
||||||
,Kumari Anushka,+919798061093,Envoyer le message,nasabutbetter@gmail.com,accore,Kumari Anushka,"Jamshedpur, Jharkhand, Inde",Asia,Ashoka University,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"Coral reefs are collapsing - rising ocean temperatures have triggered mass bleaching, 84.6% of corals in Lakshadweep bleached recently. India has 1,439 km² of mapped coral reefs, coasts have 80 ± 33 microplastic particles per cubic meter, and ~30% of sampled market fish have microplastics. Odisha’s Bay of Bengal estuaries have elevated metal concentrations.
|
,Kumari Anushka,+919798061093,Envoyer le message,nasabutbetter@gmail.com,accore,Kumari Anushka,"Jamshedpur, Jharkhand, Inde",Asia,Ashoka University,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"Coral reefs are collapsing - rising ocean temperatures have triggered mass bleaching, 84.6% of corals in Lakshadweep bleached recently. India has 1,439 km² of mapped coral reefs, coasts have 80 ± 33 microplastic particles per cubic meter, and ~30% of sampled market fish have microplastics. Odisha’s Bay of Bengal estuaries have elevated metal concentrations.
|
||||||
|
|
||||||
@@ -591,7 +595,7 @@ The MoEFCC National Coastal Mission Scheme funds coral/mangrove conservation act
|
|||||||
|
|
||||||
Also, the World Bank-supported Integrated Coastal Zone Management (ICZM) gives importance to science-based coastal planning; pods’ sensor data could be used for threat mapping and adaptive management in the deployed zones.
|
Also, the World Bank-supported Integrated Coastal Zone Management (ICZM) gives importance to science-based coastal planning; pods’ sensor data could be used for threat mapping and adaptive management in the deployed zones.
|
||||||
|
|
||||||
For global scaling: Australia’s Reef 2050 Plan, Indonesia’s COREMAP, and the US NOAA Coral Reef Conservation Program exist, so the project could plug into existing national funding priorities across eligible countries.",true,My university's professor,,,
|
For global scaling: Australia’s Reef 2050 Plan, Indonesia’s COREMAP, and the US NOAA Coral Reef Conservation Program exist, so the project could plug into existing national funding priorities across eligible countries.",true,My university's professor,Received,https://drive.google.com/drive/folders/1_T-uCTNFwzkeyKx70TZhM8OUEe7KZVYB?usp=drive_link,
|
||||||
,Daniela Nairita,+254717162468,Envoyer le message,nairita@yarsi.net,Yarsi Aquacycle,"Christabell Adhambo, Snyder Phoebe, Ken Lenguro, Walter Mwaluma, Zuhura Ahmed","Marsabit, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-11-22,Reduction of pollution (plastics chemicals noise light...),"YARSI Aquacycle is a circular blue economy enterprise focused on transforming aquatic waste into high-value, sustainable products. The core idea is to close resource loops in the fisheries and aquaculture sectors by recovering fish by-products and underutilized biomass and converting them into commercially viable outputs, reducing environmental pollution while creating new income streams.
|
,Daniela Nairita,+254717162468,Envoyer le message,nairita@yarsi.net,Yarsi Aquacycle,"Christabell Adhambo, Snyder Phoebe, Ken Lenguro, Walter Mwaluma, Zuhura Ahmed","Marsabit, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-11-22,Reduction of pollution (plastics chemicals noise light...),"YARSI Aquacycle is a circular blue economy enterprise focused on transforming aquatic waste into high-value, sustainable products. The core idea is to close resource loops in the fisheries and aquaculture sectors by recovering fish by-products and underutilized biomass and converting them into commercially viable outputs, reducing environmental pollution while creating new income streams.
|
||||||
Objectives:
|
Objectives:
|
||||||
1.Valorize aquatic waste into high-value sustainable products (fish oil, fish skin, bio-compost).
|
1.Valorize aquatic waste into high-value sustainable products (fish oil, fish skin, bio-compost).
|
||||||
@@ -608,8 +612,8 @@ By harvesting algae that would otherwise be eliminated, the project contributes
|
|||||||
|
|
||||||
In the short term, the project focuses on collecting and supplying this algae to identified outlets such as organic fertilisers, animal feed and food processing. In parallel, it aims to strengthen local food systems by making a high-quality marine resource available for nearby transformation, reducing transport distances and supporting coastal economies.
|
In the short term, the project focuses on collecting and supplying this algae to identified outlets such as organic fertilisers, animal feed and food processing. In parallel, it aims to strengthen local food systems by making a high-quality marine resource available for nearby transformation, reducing transport distances and supporting coastal economies.
|
||||||
|
|
||||||
In the longer term, the project contributes to the development of integrated oyster–algae co-cultivation systems, increasing the resilience of coastal farming activities to climate and sanitary pressures, while actively participating in the sustainable growth of the seaweed sector in line with national and European marine strategies.",true,From my friends from IZALGUE,,,
|
In the longer term, the project contributes to the development of integrated oyster–algae co-cultivation systems, increasing the resilience of coastal farming activities to climate and sanitary pressures, while actively participating in the sustainable growth of the seaweed sector in line with national and European marine strategies.",true,From my friends from IZALGUE,Received,https://drive.google.com/drive/folders/1bH0Dpgnob5lCk8neLaoV4ppDkrykt-9I?usp=drive_link,
|
||||||
,Elizabeth Okullow,+254724328171,Envoyer le message,elizabethokullow@gmail.com,Bionala,1. Sydney Badiola 2. Heloisa Bredemann 3. Elizabeth Okullow,"Kisumu, Kenya","Africa, Kenya","University of Liège (HEC Liège), Belgium",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"Bionala is a circular bioeconomy venture that transforms unavoidable seafood by-products into high-value marine bio-ingredients, including protein powders, fish oil, and collagen. The project addresses pollution and environmental externalities in fisheries and aquaculture by intercepting seafood by-products before they are dumped or left to decompose, reducing harm to aquatic ecosystems. Bionala works with existing seafood value-chain actors to create a traceable, scalable sourcing model and supplies sustainable marine bio-ingredients to global food, nutrition, and cosmetic markets, contributing to more resilient and resource-efficient blue food systems.",true,LinkedIn,,,
|
,Elizabeth Okullow,+254724328171,Envoyer le message,elizabethokullow@gmail.com,Bionala,1. Sydney Badiola 2. Heloisa Bredemann 3. Elizabeth Okullow,"Kisumu, Kenya","Africa, Kenya","University of Liège (HEC Liège), Belgium",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"Bionala is a circular bioeconomy venture that transforms unavoidable seafood by-products into high-value marine bio-ingredients, including protein powders, fish oil, and collagen. The project addresses pollution and environmental externalities in fisheries and aquaculture by intercepting seafood by-products before they are dumped or left to decompose, reducing harm to aquatic ecosystems. Bionala works with existing seafood value-chain actors to create a traceable, scalable sourcing model and supplies sustainable marine bio-ingredients to global food, nutrition, and cosmetic markets, contributing to more resilient and resource-efficient blue food systems.",true,LinkedIn,Received,https://drive.google.com/drive/folders/1i8Fka0z7owVsJW74L32UFS1FVK88_Tzc?usp=drive_link,
|
||||||
,Maria,+51968969569,Envoyer le message,maria.moralesleguia@gmail.com,metalmas,juan morales,Peru,South America,PUCP,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,ETENOLMAL,true,NOTHING,,,
|
,Maria,+51968969569,Envoyer le message,maria.moralesleguia@gmail.com,metalmas,juan morales,Peru,South America,PUCP,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,ETENOLMAL,true,NOTHING,,,
|
||||||
,Indira Angela Luza Eyzaguirre,+5591985374539,Envoyer le message,ieyzaguirre@reinnova.org,Guardianes de la Costa: Monitoreo costero participativo para la protección de playas urbanas,"Indira Angela Luza Eyzaguirre, Leonardo Jasiel Luza Eyzaguirre, Marco Ivart Mateo Eyzaguirre, Josefina Eyzaguirre Flores","Lima, Peru",South America,"Universidad Tecnológica del Perú - UTP, Lima, Perú",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"Guardianes de la Costa is a participatory coastal monitoring initiative designed to protect urban beaches by generating reliable local data and empowering coastal communities. The project trains citizens to monitor key indicators such as erosion, pollution, and coastal biodiversity using simple, replicable protocols. The collected data supports evidence-based decision-making, early detection of coastal degradation, and the implementation of nature-based and community-led solutions. Piloted on urban beaches in Lima, the model is scalable and adaptable to other coastal cities facing similar environmental pressures.",true,In instagram,Doublon,,
|
,Indira Angela Luza Eyzaguirre,+5591985374539,Envoyer le message,ieyzaguirre@reinnova.org,Guardianes de la Costa: Monitoreo costero participativo para la protección de playas urbanas,"Indira Angela Luza Eyzaguirre, Leonardo Jasiel Luza Eyzaguirre, Marco Ivart Mateo Eyzaguirre, Josefina Eyzaguirre Flores","Lima, Peru",South America,"Universidad Tecnológica del Perú - UTP, Lima, Perú",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"Guardianes de la Costa is a participatory coastal monitoring initiative designed to protect urban beaches by generating reliable local data and empowering coastal communities. The project trains citizens to monitor key indicators such as erosion, pollution, and coastal biodiversity using simple, replicable protocols. The collected data supports evidence-based decision-making, early detection of coastal degradation, and the implementation of nature-based and community-led solutions. Piloted on urban beaches in Lima, the model is scalable and adaptable to other coastal cities facing similar environmental pressures.",true,In instagram,Doublon,,
|
||||||
,Leonardo Jasiel Luza Eyzaguirre,+51942673128,Envoyer le message,resilienciainnovadora@gmail.com,Mangrove Watchers: Data-driven conservation with shellfish and crab harvesters,"Leonardo Jasiel Luza Eyzaguirre, Indira Angela Luza Eyzaguirre, Marco Ivart Mateo Eyzaguirre, Josefina Caridad Eyzaguirre Flores","Zarumilla, Tumbes, Peru",South America,Universidad Nacional Federico Villareal,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"Mangrove Watchers is a data-driven conservation initiative working with shellfish and crab harvesters to support sustainable mangrove fisheries. The project combines local ecological knowledge with simple monitoring tools to track mangrove health, shellfish and crab stocks, and human pressures. Community-generated data is used to inform sustainable harvesting practices, improve resource management, and strengthen coastal livelihoods. Piloted with artisanal harvesters in northern Peru, the model is low-cost, scalable, and adaptable to mangrove-dependent fisheries across coastal regions.",true,In website and instagram,Received,https://drive.google.com/drive/folders/1dwsY_WDvzJ6tjnDdsYVPA88TRH7gIYo7?usp=drive_link,
|
,Leonardo Jasiel Luza Eyzaguirre,+51942673128,Envoyer le message,resilienciainnovadora@gmail.com,Mangrove Watchers: Data-driven conservation with shellfish and crab harvesters,"Leonardo Jasiel Luza Eyzaguirre, Indira Angela Luza Eyzaguirre, Marco Ivart Mateo Eyzaguirre, Josefina Caridad Eyzaguirre Flores","Zarumilla, Tumbes, Peru",South America,Universidad Nacional Federico Villareal,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"Mangrove Watchers is a data-driven conservation initiative working with shellfish and crab harvesters to support sustainable mangrove fisheries. The project combines local ecological knowledge with simple monitoring tools to track mangrove health, shellfish and crab stocks, and human pressures. Community-generated data is used to inform sustainable harvesting practices, improve resource management, and strengthen coastal livelihoods. Piloted with artisanal harvesters in northern Peru, the model is low-cost, scalable, and adaptable to mangrove-dependent fisheries across coastal regions.",true,In website and instagram,Received,https://drive.google.com/drive/folders/1dwsY_WDvzJ6tjnDdsYVPA88TRH7gIYo7?usp=drive_link,
|
||||||
@@ -639,15 +643,15 @@ Over time, these programs were consolidated into Charlas El Océano as the proje
|
|||||||
|
|
||||||
Implemented continuously for seven years, Proyecto Acuática has reached over 1,900 students through in-person school-based sessions and international youth activities. A distinctive feature of the project is the direct interaction between children, youth and marine scientists, allowing educational spaces to inform conservation thinking and, in some cases, inspire new scientific inquiry.
|
Implemented continuously for seven years, Proyecto Acuática has reached over 1,900 students through in-person school-based sessions and international youth activities. A distinctive feature of the project is the direct interaction between children, youth and marine scientists, allowing educational spaces to inform conservation thinking and, in some cases, inspire new scientific inquiry.
|
||||||
|
|
||||||
The current phase focuses on consolidating impact measurement and scaling the model through institutional partnerships, ensuring that ocean education translates into measurable and lasting conservation outcomes.",true,A friend and social media,,,
|
The current phase focuses on consolidating impact measurement and scaling the model through institutional partnerships, ensuring that ocean education translates into measurable and lasting conservation outcomes.",true,A friend and social media,Received,https://drive.google.com/drive/folders/1WT7pf7I6W0JR0YWHbzQTIzRSVL6btS7G?usp=drive_link,
|
||||||
,Nina Lantinga,+14388630647,Envoyer le message,nina@netsfornetzero.com,Nets for Net Zero,"Purnank Shah, Shadi Khamani","Montréal, QC, Canada",Canada,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-03-21,Reduction of pollution (plastics chemicals noise light...),"By 2030, Nets for Net Zero will be a global leader in circular marine plastic systems, leveraging partnerships with coastal communities, fisheries, and manufacturers to advance processing technologies that create resilient, circular, net-positive economies worldwide.",true,Student On Ice Foundation,,,
|
,Nina Lantinga,+14388630647,Envoyer le message,nina@netsfornetzero.com,Nets for Net Zero,"Purnank Shah, Shadi Khamani","Montréal, QC, Canada",Canada,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-03-21,Reduction of pollution (plastics chemicals noise light...),"By 2030, Nets for Net Zero will be a global leader in circular marine plastic systems, leveraging partnerships with coastal communities, fisheries, and manufacturers to advance processing technologies that create resilient, circular, net-positive economies worldwide.",true,Student On Ice Foundation,Received,https://drive.google.com/drive/folders/1cMVVwL369wMyEcEYcVlfzoxCLPXilad8?usp=drive_link,
|
||||||
,Davide Balbi,+393318344974,Envoyer le message,db@mondomigliore.eu,C.A.S.A. Marine Loop,"Santino Filippeddu, Davide Balbi, Gianluca Del Vecchio","Olbia, Sardaigne, Italie","Europe, Italia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-09-19,Sustainable fishing and aquaculture & blue food,"C.A.S.A. Marine Loop is a modular retrofit solution for offshore fish cages, designed to operate around, inside and below cages.
|
,Davide Balbi,+393318344974,Envoyer le message,db@mondomigliore.eu,C.A.S.A. Marine Loop,"Santino Filippeddu, Davide Balbi, Gianluca Del Vecchio","Olbia, Sardaigne, Italie","Europe, Italia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-09-19,Sustainable fishing and aquaculture & blue food,"C.A.S.A. Marine Loop is a modular retrofit solution for offshore fish cages, designed to operate around, inside and below cages.
|
||||||
It aspirates and treats water to intercept solid waste and organic loads that negatively affect fish health and surrounding marine areas.
|
It aspirates and treats water to intercept solid waste and organic loads that negatively affect fish health and surrounding marine areas.
|
||||||
The core technology is a stainless-steel biofiltration system with organic BSA media, already validated in land-based RAS, which does not generate microplastics, unlike many plastic-based alternatives.
|
The core technology is a stainless-steel biofiltration system with organic BSA media, already validated in land-based RAS, which does not generate microplastics, unlike many plastic-based alternatives.
|
||||||
The system is designed to be powered by offshore renewable energy and to enable circular economy pathways for recovered materials.
|
The system is designed to be powered by offshore renewable energy and to enable circular economy pathways for recovered materials.
|
||||||
The objective is to transform offshore aquaculture into a cleaner, lower-impact and more resilient activity, contributing to ocean protection while improving farm performance.",true,"I heard about the MOPC through the SUBMARINER Network’s communications and website, where the call was featured.",Received,https://drive.google.com/drive/folders/1jSB8rvtoNf5w8cRMbd4vV_7u2N-RKr6Z?usp=drive_link,
|
The objective is to transform offshore aquaculture into a cleaner, lower-impact and more resilient activity, contributing to ocean protection while improving farm performance.",true,"I heard about the MOPC through the SUBMARINER Network’s communications and website, where the call was featured.",Received,https://drive.google.com/drive/folders/1jSB8rvtoNf5w8cRMbd4vV_7u2N-RKr6Z?usp=drive_link,
|
||||||
,Emily Hannah Purnell,+4917644482080,Envoyer le message,emily.purnell@web.de,Seads,"Simona Töpfer, Matz Hüffner, Jonas Pfitzner, Leonie Schwarte, Malena Meyer","Münster, Rhénanie-du-Nord-Westphalie, Allemagne","Europe, Germany",University of Münster in Münster,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"We restore seagrass meadows at scale.
|
,Emily Hannah Purnell,+4917644482080,Envoyer le message,emily.purnell@web.de,Seads,"Simona Töpfer, Matz Hüffner, Jonas Pfitzner, Leonie Schwarte, Malena Meyer","Münster, Rhénanie-du-Nord-Westphalie, Allemagne","Europe, Germany",University of Münster in Münster,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"We restore seagrass meadows at scale.
|
||||||
We invented an autonomous, AI-powered seagrass seed harvester to overcome the main bottleneck in seagrass restoration: the lack of scalable and affordable seed supply. By enabling efficient seed harvesting, cultivation and replanting, we unlock large-scale seagrass restoration that was previously limited to small pilot projects. As a restoration service provider, we work with governments and companies to restore degraded marine habitats within regulated ecosystem compensation schemes. Our restored seagrass meadows store CO₂, protect coastlines from erosion, and bring biodiveristy back to our oceans.",false,We met Magnus Frohmann at the Enactus World Cup last year.,,,
|
We invented an autonomous, AI-powered seagrass seed harvester to overcome the main bottleneck in seagrass restoration: the lack of scalable and affordable seed supply. By enabling efficient seed harvesting, cultivation and replanting, we unlock large-scale seagrass restoration that was previously limited to small pilot projects. As a restoration service provider, we work with governments and companies to restore degraded marine habitats within regulated ecosystem compensation schemes. Our restored seagrass meadows store CO₂, protect coastlines from erosion, and bring biodiveristy back to our oceans.",false,We met Magnus Frohmann at the Enactus World Cup last year.,Received,https://drive.google.com/drive/folders/1fSLC7MjhdXkI3lHpl8Tf2WjCJCDKU09N?usp=drive_link,
|
||||||
,Maria Jose Castaño González,+573245654738,Envoyer le message,mjose.castano2020@gmail.com,SEMOCEA - Numerical Modeling Seedbed of the Ocean and the Atmosphere,María José Castaño González,"Turbo, Colombie",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-10-03,Mitigation of climate change and sea-level rise,"This project is based on the Ocean and Atmospheric Numerical Modeling Research Group and focuses on the use of numerical modeling to understand coastal and oceanic hydrodynamics influenced by river discharges. The core idea is to analyze the spatio-temporal variability of ocean circulation in coastal and estuarine systems, using the Gulf of Urabá (Colombia) as a case study.
|
,Maria Jose Castaño González,+573245654738,Envoyer le message,mjose.castano2020@gmail.com,SEMOCEA - Numerical Modeling Seedbed of the Ocean and the Atmosphere,María José Castaño González,"Turbo, Colombie",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-10-03,Mitigation of climate change and sea-level rise,"This project is based on the Ocean and Atmospheric Numerical Modeling Research Group and focuses on the use of numerical modeling to understand coastal and oceanic hydrodynamics influenced by river discharges. The core idea is to analyze the spatio-temporal variability of ocean circulation in coastal and estuarine systems, using the Gulf of Urabá (Colombia) as a case study.
|
||||||
|
|
||||||
The project aims to model and analyze five years of oceanographic conditions to evaluate how river inflows affect currents, circulation patterns, and coastal dynamics. By integrating numerical models, observational data, and hydrodynamic analysis, the project seeks to generate scientific knowledge that supports coastal management, environmental protection, and climate resilience in vulnerable coastal regions.
|
The project aims to model and analyze five years of oceanographic conditions to evaluate how river inflows affect currents, circulation patterns, and coastal dynamics. By integrating numerical models, observational data, and hydrodynamic analysis, the project seeks to generate scientific knowledge that supports coastal management, environmental protection, and climate resilience in vulnerable coastal regions.
|
||||||
@@ -655,11 +659,11 @@ The project aims to model and analyze five years of oceanographic conditions to
|
|||||||
Additionally, the project has a strong educational and capacity-building component, promoting the training of young researchers in numerical modeling of the ocean and atmosphere, and fostering the use of science-based tools for sustainable ocean protection and decision-making.",true,I heard about the MOPC through a community leaders’ group in my local area.,,,
|
Additionally, the project has a strong educational and capacity-building component, promoting the training of young researchers in numerical modeling of the ocean and atmosphere, and fostering the use of science-based tools for sustainable ocean protection and decision-making.",true,I heard about the MOPC through a community leaders’ group in my local area.,,,
|
||||||
,Athavan RASENTHIRAM,+33758298244,Envoyer le message,athavan.rasenthiram@builders-ingenieurs.fr,Investigation on modular artificial reefs to attenuate waves,Dominique Mouaze,"Caen, Normandie, France","Europe, France","University of Caen Normandy, Caen",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"enhancement, yet the influence of reef geometry on hydrodynamic performance remains insufficiently quantified. This study presents an experimental comparison of the hydrodynamic
|
,Athavan RASENTHIRAM,+33758298244,Envoyer le message,athavan.rasenthiram@builders-ingenieurs.fr,Investigation on modular artificial reefs to attenuate waves,Dominique Mouaze,"Caen, Normandie, France","Europe, France","University of Caen Normandy, Caen",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"enhancement, yet the influence of reef geometry on hydrodynamic performance remains insufficiently quantified. This study presents an experimental comparison of the hydrodynamic
|
||||||
performance of two modular artificial reef geometries, focusing on their ability to modify wave transmission, reflection, and energy dissipation. The investigation aims to support reef-inspired design strategies that balance coastal engineering efficiency with ecological functionality.",true,LinkedIn,Received,https://drive.google.com/drive/folders/1u9uWfPyoLZgqJRUwqoxfNLjgNtqCtCTg?usp=drive_link,
|
performance of two modular artificial reef geometries, focusing on their ability to modify wave transmission, reflection, and energy dissipation. The investigation aims to support reef-inspired design strategies that balance coastal engineering efficiency with ecological functionality.",true,LinkedIn,Received,https://drive.google.com/drive/folders/1u9uWfPyoLZgqJRUwqoxfNLjgNtqCtCTg?usp=drive_link,
|
||||||
,JHONATAN DANIEL PILLA ORTIZ,+593991978337,Envoyer le message,agroindustria_pilor@outlook.com,DEL CAMPO ECUADOR,JHONATAN DANIEL PILLA ORTIZ,"Galápagos, Équateur",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-02-08,Reduction of pollution (plastics chemicals noise light...),"Mi empresa se dedica a la producción y comercialización de PULPAS DE FRUTAS congeladas, pero en la industria de alimentos todo es con plásticos de un solo uso. Nuestro objetivo es empezar a usar plásticos biodegradables en toda nuestras presentaciones, ser mas amigables con el medio ambiente y reducir la basura que siempre llegan a nuestras costas, mas a nuestra frágil biodiversidad en Galápagos.",true,"Encuentro Empresarial Galápagos, donde nuestras autoridades nos comparten temas y organizan eventos para promocionar los emprendimientos de las Islas Galápagos",,,
|
,JHONATAN DANIEL PILLA ORTIZ,+593991978337,Envoyer le message,agroindustria_pilor@outlook.com,DEL CAMPO ECUADOR,JHONATAN DANIEL PILLA ORTIZ,"Galápagos, Équateur",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-02-08,Reduction of pollution (plastics chemicals noise light...),"Mi empresa se dedica a la producción y comercialización de PULPAS DE FRUTAS congeladas, pero en la industria de alimentos todo es con plásticos de un solo uso. Nuestro objetivo es empezar a usar plásticos biodegradables en toda nuestras presentaciones, ser mas amigables con el medio ambiente y reducir la basura que siempre llegan a nuestras costas, mas a nuestra frágil biodiversidad en Galápagos.",true,"Encuentro Empresarial Galápagos, donde nuestras autoridades nos comparten temas y organizan eventos para promocionar los emprendimientos de las Islas Galápagos",Received,https://drive.google.com/drive/folders/1XFF3LHXaTyjrwJYL9rNqcEDtwHxHLXjT?usp=drive_link,
|
||||||
,Rocío Paola Urtubia Oyarzún,+56954072346,Envoyer le message,rocio.urtubia@gmail.com,VIOBACT,"Pamela Torres, Bárbara Morales","Coquimbo, Chili",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-08-28,Sustainable fishing and aquaculture & blue food,"VioBact is a science-based marine biotechnology project developing natural probiotic solutions to improve the sustainability of marine fish larviculture.
|
,Rocío Paola Urtubia Oyarzún,+56954072346,Envoyer le message,rocio.urtubia@gmail.com,VIOBACT,"Pamela Torres, Bárbara Morales","Coquimbo, Chili",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-08-28,Sustainable fishing and aquaculture & blue food,"VioBact is a science-based marine biotechnology project developing natural probiotic solutions to improve the sustainability of marine fish larviculture.
|
||||||
The project aims to increase larval survival during first feeding, one of the most critical bottlenecks in marine aquaculture, by reducing harmful Vibrio bacteria in live feed systems (rotifers).
|
The project aims to increase larval survival during first feeding, one of the most critical bottlenecks in marine aquaculture, by reducing harmful Vibrio bacteria in live feed systems (rotifers).
|
||||||
VioBact’s probiotic formulations are based on beneficial marine bacteria that naturally outcompete pathogens, lowering the need for antibiotics and chemical treatments.
|
VioBact’s probiotic formulations are based on beneficial marine bacteria that naturally outcompete pathogens, lowering the need for antibiotics and chemical treatments.
|
||||||
By improving early survival and health of marine fish larvae, VioBact contributes to more efficient, resilient, and environmentally responsible aquaculture, supporting the production of sustainable blue food.",true,"I learned about the Monaco Ocean Protection Challenge through social media and international collaboration networks focused on ocean conservation, sustainability, and blue innovation.",,,
|
By improving early survival and health of marine fish larvae, VioBact contributes to more efficient, resilient, and environmentally responsible aquaculture, supporting the production of sustainable blue food.",true,"I learned about the Monaco Ocean Protection Challenge through social media and international collaboration networks focused on ocean conservation, sustainability, and blue innovation.",Received,https://drive.google.com/drive/folders/1FxSW9wBYdozAwvEX9gtSrKjLNf1Y4F4W?usp=drive_link,
|
||||||
,Christian Yowel Masagati,+255621379594,Envoyer le message,chrstnyowel@gmail.com,Youth in Marine Science Tanzania,"(Christian Masagati), (Rahma Sadiki), (Revival Herman), (Prosper Mahenge), (Zahra Mazoya)","Dar es Salam, Tanzanie","Africa, Tanzania",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-03-23,Capacity building for coastal communities,"Tanzania’s future is intrinsically linked to the Western Indian Ocean, yet a profound ""ocean blindness"" persists within the educational system and local communities. While the government
|
,Christian Yowel Masagati,+255621379594,Envoyer le message,chrstnyowel@gmail.com,Youth in Marine Science Tanzania,"(Christian Masagati), (Rahma Sadiki), (Revival Herman), (Prosper Mahenge), (Zahra Mazoya)","Dar es Salam, Tanzanie","Africa, Tanzania",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-03-23,Capacity building for coastal communities,"Tanzania’s future is intrinsically linked to the Western Indian Ocean, yet a profound ""ocean blindness"" persists within the educational system and local communities. While the government
|
||||||
pushes for a Blue Economy, there is a critical shortage of ocean-literate youth and educators to drive this vision. Current efforts are often limited by the small capacity of existing marine
|
pushes for a Blue Economy, there is a critical shortage of ocean-literate youth and educators to drive this vision. Current efforts are often limited by the small capacity of existing marine
|
||||||
organizations to reach the vast number of primary and secondary students in need of this education.
|
organizations to reach the vast number of primary and secondary students in need of this education.
|
||||||
@@ -678,11 +682,11 @@ Statement of Need:
|
|||||||
Despite Tanzania's 1,424-kilometer coastline, two critical gaps hinder sustainable blue economy development:
|
Despite Tanzania's 1,424-kilometer coastline, two critical gaps hinder sustainable blue economy development:
|
||||||
1. The",true,From the MOPC linkedin page,,,
|
1. The",true,From the MOPC linkedin page,,,
|
||||||
,Jan Maisenbacher,+41793951989,Envoyer le message,info@janmaisenbacher.com,Podcast Ocean Collaborations,"Jan Maisenbacher, Louise Cooke","Lucerne, Suisse","Europe, Switzerland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-02-16,Other,"Ocean Collaborations is an English podcast (online since Feb 2025; ~2 episodes/month) sharing global ocean conservation insights through conversations with personalities leading complex ocean regeneration projects - spotlighting practical collaboration across sectors to help ocean changemakers (and newcomers) act beyond filter bubbles and misinformation. Available on Substack (https://janmaisenbacher.substack.com/), Apple Podcast (https://podcasts.apple.com/us/podcast/ocean-collaborations/id1797113400), and Spotify (https://open.spotify.com/show/4a3iqQ3Grj2rrcUNpaaEbi).",false,Linkedin In like,Received,https://drive.google.com/drive/folders/1WsnVle3wnXJsK-fpO0aaNOHvQMO7lBBJ?usp=drive_link,
|
,Jan Maisenbacher,+41793951989,Envoyer le message,info@janmaisenbacher.com,Podcast Ocean Collaborations,"Jan Maisenbacher, Louise Cooke","Lucerne, Suisse","Europe, Switzerland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-02-16,Other,"Ocean Collaborations is an English podcast (online since Feb 2025; ~2 episodes/month) sharing global ocean conservation insights through conversations with personalities leading complex ocean regeneration projects - spotlighting practical collaboration across sectors to help ocean changemakers (and newcomers) act beyond filter bubbles and misinformation. Available on Substack (https://janmaisenbacher.substack.com/), Apple Podcast (https://podcasts.apple.com/us/podcast/ocean-collaborations/id1797113400), and Spotify (https://open.spotify.com/show/4a3iqQ3Grj2rrcUNpaaEbi).",false,Linkedin In like,Received,https://drive.google.com/drive/folders/1WsnVle3wnXJsK-fpO0aaNOHvQMO7lBBJ?usp=drive_link,
|
||||||
,Edwin Breganza,+639061214632,Envoyer le message,eobreganza@up.edu.ph,Conservation of Pemphis acidula,"Edmar Angeles, Victorino Sandoval and Mary Grace Breganza","Los Baños, Calabarzon, Philippines",Asia,"University of the Philippines at Los Banos, Philippines",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Blue Carbon,"This conservation project addresses the urgent need to prevent extinction of Camptostemon philippinensis by generating fundamental scientific knowledge on its reproductive biology, developing germination and propagation protocols, conducting GIS-based distribution mapping, and engaging coastal communities in conservation activities. The project will establish technical foundations for ex situ conservation and restoration programs while building local capacity through training programs and information campaigns, ultimately aiming to secure legal protection for Gapas-gapas habitat as a Protected Area. Beyond species conservation, the initiative contributes to maintaining critical ecosystem services including coastal protection, carbon sequestration, and nursery habitat for commercially important species, while fulfilling the Philippines' commitments under international biodiversity agreements and providing a replicable model for community-based endangered species recovery throughout Southeast Asia.",false,from Funds for NGOs website,,,
|
,Edwin Breganza,+639061214632,Envoyer le message,eobreganza@up.edu.ph,Conservation of Pemphis acidula,"Edmar Angeles, Victorino Sandoval and Mary Grace Breganza","Los Baños, Calabarzon, Philippines",Asia,"University of the Philippines at Los Banos, Philippines",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Blue Carbon,"This conservation project addresses the urgent need to prevent extinction of Camptostemon philippinensis by generating fundamental scientific knowledge on its reproductive biology, developing germination and propagation protocols, conducting GIS-based distribution mapping, and engaging coastal communities in conservation activities. The project will establish technical foundations for ex situ conservation and restoration programs while building local capacity through training programs and information campaigns, ultimately aiming to secure legal protection for Gapas-gapas habitat as a Protected Area. Beyond species conservation, the initiative contributes to maintaining critical ecosystem services including coastal protection, carbon sequestration, and nursery habitat for commercially important species, while fulfilling the Philippines' commitments under international biodiversity agreements and providing a replicable model for community-based endangered species recovery throughout Southeast Asia.",false,from Funds for NGOs website,Received,https://drive.google.com/drive/folders/1Pzom13qFnYslQVPoKRptL1lWSRykfwSL?usp=drive_link,
|
||||||
,Nasibu Mtambo,+254742051141,Envoyer le message,mtamboduke@gmail.com,Blue EcoponicX,"1. Sharon Shali, 2. Mohammed Athman 3. Terry Okwanyo 4. Mohamed Salim","Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-05-20,Reduction of pollution (plastics chemicals noise light...),"Blue EcoponicX is a climate-tech initiative that transforms marine plastic waste into smart hydroponic towers for urban food production. The project tackles two pressing challenges; ocean plastic pollution and urban food insecurity by converting waste into productive, sustainable farming systems. Using plastic recycling, 3D printing and sensor-based monitoring, the modular towers allow households, schools and small-scale urban farmers to grow fresh food in limited spaces while using far less water and energy than traditional agriculture.
|
,Nasibu Mtambo,+254742051141,Envoyer le message,mtamboduke@gmail.com,Blue EcoponicX,"1. Sharon Shali, 2. Mohammed Athman 3. Terry Okwanyo 4. Mohamed Salim","Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-05-20,Reduction of pollution (plastics chemicals noise light...),"Blue EcoponicX is a climate-tech initiative that transforms marine plastic waste into smart hydroponic towers for urban food production. The project tackles two pressing challenges; ocean plastic pollution and urban food insecurity by converting waste into productive, sustainable farming systems. Using plastic recycling, 3D printing and sensor-based monitoring, the modular towers allow households, schools and small-scale urban farmers to grow fresh food in limited spaces while using far less water and energy than traditional agriculture.
|
||||||
|
|
||||||
The project aims to reduce marine plastic waste, improve access to affordable fresh produce in cities and lower carbon emissions from food transportation. Blue EcoponicX also creates green jobs in recycling, manufacturing and urban farming while contributing to more climate-resilient, self-sufficient urban communities.",true,Through LinkedIn,Ignore,,
|
The project aims to reduce marine plastic waste, improve access to affordable fresh produce in cities and lower carbon emissions from food transportation. Blue EcoponicX also creates green jobs in recycling, manufacturing and urban farming while contributing to more climate-resilient, self-sufficient urban communities.",true,Through LinkedIn,Ignore,,
|
||||||
,Maarten Berkhout,+31637655680,Envoyer le message,m.berkhout@hollandmarineprojects.com,IceSalvage,Ewoud Visser,"Emmeloord, Flevoland, Pays-Bas","Europe, Netherland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-11-01,Reduction of pollution (plastics chemicals noise light...),Salvage of complex and potentially dangerous and harmful objects,true,via LinkedIn,,,
|
,Maarten Berkhout,+31637655680,Envoyer le message,m.berkhout@hollandmarineprojects.com,IceSalvage,Ewoud Visser,"Emmeloord, Flevoland, Pays-Bas","Europe, Netherland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-11-01,Reduction of pollution (plastics chemicals noise light...),Salvage of complex and potentially dangerous and harmful objects,true,via LinkedIn,Received,https://drive.google.com/drive/folders/1NZA0B66dYkmpEXo7m0DQpFLlWEWWe7Ys?usp=drive_link,
|
||||||
,Mathias Mondo,+237693514085,Envoyer le message,mathiasmondo35@gmail.com,Test of the website,"Team member 1, team pepbrrx","Yaoundé, Cameroun","Africa, Cameroun",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-07-07,,Ok,false,,Ignore,,
|
,Mathias Mondo,+237693514085,Envoyer le message,mathiasmondo35@gmail.com,Test of the website,"Team member 1, team pepbrrx","Yaoundé, Cameroun","Africa, Cameroun",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-07-07,,Ok,false,,Ignore,,
|
||||||
,Juan Esteban Perdomo Ortiz,+573024197126,Envoyer le message,estebanjperdomo@gmail.com,REDJODS,Maria Fernanda Montañez - Natalia Alexandra Barrera,"Bogota, Colombie",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2020-07-20,Mitigation of climate change and sea-level rise,"REDJODS (Red de Jóvenes para el Desarrollo Social) is a youth-led network focused on strengthening civic engagement, leadership, and social innovation among young people in Latin America.
|
,Juan Esteban Perdomo Ortiz,+573024197126,Envoyer le message,estebanjperdomo@gmail.com,REDJODS,Maria Fernanda Montañez - Natalia Alexandra Barrera,"Bogota, Colombie",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2020-07-20,Mitigation of climate change and sea-level rise,"REDJODS (Red de Jóvenes para el Desarrollo Social) is a youth-led network focused on strengthening civic engagement, leadership, and social innovation among young people in Latin America.
|
||||||
|
|
||||||
@@ -720,7 +724,7 @@ The Role of Mangroves: Why the mangroves of Laguna de las Ninfas and the area su
|
|||||||
|
|
||||||
Seabird Watching: The importance of boobies and frigatebirds as indicators of ocean health.
|
Seabird Watching: The importance of boobies and frigatebirds as indicators of ocean health.
|
||||||
|
|
||||||
4. Climate Change and Local Resilience: The El Niño Phenomenon: Education on why",true,REDES SOCIALES,,,
|
4. Climate Change and Local Resilience: The El Niño Phenomenon: Education on why",true,REDES SOCIALES,Received,https://drive.google.com/drive/folders/10Y3qbqomEJgeSFNtLv3VWECVyLmTjM3_?usp=drive_link,
|
||||||
,Achraf Bleili,+21651834344,Envoyer le message,achrefbleili@gmail.com,EcoRecy,Achraf Bleili,"Bizerte, Tunisie","Africa, Tunisia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-01,Reduction of pollution (plastics chemicals noise light...),"EcoRecy is a greentech startup tackling plastic waste through automated recycling machines combined with a digital rewards platform. The idea is to make recycling simple, accessible, and incentivized for citizens.",true,Google,Received,https://drive.google.com/drive/folders/1Qm6ZkKcHHKnL0lDM_iCyLa1Bsii1Lks5?usp=drive_link,
|
,Achraf Bleili,+21651834344,Envoyer le message,achrefbleili@gmail.com,EcoRecy,Achraf Bleili,"Bizerte, Tunisie","Africa, Tunisia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-01,Reduction of pollution (plastics chemicals noise light...),"EcoRecy is a greentech startup tackling plastic waste through automated recycling machines combined with a digital rewards platform. The idea is to make recycling simple, accessible, and incentivized for citizens.",true,Google,Received,https://drive.google.com/drive/folders/1Qm6ZkKcHHKnL0lDM_iCyLa1Bsii1Lks5?usp=drive_link,
|
||||||
,Douglas Bertin,+33601185122,Envoyer le message,dbertin@calx-sea.com,CalX,Quentin GERME and Tematuanui a Tehei HANTZ,"Gujan-Mestras, Nouvelle-Aquitaine, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-11-19,Restoration of marine habitats & ecosystems,"CalX is developing Oysteria X, a 100% natural, low-carbon marine construction material designed to replace conventional marine concrete.
|
,Douglas Bertin,+33601185122,Envoyer le message,dbertin@calx-sea.com,CalX,Quentin GERME and Tematuanui a Tehei HANTZ,"Gujan-Mestras, Nouvelle-Aquitaine, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-11-19,Restoration of marine habitats & ecosystems,"CalX is developing Oysteria X, a 100% natural, low-carbon marine construction material designed to replace conventional marine concrete.
|
||||||
Developed in partnership with IFREMER and CNRS, Oysteria X is a structurally engineered material that complies with marine infrastructure standards. It is based on recycled oyster shells used as a direct substitute for sand and traditional mineral aggregates, combined with natural mineral binders without clinker.
|
Developed in partnership with IFREMER and CNRS, Oysteria X is a structurally engineered material that complies with marine infrastructure standards. It is based on recycled oyster shells used as a direct substitute for sand and traditional mineral aggregates, combined with natural mineral binders without clinker.
|
||||||
@@ -732,7 +736,7 @@ The material has undergone in situ marine testing to validate its durability, ec
|
|||||||
We are giving back to the sea what it has given us.",true,On LinkedIn and through our personal research,Received,https://drive.google.com/drive/folders/1T5ONe70hze061-C7AOnaPLBlN6AYpPfH?usp=drive_link,
|
We are giving back to the sea what it has given us.",true,On LinkedIn and through our personal research,Received,https://drive.google.com/drive/folders/1T5ONe70hze061-C7AOnaPLBlN6AYpPfH?usp=drive_link,
|
||||||
,Mayra Alejandra Cuero Gonzalez,+573186629665,Envoyer le message,macuerogonzalez@gmail.com,Roots of the Sea: Ancestral Tourism for Ocean Protection in the Colombian Pacific,Waldis Natalia Conrado Gamboa,"Buenaventura, Colombie",South America,"Community-based Project – Fundación Cultura Ancestral de Juanchaco, Colombia",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"Roots of the Sea is a community-led ancestral tourism project developed with the Fundación Cultura Ancestral de Juanchaco on Colombia’s Pacific coast.
|
,Mayra Alejandra Cuero Gonzalez,+573186629665,Envoyer le message,macuerogonzalez@gmail.com,Roots of the Sea: Ancestral Tourism for Ocean Protection in the Colombian Pacific,Waldis Natalia Conrado Gamboa,"Buenaventura, Colombie",South America,"Community-based Project – Fundación Cultura Ancestral de Juanchaco, Colombia",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"Roots of the Sea is a community-led ancestral tourism project developed with the Fundación Cultura Ancestral de Juanchaco on Colombia’s Pacific coast.
|
||||||
The project empowers coastal communities to protect marine and coastal ecosystems by transforming ancestral knowledge, cultural practices, and responsible tourism into a sustainable source of income.
|
The project empowers coastal communities to protect marine and coastal ecosystems by transforming ancestral knowledge, cultural practices, and responsible tourism into a sustainable source of income.
|
||||||
Its main objectives are to strengthen local capacities, raise ocean awareness among visitors, reduce pollution on beaches and coastal areas, and promote long-term ocean conservation through community leadership and cultural transmission.",true,Through LinkedIn and international professional networks focused on ocean protection and sustainable innovation.,,,
|
Its main objectives are to strengthen local capacities, raise ocean awareness among visitors, reduce pollution on beaches and coastal areas, and promote long-term ocean conservation through community leadership and cultural transmission.",true,Through LinkedIn and international professional networks focused on ocean protection and sustainable innovation.,Received,https://drive.google.com/drive/folders/1KrDCfgbjDtifUR9iOkDFz20Is8djoqZu?usp=drive_link,
|
||||||
,Anthony Duxell Malle,+237675306895,Envoyer le message,anthonymalle3@gmail.com,Coastal Communities for Grassroots Mangrove Restoration and Livelihoods,"Tabang David , Chiamoh Blandine, Ebune Jacques",Cameroun,"Africa, Cameroun","University of Buea , Cameroon.",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"This project aims to scale restoration by rehabilitating 3 hectares of mangroves through planting 5,000 seedlings with active participation from three local communities ( Ekange 3, Motombolombo , Ijaw-Mabeta, supported by site surveys, GIS web maps and geographic data monitoring. It also integrates sustainable agriculture by empowering 20 local farmers to propagate 2,000 plantain and 1,000 cocoyam seedlings using natural compost, enhancing food security and reducing mangrove deforestation for firewood.
|
,Anthony Duxell Malle,+237675306895,Envoyer le message,anthonymalle3@gmail.com,Coastal Communities for Grassroots Mangrove Restoration and Livelihoods,"Tabang David , Chiamoh Blandine, Ebune Jacques",Cameroun,"Africa, Cameroun","University of Buea , Cameroon.",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"This project aims to scale restoration by rehabilitating 3 hectares of mangroves through planting 5,000 seedlings with active participation from three local communities ( Ekange 3, Motombolombo , Ijaw-Mabeta, supported by site surveys, GIS web maps and geographic data monitoring. It also integrates sustainable agriculture by empowering 20 local farmers to propagate 2,000 plantain and 1,000 cocoyam seedlings using natural compost, enhancing food security and reducing mangrove deforestation for firewood.
|
||||||
The initiative targets multiple social and environmental benefits including increased farmer income, improved nutrition, climate-resilient agricultural systems, and strengthened local institutions within Tiko and Limbe 3 municipalities. A long-term vision includes creating a mangrove ecopark (Marine Protected Area) to protect biodiversity and support community livelihoods.",true,,,,
|
The initiative targets multiple social and environmental benefits including increased farmer income, improved nutrition, climate-resilient agricultural systems, and strengthened local institutions within Tiko and Limbe 3 municipalities. A long-term vision includes creating a mangrove ecopark (Marine Protected Area) to protect biodiversity and support community livelihoods.",true,,,,
|
||||||
,Tarwita Phetchaiyo,+66947484964,Envoyer le message,tarwita550@gmail.com,Mute Matter,Tarwita Phetchaiyo,"Phetchabun, 67000 Thailande",Asia,"Phetchabun Rajabhat University, Thailand",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"Mute Matter: Turning Ocean's #1 Pollution into Profitable Acoustic Solution - Cigarette butts are the ocean's #1 microplastic polluter. Mute Matter stops this toxic waste at the source by upcycling it into premium acoustic panels. We transform hazardous litter into high-value products for the green construction market, cleaning the ocean while building a profitable circular economy",true,Internet Search,,,
|
,Tarwita Phetchaiyo,+66947484964,Envoyer le message,tarwita550@gmail.com,Mute Matter,Tarwita Phetchaiyo,"Phetchabun, 67000 Thailande",Asia,"Phetchabun Rajabhat University, Thailand",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"Mute Matter: Turning Ocean's #1 Pollution into Profitable Acoustic Solution - Cigarette butts are the ocean's #1 microplastic polluter. Mute Matter stops this toxic waste at the source by upcycling it into premium acoustic panels. We transform hazardous litter into high-value products for the green construction market, cleaning the ocean while building a profitable circular economy",true,Internet Search,,,
|
||||||
@@ -745,7 +749,7 @@ Piloted in the Caribbean region of Quintana Roo, Mexico, SARGASAFE ENERGY combin
|
|||||||
|
|
||||||
By enabling traceability, impact measurement, and ESG-aligned reporting, Bluezone allows corporate buyers to demonstrably reduce Scope 3 emissions while embedding sustainability into their procurement decisions.
|
By enabling traceability, impact measurement, and ESG-aligned reporting, Bluezone allows corporate buyers to demonstrably reduce Scope 3 emissions while embedding sustainability into their procurement decisions.
|
||||||
|
|
||||||
Bluezone’s long-term vision is to become the go-to global platform for ocean-positive procurement, aligning economic incentives with marine regeneration. By redirecting capital toward low-carbon ocean-based solutions, Bluezone helps restore ecosystems, reduce emissions, and build resilient coastal economies, turning the ocean from a victim of climate change into a driver of climate solutions.",true,Word of mouth,,,
|
Bluezone’s long-term vision is to become the go-to global platform for ocean-positive procurement, aligning economic incentives with marine regeneration. By redirecting capital toward low-carbon ocean-based solutions, Bluezone helps restore ecosystems, reduce emissions, and build resilient coastal economies, turning the ocean from a victim of climate change into a driver of climate solutions.",true,Word of mouth,Received,https://drive.google.com/drive/folders/19xR5_x16lNiaOjBi4U5uhZm2WuiCdoom?usp=drive_link,
|
||||||
,Ayman El Arroubi,+212619179409,Envoyer le message,aymanelarroubi@gmail.com,Ecoplast,"Ayman El Arroubi ( co-founder , CEO)","Rabat, Rabat - Salé - Kénitra, Maroc","Africa, Morocco",École Nationale supérieure d’Arts et Métiers de Rabat,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"The Core Idea A biodegradable, algae-based alternative to single-use marine plastics (like fishing gear, poly-bags, and coastal packaging) that dissolves harmlessly if lost at sea, or serves as ""fish food"" rather than microplastic pollution.",true,Alumni recommandation,Received,https://drive.google.com/drive/folders/1ZUabWuOlOO_xdQIwpE0GQ3AdLsg0ngek?usp=drive_link,
|
,Ayman El Arroubi,+212619179409,Envoyer le message,aymanelarroubi@gmail.com,Ecoplast,"Ayman El Arroubi ( co-founder , CEO)","Rabat, Rabat - Salé - Kénitra, Maroc","Africa, Morocco",École Nationale supérieure d’Arts et Métiers de Rabat,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"The Core Idea A biodegradable, algae-based alternative to single-use marine plastics (like fishing gear, poly-bags, and coastal packaging) that dissolves harmlessly if lost at sea, or serves as ""fish food"" rather than microplastic pollution.",true,Alumni recommandation,Received,https://drive.google.com/drive/folders/1ZUabWuOlOO_xdQIwpE0GQ3AdLsg0ngek?usp=drive_link,
|
||||||
,Tabakova,+33749812907,Envoyer le message,maria.tabakova.06@gmail.com,Tideroom.ai,"Olivier Guillo, Jeanne Guillo, Olivier Bettati",Ville de Valbonne Sophia Antipolis,"Europe, France",International University of Monaco,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"TideRoom est une plateforme numérique (""GPS du financement climat"") qui met en relation les villes côtières africaines avec les bailleurs de fonds climat internationaux. Elle guide les collectivités de l'identification de leurs besoins jusqu'à l'obtention des financements et la mise en œuvre de solutions de résilience côtière. Le pilote cible deux villes : Dakhla (Maroc) et Grand-Bassam (Côte d'Ivoire), toutes deux vulnérables au changement climatique (érosion, montée des eaux) et dont les économies locales (pêche, tourisme) dépendent directement de la santé des écosystèmes côtiers.
|
,Tabakova,+33749812907,Envoyer le message,maria.tabakova.06@gmail.com,Tideroom.ai,"Olivier Guillo, Jeanne Guillo, Olivier Bettati",Ville de Valbonne Sophia Antipolis,"Europe, France",International University of Monaco,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"TideRoom est une plateforme numérique (""GPS du financement climat"") qui met en relation les villes côtières africaines avec les bailleurs de fonds climat internationaux. Elle guide les collectivités de l'identification de leurs besoins jusqu'à l'obtention des financements et la mise en œuvre de solutions de résilience côtière. Le pilote cible deux villes : Dakhla (Maroc) et Grand-Bassam (Côte d'Ivoire), toutes deux vulnérables au changement climatique (érosion, montée des eaux) et dont les économies locales (pêche, tourisme) dépendent directement de la santé des écosystèmes côtiers.
|
||||||
Objectifs à 36 mois :
|
Objectifs à 36 mois :
|
||||||
@@ -764,13 +768,13 @@ The project’s objective is to enable the decarbonisation of fast maritime tran
|
|||||||
Beyond the technological development, Whisper EF aims to contribute to the emergence of sustainable, high-performance maritime mobility solutions in Europe and internationally.",false,"We learned about the MOPC through our previous participation, having applied to the programme last year.",Received,https://drive.google.com/drive/folders/1L1Gaac4QLxxXYfcUD3wsqM_mTo5vrpss?usp=drive_link,
|
Beyond the technological development, Whisper EF aims to contribute to the emergence of sustainable, high-performance maritime mobility solutions in Europe and internationally.",false,"We learned about the MOPC through our previous participation, having applied to the programme last year.",Received,https://drive.google.com/drive/folders/1L1Gaac4QLxxXYfcUD3wsqM_mTo5vrpss?usp=drive_link,
|
||||||
,Amy Dzikowski,+33652328060,Envoyer le message,amy.dzikowski@monaco.edu,PEARL (Protective Ecosystem AUV for Reef Longevity),"Amy Dzikowski, Nathalie Falck, Anne Marie Medema, Rebecca Sidalova, Melissa Chera","Pereybere, Grand Baie, Maurice","Africa, Mauritius","International University of Monaco, Monaco",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"Our project is the development of the Protective Ecosystem AUV for Reef Longevity (PEARL), an autonomous underwater vehicle (AUV) designed to locate and capture invasive lionfish. PEARL uses a precision system to identify the invasive lionfish, enabling high-yield targeted removal without damaging reefs or harming native species. Unlike human spearfishers, PEARL is not constrained by dive time or depth limitations and can operate continuously across the full depth range of known lionfish habitats. AI-driven detection systems enhance identification accuracy and ensure an environmentally responsible operation.
|
,Amy Dzikowski,+33652328060,Envoyer le message,amy.dzikowski@monaco.edu,PEARL (Protective Ecosystem AUV for Reef Longevity),"Amy Dzikowski, Nathalie Falck, Anne Marie Medema, Rebecca Sidalova, Melissa Chera","Pereybere, Grand Baie, Maurice","Africa, Mauritius","International University of Monaco, Monaco",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"Our project is the development of the Protective Ecosystem AUV for Reef Longevity (PEARL), an autonomous underwater vehicle (AUV) designed to locate and capture invasive lionfish. PEARL uses a precision system to identify the invasive lionfish, enabling high-yield targeted removal without damaging reefs or harming native species. Unlike human spearfishers, PEARL is not constrained by dive time or depth limitations and can operate continuously across the full depth range of known lionfish habitats. AI-driven detection systems enhance identification accuracy and ensure an environmentally responsible operation.
|
||||||
|
|
||||||
The primary objective of PEARL is to halt the spread of invasive lionfish in the Mediterranean Sea and beyond, protecting reef ecosystems and ocean health. To accomplish this, our key objectives are to reduce invasive lionfish populations, prevent further geographic expansion, and enable sustainable revenue through the use of lionfish byproducts. Incorporating economic viability alongside ecological impact is essential to ensuring long-term adoption and effective large-scale intervention.",true,Prof. Elena Tavella at the International University of Monaco,,,
|
The primary objective of PEARL is to halt the spread of invasive lionfish in the Mediterranean Sea and beyond, protecting reef ecosystems and ocean health. To accomplish this, our key objectives are to reduce invasive lionfish populations, prevent further geographic expansion, and enable sustainable revenue through the use of lionfish byproducts. Incorporating economic viability alongside ecological impact is essential to ensuring long-term adoption and effective large-scale intervention.",true,Prof. Elena Tavella at the International University of Monaco,Received,https://drive.google.com/drive/folders/1DD6FeWZxM0BsY4KDQavdb5yHJCbuE7_2?usp=drive_link,
|
||||||
,Kelvish Duval,+33768126798,Envoyer le message,kelvish.duval@efrei.net,Efrei grp 4,"Yassine Tenzekhti, Theotim Djelassi, El-Assad Said, Lucas Rinaudo","Villejuif, Île-de-France, France","Europe, France","Efrei, Villejuif",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,Creation of artificial habitats with adapted materials,false,No,,,
|
,Kelvish Duval,+33768126798,Envoyer le message,kelvish.duval@efrei.net,Efrei grp 4,"Yassine Tenzekhti, Theotim Djelassi, El-Assad Said, Lucas Rinaudo","Villejuif, Île-de-France, France","Europe, France","Efrei, Villejuif",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,Creation of artificial habitats with adapted materials,false,No,,,
|
||||||
,Thamires Pontes,+5511992001931,Envoyer le message,thamires@phycolabs.com,Phycolabs,"Thamires Pontes, Wolgrand Neto, Diego Nunes, Gustavo Gonçalves","Sao Paulo, SP, Brésil",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-06-14,Reduction of pollution (plastics chemicals noise light...),"Phycolabs is a Brazilian biomaterials startup decarbonizing fashion with textile fibers made entirely from cultivated seaweed. Our SeaweedFibers replace traditional and man-made fibers that drive CO₂ emissions and marine microplastic pollution at the source, using a fast-growing ocean feedstock that requires no land, pesticides, or freshwater.
|
,Thamires Pontes,+5511992001931,Envoyer le message,thamires@phycolabs.com,Phycolabs,"Thamires Pontes, Wolgrand Neto, Diego Nunes, Gustavo Gonçalves","Sao Paulo, SP, Brésil",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-06-14,Reduction of pollution (plastics chemicals noise light...),"Phycolabs is a Brazilian biomaterials startup decarbonizing fashion with textile fibers made entirely from cultivated seaweed. Our SeaweedFibers replace traditional and man-made fibers that drive CO₂ emissions and marine microplastic pollution at the source, using a fast-growing ocean feedstock that requires no land, pesticides, or freshwater.
|
||||||
|
|
||||||
Our patent-pending process produces continuous seaweed filaments compatible with existing spinning and weaving machinery, enabling global brands to adopt ocean-positive materials at scale while building regenerative seaweed farming value chains, contributing to marine ecosystem restoration, and strengthening coastal livelihoods.",true,We were semifinalist last year and I would like to try again in 2026 :)),,,
|
Our patent-pending process produces continuous seaweed filaments compatible with existing spinning and weaving machinery, enabling global brands to adopt ocean-positive materials at scale while building regenerative seaweed farming value chains, contributing to marine ecosystem restoration, and strengthening coastal livelihoods.",true,We were semifinalist last year and I would like to try again in 2026 :)),Received,https://drive.google.com/drive/folders/1fqJe8wPVdDc0_yICZP1jGV8KB5HvWJSe?usp=drive_link,
|
||||||
,Davide Balbi,+393318344974,Envoyer le message,db@mondomigliore.eu,C.A.S.A. Marine Loop,"Davide Balbi, Santino Filippeddu, Gianluca Del Vecchio","Arzachena, Sardaigne, Italie","Europe, Italia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-09-19,Sustainable fishing and aquaculture & blue food,"C.A.S.A. Marine Loop is a modular retrofit solution for offshore fish cages, designed to operate around, inside and below cages. It aspirates and treats water to intercept solid waste and organic loads that affect fish welfare and surrounding marine areas. The core technology is a stainless-steel biofiltration system with organic BSA media, already validated in land-based RAS, designed not to generate microplastics compared to plastic-based alternatives. The system is conceived to be powered by offshore renewable energy and to enable circular economy pathways for recovered materials. Our objective is to make offshore aquaculture cleaner, more resilient and ocean-positive while improving farm performance.",true,Newsletter,Doublon,,
|
,Davide Balbi,+393318344974,Envoyer le message,db@mondomigliore.eu,C.A.S.A. Marine Loop,"Davide Balbi, Santino Filippeddu, Gianluca Del Vecchio","Arzachena, Sardaigne, Italie","Europe, Italia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-09-19,Sustainable fishing and aquaculture & blue food,"C.A.S.A. Marine Loop is a modular retrofit solution for offshore fish cages, designed to operate around, inside and below cages. It aspirates and treats water to intercept solid waste and organic loads that affect fish welfare and surrounding marine areas. The core technology is a stainless-steel biofiltration system with organic BSA media, already validated in land-based RAS, designed not to generate microplastics compared to plastic-based alternatives. The system is conceived to be powered by offshore renewable energy and to enable circular economy pathways for recovered materials. Our objective is to make offshore aquaculture cleaner, more resilient and ocean-positive while improving farm performance.",true,Newsletter,Doublon,,
|
||||||
,Juan Carlos Saldaña Guerrero,+51932751524,Envoyer le message,juan.carlos.saldana.guerrero@gmail.com,Carlos Biologist SG Group,"Carmen Nallelí Saldaña Guerrero, Elizabeth Bridgit Saldaña Guerrero","Ica, Peru",South America,ESNECA Business School,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Other,Laboratorio de Biología Marina,true,LinkedIn,,,
|
,Juan Carlos Saldaña Guerrero,+51932751524,Envoyer le message,juan.carlos.saldana.guerrero@gmail.com,Carlos Biologist SG Group,"Carmen Nallelí Saldaña Guerrero, Elizabeth Bridgit Saldaña Guerrero","Ica, Peru",South America,ESNECA Business School,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Other,Laboratorio de Biología Marina,true,LinkedIn,Received,https://drive.google.com/drive/folders/11vBtdjYek8qMUF06RySGJkhbY34zYpBg?usp=drive_link,
|
||||||
,Lautaro Girones,+541151355710,Envoyer le message,lautaro.girones@uns.edu.ar,HydroTrace,Lautaro Girones; Andres Hugo Arias; Rosario Corradini; Rocio Luciana Bray; Alejandro Jose Vitale,"Bahía Blanca, Buenos Aires, Argentine",South America,"Universidad Nacional del Sur, Bahía Blanca, Argentina",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"HydroTrace is an environmental monitoring technology platform designed to reveal hydrophobic chemical contaminants that are often missed by conventional water monitoring methods. Many industrial pollutants, including hydrocarbons, PAHs and selected unintentional persistent organic pollutants (UPOPs), are not fully captured by dissolved water measurements, leading to underestimation of real environmental exposure and risks to marine ecosystems and ocean health.
|
,Lautaro Girones,+541151355710,Envoyer le message,lautaro.girones@uns.edu.ar,HydroTrace,Lautaro Girones; Andres Hugo Arias; Rosario Corradini; Rocio Luciana Bray; Alejandro Jose Vitale,"Bahía Blanca, Buenos Aires, Argentine",South America,"Universidad Nacional del Sur, Bahía Blanca, Argentina",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"HydroTrace is an environmental monitoring technology platform designed to reveal hydrophobic chemical contaminants that are often missed by conventional water monitoring methods. Many industrial pollutants, including hydrocarbons, PAHs and selected unintentional persistent organic pollutants (UPOPs), are not fully captured by dissolved water measurements, leading to underestimation of real environmental exposure and risks to marine ecosystems and ocean health.
|
||||||
|
|
||||||
HydroTrace uses standardized passive polymer-based sensors combined with analytical workflows and exposure interpretation tools to measure time-integrated chemical exposure across aquatic environments, from industrial discharges and rivers to estuaries, ports and offshore marine waters.
|
HydroTrace uses standardized passive polymer-based sensors combined with analytical workflows and exposure interpretation tools to measure time-integrated chemical exposure across aquatic environments, from industrial discharges and rivers to estuaries, ports and offshore marine waters.
|
||||||
@@ -779,19 +783,19 @@ By detecting hidden chemical exposure pathways along the land-to-ocean continuum
|
|||||||
|
|
||||||
The technology is currently being refined through pilot monitoring applications in industrial coastal environments, demonstrating technical feasibility under real-world conditions. Initial applications focus on hydrophobic hydrocarbon fractions (C10–C40), PAHs and selected hydrophobic industrial byproducts. Future development includes evaluation of additional hydrophobic industrial and combustion-related contaminants depending on environmental behavior and monitoring needs.
|
The technology is currently being refined through pilot monitoring applications in industrial coastal environments, demonstrating technical feasibility under real-world conditions. Initial applications focus on hydrophobic hydrocarbon fractions (C10–C40), PAHs and selected hydrophobic industrial byproducts. Future development includes evaluation of additional hydrophobic industrial and combustion-related contaminants depending on environmental behavior and monitoring needs.
|
||||||
|
|
||||||
HydroTrace is designed to complement existing water monitoring technologies by targeting the hydrophobic contaminant fraction that is often under-characterized in routine monitoring programs. The platform combines standardized deployment hardware, recurring replacement kits, laboratory analysis and exposure intelligence reporting, enabling scalable, standardize",true,Through a colleague working in the ocean and environmental sector,,,
|
HydroTrace is designed to complement existing water monitoring technologies by targeting the hydrophobic contaminant fraction that is often under-characterized in routine monitoring programs. The platform combines standardized deployment hardware, recurring replacement kits, laboratory analysis and exposure intelligence reporting, enabling scalable, standardize",true,Through a colleague working in the ocean and environmental sector,Received,https://drive.google.com/drive/folders/18VZom2ZxXwIrtuWWPwcj-k_YInxw3HV7?usp=drive_link,
|
||||||
,Sahil,+917488447394,Envoyer le message,sahilkumar8176xd@gmail.com,BlueVault,"Sahil Kumar , Ankit Kumar","Jehanabad, Bihar, Inde",Asia,"Magadh University,Bihar",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Blue Carbon,"BlueVault is an autonomous ""Proof-of-Ecosystem"" protocol. We deploy swarms of low-cost micro-AUVs to verify Blue Carbon assets (seagrass/reefs) with millimeter precision.
|
,Sahil,+917488447394,Envoyer le message,sahilkumar8176xd@gmail.com,BlueVault,"Sahil Kumar , Ankit Kumar","Jehanabad, Bihar, Inde",Asia,"Magadh University,Bihar",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Blue Carbon,"BlueVault is an autonomous ""Proof-of-Ecosystem"" protocol. We deploy swarms of low-cost micro-AUVs to verify Blue Carbon assets (seagrass/reefs) with millimeter precision.
|
||||||
|
|
||||||
Objective: To unlock the $50B ocean finance market by replacing expensive human divers with scalable, audit-grade data.
|
Objective: To unlock the $50B ocean finance market by replacing expensive human divers with scalable, audit-grade data.
|
||||||
|
|
||||||
Tech: Our proprietary C++ Edge-AI algorithms calculate biomass volume in real-time, providing the ""trust layer"" banks need to invest in ocean restoration without fear of greenwashing.",true,,Received,https://drive.google.com/drive/folders/11x14DmyXxxi8EaLG13q31QvHXYRUPAvN?usp=drive_link,
|
Tech: Our proprietary C++ Edge-AI algorithms calculate biomass volume in real-time, providing the ""trust layer"" banks need to invest in ocean restoration without fear of greenwashing.",true,,Received,https://drive.google.com/drive/folders/11x14DmyXxxi8EaLG13q31QvHXYRUPAvN?usp=drive_link,
|
||||||
,S KAVIYA,+919042979984,Envoyer le message,sec23ec232@sairamtap.edu.in,ROBOVAC,ABIBA FATHIMA A & SIVASHANKER G,Inde,Asia,"SRI SAIRAM ENGINEERING COLLEGE , WEST TAMBARAM, CHENNAI,TAMIL NADU, INDIA",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"This venture delivers a scalable, semi-autonomous solution for water surface cleanup by combining AI-based visual detection with geospatial analytics. The system identifies and geo-tags floating plastic waste, converting raw detections into actionable pollution hotspot intelligence for targeted, cost-efficient cleanup operations. Designed with a modular hardware architecture and cloud-enabled analytics, the platform enables data-driven, repeatable deployments for municipalities, environmental agencies, and industrial water-body operators, reducing manual effort while improving operational efficiency and environmental impact.",true,I learned about the MOPC through institutional communications and peer networks within the student innovation and project development community. I also came across it via online announcements highlighting opportunities for showcasing interdisciplinary projects.,,,
|
,S KAVIYA,+919042979984,Envoyer le message,sec23ec232@sairamtap.edu.in,ROBOVAC,ABIBA FATHIMA A & SIVASHANKER G,Inde,Asia,"SRI SAIRAM ENGINEERING COLLEGE , WEST TAMBARAM, CHENNAI,TAMIL NADU, INDIA",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"This venture delivers a scalable, semi-autonomous solution for water surface cleanup by combining AI-based visual detection with geospatial analytics. The system identifies and geo-tags floating plastic waste, converting raw detections into actionable pollution hotspot intelligence for targeted, cost-efficient cleanup operations. Designed with a modular hardware architecture and cloud-enabled analytics, the platform enables data-driven, repeatable deployments for municipalities, environmental agencies, and industrial water-body operators, reducing manual effort while improving operational efficiency and environmental impact.",true,I learned about the MOPC through institutional communications and peer networks within the student innovation and project development community. I also came across it via online announcements highlighting opportunities for showcasing interdisciplinary projects.,Doublon,,
|
||||||
,S KAVIYA,+919042979984,Envoyer le message,sec23ec232@sairamtap.edu.in,NAUTILUS NEXUS,ABIBA FATHIMA A / SIVAHANKER G,Inde,Asia,"SRI SAIRAM ENGINEERING COLLEGE, WEST TAMBARAM , CHENNAI , TAMIL NADU , INDIA",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"This venture delivers a scalable, semi-autonomous solution for water surface cleanup by combining AI-based visual detection with geospatial analytics. The system identifies and geo-tags floating plastic waste, converting raw detections into actionable pollution hotspot intelligence for targeted, cost-efficient cleanup operations. Designed with a modular hardware architecture and cloud-enabled analytics, the platform enables data-driven, repeatable deployments for municipalities, environmental agencies, and industrial waterbody operators, reducing manual effort while improving operational efficiency and environmental impact.",true,I learned about the MOPC through institutional communications and peer networks within the student innovation and project development community. I also came across it via online announcements highlighting opportunities for showcasing interdisciplinary projects.,Doublon,,
|
,S KAVIYA,+919042979984,Envoyer le message,sec23ec232@sairamtap.edu.in,NAUTILUS NEXUS,ABIBA FATHIMA A / SIVAHANKER G,Inde,Asia,"SRI SAIRAM ENGINEERING COLLEGE, WEST TAMBARAM , CHENNAI , TAMIL NADU , INDIA",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"This venture delivers a scalable, semi-autonomous solution for water surface cleanup by combining AI-based visual detection with geospatial analytics. The system identifies and geo-tags floating plastic waste, converting raw detections into actionable pollution hotspot intelligence for targeted, cost-efficient cleanup operations. Designed with a modular hardware architecture and cloud-enabled analytics, the platform enables data-driven, repeatable deployments for municipalities, environmental agencies, and industrial waterbody operators, reducing manual effort while improving operational efficiency and environmental impact.",true,I learned about the MOPC through institutional communications and peer networks within the student innovation and project development community. I also came across it via online announcements highlighting opportunities for showcasing interdisciplinary projects.,Received,https://drive.google.com/drive/folders/1nXrjERUGyNhA1T0A7o_uqmpvd1jree60?usp=drive_link,
|
||||||
,Greta Puschmann,+33674308280,Envoyer le message,greta.puschmann@monaco.edu,BlueSupply ESG,Tom Döscher,"Monaco, Monaco","Europe, Monaco","International University of Monaco, Monaco",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Other,"BlueTrace ESG is a digital ESG intelligence platform designed to bring transparency to ocean-based supply chains.
|
,Greta Puschmann,+33674308280,Envoyer le message,greta.puschmann@monaco.edu,BlueSupply ESG,Tom Döscher,"Monaco, Monaco","Europe, Monaco","International University of Monaco, Monaco",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Other,"BlueTrace ESG is a digital ESG intelligence platform designed to bring transparency to ocean-based supply chains.
|
||||||
The project helps companies and investors identify, measure and manage their marine environmental and social impact across sectors such as fisheries, aquaculture, fashion and maritime logistics.
|
The project helps companies and investors identify, measure and manage their marine environmental and social impact across sectors such as fisheries, aquaculture, fashion and maritime logistics.
|
||||||
By combining supply-chain data, ocean-specific ESG indicators and regulatory requirements (e.g. CSRD), BlueTrace ESG enables reliable reporting, risk assessment and decision-making.
|
By combining supply-chain data, ocean-specific ESG indicators and regulatory requirements (e.g. CSRD), BlueTrace ESG enables reliable reporting, risk assessment and decision-making.
|
||||||
Its objective is to make ocean impact measurable and actionable - and to redirect capital and business practices toward more sustainable, ocean-positive value chains.",true,From my university,,,
|
Its objective is to make ocean impact measurable and actionable - and to redirect capital and business practices toward more sustainable, ocean-positive value chains.",true,From my university,,,
|
||||||
,TEMANO,+33746225522,Envoyer le message,clara.honore@temano.fr,TEMANO,Quentin DEMOULIN,"Ploemeur, Bretagne, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-01-17,Technology & innovations,"Development of anchors with minimal environmental impact, intended for recreational boating and renewable energy on floating bases",false,Pole Mer Bretagne Atlantique,,,
|
,TEMANO,+33746225522,Envoyer le message,clara.honore@temano.fr,TEMANO,Quentin DEMOULIN,"Ploemeur, Bretagne, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-01-17,Technology & innovations,"Development of anchors with minimal environmental impact, intended for recreational boating and renewable energy on floating bases",false,Pole Mer Bretagne Atlantique,Received,https://drive.google.com/drive/folders/1hLQWBtegrcsvq_sDjRrn5l0bb4P9_pxt?usp=drive_link,
|
||||||
,Mathias Mondo,+237693514085,Envoyer le message,mathiasmondo@me.com,Blue Sentinel,Nguessong Donfack Marie-Ange; Tagne Wambo Elohim Junior; Bidzana Deugoue Erwin ; GUENTANG BADEFONA ODILE; Nkot-a-Nzok Etienne; Mondo Mathias; Adipauldi Sigot Sabine; Ndzana Nga Romaric; Lowe Nyat Fred,Cameroun,"Africa, Cameroun",INSTITUT UNIVERSITAIRE D'INNOVATION ET DE MANAGEMENT MARIE-ALBERT (ISIMMA),the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,"Ocean pollution starts on land.
|
,Mathias Mondo,+237693514085,Envoyer le message,mathiasmondo@me.com,Blue Sentinel,Nguessong Donfack Marie-Ange; Tagne Wambo Elohim Junior; Bidzana Deugoue Erwin ; GUENTANG BADEFONA ODILE; Nkot-a-Nzok Etienne; Mondo Mathias; Adipauldi Sigot Sabine; Ndzana Nga Romaric; Lowe Nyat Fred,Cameroun,"Africa, Cameroun",INSTITUT UNIVERSITAIRE D'INNOVATION ET DE MANAGEMENT MARIE-ALBERT (ISIMMA),the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,"Ocean pollution starts on land.
|
||||||
In Yoyo (Gulf of Guinea), accelerated mangrove degradation directly impacts coastal water quality, marine nurseries and coastal resilience. Despite awareness and initiatives, decisions remain fragmented, reactive and poorly governed — leaving the Ocean unprotected at its source.
|
In Yoyo (Gulf of Guinea), accelerated mangrove degradation directly impacts coastal water quality, marine nurseries and coastal resilience. Despite awareness and initiatives, decisions remain fragmented, reactive and poorly governed — leaving the Ocean unprotected at its source.
|
||||||
|
|
||||||
@@ -817,28 +821,28 @@ Yoyo is the pilot. Monaco is the accelerator.",false,Via Linkedin and Mrs Manon
|
|||||||
,Maya Bauer,+33768779375,Envoyer le message,mayabauer011@gmail.com,Veratech,Maya Bauer,"Cleveland, OH, États-Unis",US,Universite Cote D'Azur,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,,true,"I heard about MOPC through my university, and I participated in 2024 with my project NEMA. I want to be transparent, just in case I can not participate again. However my new project, Veratech, is a completely different concept/project and is at the prototype stage. If anyone would like to discuss this with me further I would be happy to! My last experience at MOPC was such a pleasant learning experience, and I would greatly like to participate again if it is possible.",,,
|
,Maya Bauer,+33768779375,Envoyer le message,mayabauer011@gmail.com,Veratech,Maya Bauer,"Cleveland, OH, États-Unis",US,Universite Cote D'Azur,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,,true,"I heard about MOPC through my university, and I participated in 2024 with my project NEMA. I want to be transparent, just in case I can not participate again. However my new project, Veratech, is a completely different concept/project and is at the prototype stage. If anyone would like to discuss this with me further I would be happy to! My last experience at MOPC was such a pleasant learning experience, and I would greatly like to participate again if it is possible.",,,
|
||||||
,Charles Maher,+16174609706,Envoyer le message,charles.maher@blueshadow.dk,BlueShadow ApS,Emil Luth,"Copenhague, Région de la Capitale, Danemark","Denmark, Europe",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-02-24,Sustainable fishing and aquaculture & blue food,"BlueShadow protects society and the environment by delivering Maritime Security as a Service - safeguarding critical infrastructure and natural resources across the maritime domain.
|
,Charles Maher,+16174609706,Envoyer le message,charles.maher@blueshadow.dk,BlueShadow ApS,Emil Luth,"Copenhague, Région de la Capitale, Danemark","Denmark, Europe",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-02-24,Sustainable fishing and aquaculture & blue food,"BlueShadow protects society and the environment by delivering Maritime Security as a Service - safeguarding critical infrastructure and natural resources across the maritime domain.
|
||||||
|
|
||||||
Through our BlueFisheries Mission Solution, we serve as a force-multiplier for responsible authorities in the fight against illegal and destructive fishing. We fuse multi-source data into real-time, predictive intelligence that cuts through deception and exposes suspicious activity, triggering the immediate deployment of autonomous systems to investigate, document, and secure evidence - enabling decisive enforcement action to ensure violators are held accountable.",true,Referral from Victor Cobos of Avantgard Capital,,,
|
Through our BlueFisheries Mission Solution, we serve as a force-multiplier for responsible authorities in the fight against illegal and destructive fishing. We fuse multi-source data into real-time, predictive intelligence that cuts through deception and exposes suspicious activity, triggering the immediate deployment of autonomous systems to investigate, document, and secure evidence - enabling decisive enforcement action to ensure violators are held accountable.",true,Referral from Victor Cobos of Avantgard Capital,Received,https://drive.google.com/drive/folders/1HOZF-ywLd22e_-S1-V6Ezw0CF_srypRA?usp=drive_link,
|
||||||
,Sudarsha Chanaka De Silva,+94776761788,Envoyer le message,sudarsha30384@gmail.com,Install High-Tech Water Purification Units in Fisheries Sector To Tackle Plastic Pollution in sea off Sri Lanka,"Sudarsha De Silva , Jagath Gunasekara, Madhura Delpachithra","Kudawella South, Southern, Sri Lanka","Africa, Sri Lanka",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-06-16,Reduction of pollution (plastics chemicals noise light...),"• Cleaner oceans and beaches: Less plastic waste entering our marine ecosystems.
|
,Sudarsha Chanaka De Silva,+94776761788,Envoyer le message,sudarsha30384@gmail.com,Install High-Tech Water Purification Units in Fisheries Sector To Tackle Plastic Pollution in sea off Sri Lanka,"Sudarsha De Silva , Jagath Gunasekara, Madhura Delpachithra","Kudawella South, Southern, Sri Lanka","Africa, Sri Lanka",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-06-16,Reduction of pollution (plastics chemicals noise light...),"• Cleaner oceans and beaches: Less plastic waste entering our marine ecosystems.
|
||||||
• Protected marine biodiversity: Safeguarding our coral reefs and diverse marine life.
|
• Protected marine biodiversity: Safeguarding our coral reefs and diverse marine life.
|
||||||
• Improved health for fishing crews of multi-day fishing boats: Access to clean, purified water on long voyages improves the hygiene and sanitary standards of fishermen
|
• Improved health for fishing crews of multi-day fishing boats: Access to clean, purified water on long voyages improves the hygiene and sanitary standards of fishermen
|
||||||
• Reduced operational costs of the fisheries sector: Fishermen save on bottled water purchases.
|
• Reduced operational costs of the fisheries sector: Fishermen save on bottled water purchases.
|
||||||
• Enhanced industry reputation: Sri Lanka leading the way in environmental stewardship
|
• Enhanced industry reputation: Sri Lanka leading the way in environmental stewardship
|
||||||
The project responds to the urgency to reduce plastic pollution caused by multiday fishing vessels in Sri Lanka, which is a major contribution to marine debris in the country's ocean waters. these fishing vessles spends 4-5 weeks in the sea and heavily depend on single-use plastic bottles for drinking water. with a limited waste storage and disposal infrastructure on board, much of this plastic is directly to the ocean, creating hotspots in the sea for marine litter which will result on microsplatic contamination on marine life and human body. This catostophe affects the tourism and fishries industry badly both sectors are vital for the economy of Sri Lanka. During their journey fisherman face challenges accessing safe drinking water while at sea affecting their health, hygiene and overall well-being. This highlights the importance clean water access and for reducing health impacts caused by plastic dependency. Therefore the project demonstrate the need for environment protection, ensure occupational health.",true,Through social media,,,
|
The project responds to the urgency to reduce plastic pollution caused by multiday fishing vessels in Sri Lanka, which is a major contribution to marine debris in the country's ocean waters. these fishing vessles spends 4-5 weeks in the sea and heavily depend on single-use plastic bottles for drinking water. with a limited waste storage and disposal infrastructure on board, much of this plastic is directly to the ocean, creating hotspots in the sea for marine litter which will result on microsplatic contamination on marine life and human body. This catostophe affects the tourism and fishries industry badly both sectors are vital for the economy of Sri Lanka. During their journey fisherman face challenges accessing safe drinking water while at sea affecting their health, hygiene and overall well-being. This highlights the importance clean water access and for reducing health impacts caused by plastic dependency. Therefore the project demonstrate the need for environment protection, ensure occupational health.",true,Through social media,Received,https://drive.google.com/drive/folders/1lsSAKHSXpo5En0QJGz2DumJpJFGntqoi?usp=drive_link,
|
||||||
,Trisna Oktavia,+6289530030014,Envoyer le message,trisna.jobs@gmail.com,Seawaste Tech,"Asadurrofiq, Jean Baptiste Sugihardjanto",,,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-01-01,Reduction of pollution (plastics chemicals noise light...),"SeaWaste Tech is an integrated fishery waste and marine waste processing through a zero-waste system approach. In 2025, We successfully processed 50 tons of fishery waste into animal feed and organic fertilizer. We collaborate with seafood processing plants to manage and transform their production waste. However, beyond organic fishery waste, plastic packaging waste has also become a growing environmental challenge for these facilities.
|
,Trisna Oktavia,+6289530030014,Envoyer le message,trisna.jobs@gmail.com,Seawaste Tech,"Asadurrofiq, Jean Baptiste Sugihardjanto",,,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-01-01,Reduction of pollution (plastics chemicals noise light...),"SeaWaste Tech is an integrated fishery waste and marine waste processing through a zero-waste system approach. In 2025, We successfully processed 50 tons of fishery waste into animal feed and organic fertilizer. We collaborate with seafood processing plants to manage and transform their production waste. However, beyond organic fishery waste, plastic packaging waste has also become a growing environmental challenge for these facilities.
|
||||||
Currently, our fishery waste processing operations still rely on fossil fuels for energy. Unfortunately, fossil fuels are increasingly expensive and often subject to supply shortages, which directly impacts production stability and costs. Recognizing this challenge, we see a strong opportunity to replace fossil fuel consumption with fuel derived from plastic waste generated by seafood processing factories. Through our pilot project calculations, this transition has the potential to reduce fossil fuel usage by up to 80% and lower production costs (HPP) by approximately 20%.
|
Currently, our fishery waste processing operations still rely on fossil fuels for energy. Unfortunately, fossil fuels are increasingly expensive and often subject to supply shortages, which directly impacts production stability and costs. Recognizing this challenge, we see a strong opportunity to replace fossil fuel consumption with fuel derived from plastic waste generated by seafood processing factories. Through our pilot project calculations, this transition has the potential to reduce fossil fuel usage by up to 80% and lower production costs (HPP) by approximately 20%.
|
||||||
In addition to utilizing factory plastic waste, we also plan to collect plastic waste from coastal communities. Many of these communities lack proper waste management infrastructure, leading to common practices such as dumping plastic into the ocean or burning it both of which create further environmental pollution and threaten marine ecosystems. Our core focus is to convert plastic waste into diesel fuel as an alternative energy source and make a collection point. This solution will reduce dependence on fossil fuels, Lower production costs, Create a community-based plastic savings system that encourages behavioral change, Deliver significant environmental impact.
|
In addition to utilizing factory plastic waste, we also plan to collect plastic waste from coastal communities. Many of these communities lack proper waste management infrastructure, leading to common practices such as dumping plastic into the ocean or burning it both of which create further environmental pollution and threaten marine ecosystems. Our core focus is to convert plastic waste into diesel fuel as an alternative energy source and make a collection point. This solution will reduce dependence on fossil fuels, Lower production costs, Create a community-based plastic savings system that encourages behavioral change, Deliver significant environmental impact.
|
||||||
By transforming plastic waste into energy, we are not only solving industrial and community waste challenges but also contributing to global sustainability goals, specifically SDG 12 (Responsible Consumption and Production), SDG 13 (Climate Action), and SDG 14 (Life Below Water).",true,"I first learned about the Monaco Ocean Protection Challenge through my participation in the 2025 edition, where SeaWaste Tech was shortlisted as a finalist in the Business Concepts category. That experience deepened my commitment to refining our ocean impact model and motivated me to reapply in 2026 with a more mature, measurable solution grounded in our 2025 operational results.",,,
|
By transforming plastic waste into energy, we are not only solving industrial and community waste challenges but also contributing to global sustainability goals, specifically SDG 12 (Responsible Consumption and Production), SDG 13 (Climate Action), and SDG 14 (Life Below Water).",true,"I first learned about the Monaco Ocean Protection Challenge through my participation in the 2025 edition, where SeaWaste Tech was shortlisted as a finalist in the Business Concepts category. That experience deepened my commitment to refining our ocean impact model and motivated me to reapply in 2026 with a more mature, measurable solution grounded in our 2025 operational results.",Received,https://drive.google.com/drive/folders/1ZgyOGhdVYJOFjKlK6EwyHWKbeExBWDlO?usp=drive_link,
|
||||||
,Tabe Brandon Njume,+237675068360,Envoyer le message,tabebrandon50@gmail.com,TBINDS,"Anthony Duxell Malle, Forbah Sandra, Atianjoh Laris, Warri Bilson","Tiko, Cameroun","Africa, Cameroun","University of Buea, Cameroon + University of Bamenda, Cameroon",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"My solution seeks to tackle microplastics pollution in our oceans by addressing point sources such as household and hospital diaper and surgical mask waste. To tackle this, we have engineered a circular, self-powering system that transforms waste from disposable diapers and surgical masks into TBinds, a soft, durable shoe sole.
|
,Tabe Brandon Njume,+237675068360,Envoyer le message,tabebrandon50@gmail.com,TBINDS,"Anthony Duxell Malle, Forbah Sandra, Atianjoh Laris, Warri Bilson","Tiko, Cameroun","Africa, Cameroun","University of Buea, Cameroon + University of Bamenda, Cameroon",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"My solution seeks to tackle microplastics pollution in our oceans by addressing point sources such as household and hospital diaper and surgical mask waste. To tackle this, we have engineered a circular, self-powering system that transforms waste from disposable diapers and surgical masks into TBinds, a soft, durable shoe sole.
|
||||||
Our process begins with a closed-loop collection system using leak-proof bins in hospitals and households to keep waste contained. To handle the biological load, we use a specialized fractionation method: waste is treated with a 2% Calcium Chloride solution to collapse the Superabsorbent Polymers (SAP), forcing them to release trapped liquids and excreta.
|
Our process begins with a closed-loop collection system using leak-proof bins in hospitals and households to keep waste contained. To handle the biological load, we use a specialized fractionation method: waste is treated with a 2% Calcium Chloride solution to collapse the Superabsorbent Polymers (SAP), forcing them to release trapped liquids and excreta.
|
||||||
This organic slurry is diverted to an anaerobic digester to produce biogas, which powers our machinery. The remaining solids are sent to a wet shredder and centrifugal separator. A float-sink tank then isolates the high-grade Polypropylene (PP) and Polyethylene (PE) from the cellulose fibers. While our priority is stopping waste at the source, TBinds can process diaper waste from drains by adding a sedimentation step to wash off mud and grit.",true,,Received,https://drive.google.com/drive/folders/1pYFR66gjdcHQjRlOc24gUpstIhhmFyGO?usp=drive_link,
|
This organic slurry is diverted to an anaerobic digester to produce biogas, which powers our machinery. The remaining solids are sent to a wet shredder and centrifugal separator. A float-sink tank then isolates the high-grade Polypropylene (PP) and Polyethylene (PE) from the cellulose fibers. While our priority is stopping waste at the source, TBinds can process diaper waste from drains by adding a sedimentation step to wash off mud and grit.",true,,Received,https://drive.google.com/drive/folders/1pYFR66gjdcHQjRlOc24gUpstIhhmFyGO?usp=drive_link,
|
||||||
,Oscar Montoya,+573147802359,Envoyer le message,archivoadm@gmail.com,ISO Brick,2,"Medellin, Colombie",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-01-01,Reduction of pollution (plastics chemicals noise light...),"The ISOBrick project aims to recover rigid polyurethane discarded during the manufacture of thermo-acoustic roof tiles and reuse that material as a filler within the cavities of clay bricks, transforming them into bricks with greater thermal and acoustic insulation capacity, reducing industrial waste and promoting more sustainable and energy-efficient construction.",true,By a research group,,,
|
,Oscar Montoya,+573147802359,Envoyer le message,archivoadm@gmail.com,ISO Brick,2,"Medellin, Colombie",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-01-01,Reduction of pollution (plastics chemicals noise light...),"The ISOBrick project aims to recover rigid polyurethane discarded during the manufacture of thermo-acoustic roof tiles and reuse that material as a filler within the cavities of clay bricks, transforming them into bricks with greater thermal and acoustic insulation capacity, reducing industrial waste and promoting more sustainable and energy-efficient construction.",true,By a research group,Received,https://drive.google.com/drive/folders/1nMXqz2TT_7QUixsQ1EljE9bE9_ThujcV?usp=drive_link,
|
||||||
,Anastasija Stefanovic,+33749014764,Envoyer le message,anastasija.stefanovic@univ-paris1.fr,BluePrint Reefs,"Anastasija Stefanovic, Florian Guichard","Paris, Île-de-France, France","Europe, France","Sorbonne Pantheon Paris 1, Paris",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"We offer a high-tech, scalable solution: 3D-bioprinted modular reefs.
|
,Anastasija Stefanovic,+33749014764,Envoyer le message,anastasija.stefanovic@univ-paris1.fr,BluePrint Reefs,"Anastasija Stefanovic, Florian Guichard","Paris, Île-de-France, France","Europe, France","Sorbonne Pantheon Paris 1, Paris",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"We offer a high-tech, scalable solution: 3D-bioprinted modular reefs.
|
||||||
|
|
||||||
Innovation: Using a proprietary ""Living Ink"" (recycled calcium carbonate + bio-triggers), we print structures that mimic natural reef complexity.
|
Innovation: Using a proprietary ""Living Ink"" (recycled calcium carbonate + bio-triggers), we print structures that mimic natural reef complexity.
|
||||||
|
|
||||||
Result: These reefs don't just sit on the seabed; they actively ""invite"" coral larvae to settle, accelerating biodiversity recovery by 300% compared to traditional methods.",true,I am a resident of Fondation de Monaco at Cite Internationale Universitaire de Paris where MOPC was mentioned.,,,
|
Result: These reefs don't just sit on the seabed; they actively ""invite"" coral larvae to settle, accelerating biodiversity recovery by 300% compared to traditional methods.",true,I am a resident of Fondation de Monaco at Cite Internationale Universitaire de Paris where MOPC was mentioned.,Received,https://drive.google.com/drive/folders/1xcY1iVbFDOe9KQuLTSJlOpK8WPqFDpUP?usp=drive_link,
|
||||||
,Anvi Gowda,+4917623367782,Envoyer le message,anvi.gowda@myhsba.de,BlueReturn,"Jana Cisewski, Anvi Gowda, Malena Merkel & Kamar Haidar","Hambourg, Allemagne","Europe, Germany","Hamburg School of Business Administration, Hamburg",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"With our project ""BlueReturn"", we aim to motivate people to collect trash in coastal areas and dispose of it. It is a system that consists of AI supported clean up stations, which enable correct trash seperation and are connected to an app. The app tracks the amount of trash collected by users and converts it into points, which can either be donated to ocean protection initiatives or redeemed for sustanability related rewards. Rewards include e.g. vouchers for sustainable brands or a free public transport ticket. With BlueReturn our goal is to incentivize people to turn small actions into a visible impact on ocean protection.",true,Our Professor introduced the challenge during a sustanability course at the HSBA.,Received,https://drive.google.com/drive/folders/1iac3f__cDlv03ssLpfEVRHq5TzdjtYpa?usp=drive_link,
|
,Anvi Gowda,+4917623367782,Envoyer le message,anvi.gowda@myhsba.de,BlueReturn,"Jana Cisewski, Anvi Gowda, Malena Merkel & Kamar Haidar","Hambourg, Allemagne","Europe, Germany","Hamburg School of Business Administration, Hamburg",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"With our project ""BlueReturn"", we aim to motivate people to collect trash in coastal areas and dispose of it. It is a system that consists of AI supported clean up stations, which enable correct trash seperation and are connected to an app. The app tracks the amount of trash collected by users and converts it into points, which can either be donated to ocean protection initiatives or redeemed for sustanability related rewards. Rewards include e.g. vouchers for sustainable brands or a free public transport ticket. With BlueReturn our goal is to incentivize people to turn small actions into a visible impact on ocean protection.",true,Our Professor introduced the challenge during a sustanability course at the HSBA.,Received,https://drive.google.com/drive/folders/1iac3f__cDlv03ssLpfEVRHq5TzdjtYpa?usp=drive_link,
|
||||||
,Nasha Afrina Binte Abdul Mutalib,+6588345209,Envoyer le message,afrinaabdul77@gmail.com,Blue Miles Index,1.Nasha Abdul 2. Cassarah Matakena 3. Moh. Aufa Dany Damario 4. Pathinettan Philemon,"Singapour, Singapour",Asia,1. Singapore institute of technology/User experience and game design 2. Universitas Indonesia/Biology 3.Universitas Indonesia/Naval Architecture-Marine Engineering 4. Nanyang Technological University/Environmental and Earth Systems Science,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"Blue Miles Index is a standardized decision-support framework that makes maritime transport distance visible in purchasing and procurement decisions. The project addresses a structural transparency gap in global trade: while over 80% of goods move by sea, shipping distance and its ocean impact remain invisible at the point of purchase. Blue Miles Index converts product origin and logistics data into a normalized, distance-based ocean pressure indicator that integrates into retail platforms and procurement systems. By introducing a comparable transport signal, the framework enables informed sourcing decisions without restricting choice. Its objective is to reduce cumulative vessel kilometres travelled by influencing upstream purchasing behaviour, thereby lowering emissions, underwater noise exposure, and maritime pressure on marine ecosystems. The system scales through voluntary integration and leverages existing supply chain data, requiring no new infrastructure.",true,linkedin,,,
|
,Nasha Afrina Binte Abdul Mutalib,+6588345209,Envoyer le message,afrinaabdul77@gmail.com,Blue Miles Index,1.Nasha Abdul 2. Cassarah Matakena 3. Moh. Aufa Dany Damario 4. Pathinettan Philemon,"Singapour, Singapour",Asia,1. Singapore institute of technology/User experience and game design 2. Universitas Indonesia/Biology 3.Universitas Indonesia/Naval Architecture-Marine Engineering 4. Nanyang Technological University/Environmental and Earth Systems Science,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"Blue Miles Index is a standardized decision-support framework that makes maritime transport distance visible in purchasing and procurement decisions. The project addresses a structural transparency gap in global trade: while over 80% of goods move by sea, shipping distance and its ocean impact remain invisible at the point of purchase. Blue Miles Index converts product origin and logistics data into a normalized, distance-based ocean pressure indicator that integrates into retail platforms and procurement systems. By introducing a comparable transport signal, the framework enables informed sourcing decisions without restricting choice. Its objective is to reduce cumulative vessel kilometres travelled by influencing upstream purchasing behaviour, thereby lowering emissions, underwater noise exposure, and maritime pressure on marine ecosystems. The system scales through voluntary integration and leverages existing supply chain data, requiring no new infrastructure.",true,linkedin,Received,https://drive.google.com/drive/folders/1RvCTmMWpuNlhTk-zhQTezCbzeeUiunxd?usp=drive_link,
|
||||||
,Ratri Maria,+6596809257,Envoyer le message,ratri@changemakr.asia,ClimaFund,"Tsamara Tsabita, Anggy Priyo, Rizal Suryawan","Singapour, Singapour",Asia,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-07-22,Sustainable fishing and aquaculture & blue food,"ClimaFund makes coastal conservation economically self-sustaining by solving the ""first mile problem""—the gap between restoration investments and verifiable, community-level impact.
|
,Ratri Maria,+6596809257,Envoyer le message,ratri@changemakr.asia,ClimaFund,"Tsamara Tsabita, Anggy Priyo, Rizal Suryawan","Singapour, Singapour",Asia,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-07-22,Sustainable fishing and aquaculture & blue food,"ClimaFund makes coastal conservation economically self-sustaining by solving the ""first mile problem""—the gap between restoration investments and verifiable, community-level impact.
|
||||||
|
|
||||||
The Problem: Mangrove restoration programs suffer 40-60% failure rates due to late stress detection, while fishing communities—the essential stewards—remain economically invisible to premium markets and carbon finance.
|
The Problem: Mangrove restoration programs suffer 40-60% failure rates due to late stress detection, while fishing communities—the essential stewards—remain economically invisible to premium markets and carbon finance.
|
||||||
@@ -854,7 +858,7 @@ Generate verifiable blue carbon credits that fund long-term community stewardshi
|
|||||||
|
|
||||||
We're the first solution combining ecosystem verification with seafood traceability—making conservation profitable for those who protect it.
|
We're the first solution combining ecosystem verification with seafood traceability—making conservation profitable for those who protect it.
|
||||||
|
|
||||||
Current Traction: Operating in Indonesia's Delta Mahakam with mangrove custodians & social forestry groups, totalling more than 450 hectares, preparing Q1 2026 commercial launch.",true,linkedin,,,
|
Current Traction: Operating in Indonesia's Delta Mahakam with mangrove custodians & social forestry groups, totalling more than 450 hectares, preparing Q1 2026 commercial launch.",true,linkedin,Received,https://drive.google.com/drive/folders/11PVLhT2YOjlnSfG1nwOTgWbw5kjbgifZ?usp=drive_link,
|
||||||
,Karime Guillen Libien,+527222617967,Envoyer le message,karimeguillen@rearvora.com,Rearvora,"Estefania Vazquez Martinez, Estefania Valdes Vazquez, Juanjo Saldivar","Toluca, Edomex, Mexique",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-01-31,Reduction of pollution (plastics chemicals noise light...),"We are a Mexican circular biotechnology company born from a simple but urgent realization: what tourism leaves behind does not disappear. It travels. It flows through rivers, drains, coastlines and eventually reaches the ocean.
|
,Karime Guillen Libien,+527222617967,Envoyer le message,karimeguillen@rearvora.com,Rearvora,"Estefania Vazquez Martinez, Estefania Valdes Vazquez, Juanjo Saldivar","Toluca, Edomex, Mexique",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-01-31,Reduction of pollution (plastics chemicals noise light...),"We are a Mexican circular biotechnology company born from a simple but urgent realization: what tourism leaves behind does not disappear. It travels. It flows through rivers, drains, coastlines and eventually reaches the ocean.
|
||||||
|
|
||||||
In coastal and tourism destinations, millions of plastic amenities, chemically loaded personal care products, and poorly managed organic waste quietly accumulate every day. What seems harmless in a hotel room becomes microplastics in marine food chains, toxic residues in coral ecosystems, and nutrient overload that suffocates coastal waters. Organic waste, when mismanaged, releases methane in landfills and contributes to water contamination and eutrophication when it reaches marine ecosystems. These impacts are rarely associated with tourism, yet they are deeply connected.
|
In coastal and tourism destinations, millions of plastic amenities, chemically loaded personal care products, and poorly managed organic waste quietly accumulate every day. What seems harmless in a hotel room becomes microplastics in marine food chains, toxic residues in coral ecosystems, and nutrient overload that suffocates coastal waters. Organic waste, when mismanaged, releases methane in landfills and contributes to water contamination and eutrophication when it reaches marine ecosystems. These impacts are rarely associated with tourism, yet they are deeply connected.
|
||||||
@@ -863,8 +867,8 @@ At Rearvora, we transform organic waste into bioproducts and bio-based packaging
|
|||||||
|
|
||||||
Our objectives are concrete: to provide hotels with sustainable amenities that prevent plastic and chemical leakage; intervene upstream within tourism systemsbefore pollution reaches the sea. To offer collection and treatment services for organic waste generated in tourism operations; and to establish circular transformation hubs in tourism regions where local communities participate directly, generating green and innovative jobs. Through circular production hubs, sustainable amenities, and environmental education for hotels and travelers, we turn prevention into ocean protection. These hubs close material loops locally, preventing waste from reaching waterways while strengthening community resilience.
|
Our objectives are concrete: to provide hotels with sustainable amenities that prevent plastic and chemical leakage; intervene upstream within tourism systemsbefore pollution reaches the sea. To offer collection and treatment services for organic waste generated in tourism operations; and to establish circular transformation hubs in tourism regions where local communities participate directly, generating green and innovative jobs. Through circular production hubs, sustainable amenities, and environmental education for hotels and travelers, we turn prevention into ocean protection. These hubs close material loops locally, preventing waste from reaching waterways while strengthening community resilience.
|
||||||
|
|
||||||
For us, protecting the ocean does not start at the shoreline. It starts with what we choose to produce, consume, and discar",true,From Instagram and Recommendation of a program name TECA,,,
|
For us, protecting the ocean does not start at the shoreline. It starts with what we choose to produce, consume, and discar",true,From Instagram and Recommendation of a program name TECA,Received,https://drive.google.com/drive/folders/1E0r6BdnMQ9-7-JSnjoNVNrAfolA_Pyv5?usp=drive_link,
|
||||||
,Alfonso Rodríguez,+526461512748,Envoyer le message,alfonso.rodriguez@cicese.edu.mx,UniMar,"Alfonso Rodríguez, Jeremie Bauer, Manuel Acosta & Jorge Olmos","Ensenada, BC, Mexique",South America,"Centro de Investigación Científica y de Educación Superior de Ensenada Baja California. Ensenada, Baja California. Mexico.",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"UniMar produces a proprietary plant-based probiotic feed (zero fishmeal or macroalgae) that fattens starved barren urchins into premium gonads. Every urchin ranched removes a grazer from collapsing kelp forests, making restoration self-funding. 30 years R&D, peer-reviewed validation, only plant-based feed achieving commercial-grade results globally. Scaling from Baja California to global markets.",true,Colleagues,,,
|
,Alfonso Rodríguez,+526461512748,Envoyer le message,alfonso.rodriguez@cicese.edu.mx,UniMar,"Alfonso Rodríguez, Jeremie Bauer, Manuel Acosta & Jorge Olmos","Ensenada, BC, Mexique",South America,"Centro de Investigación Científica y de Educación Superior de Ensenada Baja California. Ensenada, Baja California. Mexico.",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"UniMar produces a proprietary plant-based probiotic feed (zero fishmeal or macroalgae) that fattens starved barren urchins into premium gonads. Every urchin ranched removes a grazer from collapsing kelp forests, making restoration self-funding. 30 years R&D, peer-reviewed validation, only plant-based feed achieving commercial-grade results globally. Scaling from Baja California to global markets.",true,Colleagues,Received,https://drive.google.com/drive/folders/10QsjUBj5C37qoteCpheqEfNySBNtvsLM?usp=drive_link,
|
||||||
,Aluora Annette Luttah,+22892077042,Envoyer le message,aluttah@gmail.com,EcoHarbor Services and Solutions,"Annette Luttah ALUORA, Edem ASSIGBLEY, Adeline ALOKPA, Yaovi ANDELE, Philippe AKUE-ABOSSE, Kokouvi Noviti YEHOUESSI","Lomé, Maritime, Togo","Africa, Togo",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-02-04,Reduction of pollution (plastics chemicals noise light...),"Transforming plastic ocean waste into prospects for change in Togo
|
,Aluora Annette Luttah,+22892077042,Envoyer le message,aluttah@gmail.com,EcoHarbor Services and Solutions,"Annette Luttah ALUORA, Edem ASSIGBLEY, Adeline ALOKPA, Yaovi ANDELE, Philippe AKUE-ABOSSE, Kokouvi Noviti YEHOUESSI","Lomé, Maritime, Togo","Africa, Togo",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-02-04,Reduction of pollution (plastics chemicals noise light...),"Transforming plastic ocean waste into prospects for change in Togo
|
||||||
1. Project Overview
|
1. Project Overview
|
||||||
EcoHarbor Services and Solutions is a nature-positive social enterprise that integrates marine plastic recovery with clean port and beach services. The initiative endeavors to reduce ocean pollution, create local jobs, and promote a circular blue economy in Togo.
|
EcoHarbor Services and Solutions is a nature-positive social enterprise that integrates marine plastic recovery with clean port and beach services. The initiative endeavors to reduce ocean pollution, create local jobs, and promote a circular blue economy in Togo.
|
||||||
@@ -874,7 +878,7 @@ JVE International leads the initiative in partnership with local NGOs, municipal
|
|||||||
2. The Problem
|
2. The Problem
|
||||||
Plastic pollution is a growing threat to Togo’s 56 km coastline, ports, lagoons, mangroves, fisheries, and tourism. Plastics represent more than 10% of municipal solid waste, while only about 48% of municipal waste is collected, creating high leakage risk into the marine environment affecting mangroves, fisheries, tourism, and coastal livelihoods.
|
Plastic pollution is a growing threat to Togo’s 56 km coastline, ports, lagoons, mangroves, fisheries, and tourism. Plastics represent more than 10% of municipal solid waste, while only about 48% of municipal waste is collected, creating high leakage risk into the marine environment affecting mangroves, fisheries, tourism, and coastal livelihoods.
|
||||||
Across coastal West Africa, around 80% of plastic waste is mismanaged, making ports and beaches major entry points for plastics into the ocean. Globally, 8–11 million tonnes of plastic enter the ocean every year, underscoring the urgency of local action.
|
Across coastal West Africa, around 80% of plastic waste is mismanaged, making ports and beaches major entry points for plastics into the ocean. Globally, 8–11 million tonnes of plastic enter the ocean every year, underscoring the urgency of local action.
|
||||||
Plastic pollution severely affects mangrove ecosystems, reducing seedling survival, blocking root respiration, and accelerating ecosystem degradation in coastal and lagoon environments. Limited waste recovery in ports, lagoons, and beaches leads to plastics entering the ocean, Currently, ports and beaches lack integrated solutions that target pollution prevention, waste recovery, and economic value creation. This project responds to a national need for locally adapted, job-creating ocean solutions rather than imported techno",true,Through a Partner,,,
|
Plastic pollution severely affects mangrove ecosystems, reducing seedling survival, blocking root respiration, and accelerating ecosystem degradation in coastal and lagoon environments. Limited waste recovery in ports, lagoons, and beaches leads to plastics entering the ocean, Currently, ports and beaches lack integrated solutions that target pollution prevention, waste recovery, and economic value creation. This project responds to a national need for locally adapted, job-creating ocean solutions rather than imported techno",true,Through a Partner,Received,https://drive.google.com/drive/folders/1ezy4ro_k2R45iTeFb1GNLx4qa7f1t1kW?usp=drive_link,
|
||||||
,Janne Springer,+491723211337,Envoyer le message,janne.springer@myhsba.de,The BetterCatch,"Mette Wolf, Lulu Kuhlwein, Janne Springer","Hambourg, Allemagne","Europe, Germany","Hamburg School of Business Administration, Hamburg",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"The BetterCatch is a white‑label, science‑based decision system integrated into existing supermarket apps and a stand-alone app. By scanning a barcode or searching for a product, shoppers receive an instant traffic‑light rating (green/yellow/red) and a better in‑store alternative in under three seconds. The rating logic aggregates species, FAO fishing area, gear type, stock status, and additional risk factors into a clear recommendation grounded in peer‑reviewed research and established indicator frameworks.",false,University and our Sustainability Lecture,Received,https://drive.google.com/drive/folders/1lzWuGV8pyCjSyVRv1K2Ns1FhwIGfuH7O?usp=drive_link,
|
,Janne Springer,+491723211337,Envoyer le message,janne.springer@myhsba.de,The BetterCatch,"Mette Wolf, Lulu Kuhlwein, Janne Springer","Hambourg, Allemagne","Europe, Germany","Hamburg School of Business Administration, Hamburg",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"The BetterCatch is a white‑label, science‑based decision system integrated into existing supermarket apps and a stand-alone app. By scanning a barcode or searching for a product, shoppers receive an instant traffic‑light rating (green/yellow/red) and a better in‑store alternative in under three seconds. The rating logic aggregates species, FAO fishing area, gear type, stock status, and additional risk factors into a clear recommendation grounded in peer‑reviewed research and established indicator frameworks.",false,University and our Sustainability Lecture,Received,https://drive.google.com/drive/folders/1lzWuGV8pyCjSyVRv1K2Ns1FhwIGfuH7O?usp=drive_link,
|
||||||
,Esméralda Mavrel,+46793575837,Envoyer le message,esmeralda.mavrel1@gmail.com,ROBOVAC,"Esméralda Mavrel, ABIBA FATHIMA A , S. KAVIYA & SIVASHANKER G","Goteborg, Comté de Vastra Gotaland, Suède","Europe, Sweden",University of Gothenburg,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"ROBOVAC is a semi-
|
,Esméralda Mavrel,+46793575837,Envoyer le message,esmeralda.mavrel1@gmail.com,ROBOVAC,"Esméralda Mavrel, ABIBA FATHIMA A , S. KAVIYA & SIVASHANKER G","Goteborg, Comté de Vastra Gotaland, Suède","Europe, Sweden",University of Gothenburg,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"ROBOVAC is a semi-
|
||||||
autonomous floating robot
|
autonomous floating robot
|
||||||
@@ -882,4 +886,138 @@ collecting surface plastic
|
|||||||
pollution in rivers, lakes,
|
pollution in rivers, lakes,
|
||||||
and coastal waters before
|
and coastal waters before
|
||||||
its degradation in the
|
its degradation in the
|
||||||
environment. It's AI-enhanced to classify the debris and generate pollution hotspots maps.",false,After the EU-India Ideathon,,,
|
environment. It's AI-enhanced to classify the debris and generate pollution hotspots maps.",false,After the EU-India Ideathon,,,
|
||||||
|
,CHE DIVINE NSOH,+237653614474,Envoyer le message,chedivine@ascoa-cm.org,ASSOCIATION FOR COMMUNITY AWARENESS (ASCOA),"Linus Ayangwoh Embe, Epeh Nuela Ayuk, Ruth Enjema, Solomon Takwi","Buea, Cameroun","Africa, Cameroun",University of Buea,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"Cameroon faces a growing crisis of plastic pollution, producing around 600,000 tonnes of plastic waste per year, with the greatest burden on coastal communities, due to an excess of pollution in rivers and waterways that drain into the Atlantic Ocean. Insufficiently regulated waste disposal has led to a number of severe and unexpected side effects, including increased disease burden and severe ecological degradation and disruption of marine biodiversity around estuaries and mangrove forests. Cameroon also has inadequate access to data regarding sources and mechanisms of plastic leakage into marine environments which, paired with a lack of funding and fragmented response, makes it difficult to implement effective interventions to address the impacts of marine plastic pollution.
|
||||||
|
ASCOA desires to address these issues by combining infrastructure, community engagement, and market incentives to intercept waste, create livelihoods, and foster a local circular economy.
|
||||||
|
|
||||||
|
Project Context: Marine plastic pollution poses a significant threat to coastal ecosystems, marine biodiversity, and human health, with coastal communities in developing regions facing some of the greatest impact. Due to a lack of infrastructure, resources, and awareness of effective waste management strategies, these areas face a continuous leakage of plastics into the marine environment, negatively impacting fisheries, tourism, and local livelihoods.
|
||||||
|
|
||||||
|
Project Goal: To establish and implement a comprehensive, community-based circular economy model in Buea-South West Region of Cameroon that significantly reduces the amount of plastic waste entering the marine environment, while simultaneously creating new economic opportunities for local residents.
|
||||||
|
|
||||||
|
Project Objectives:
|
||||||
|
- Reduce and prevent plastic waste through targeted source reduction program and promotion of reusable alternatives.
|
||||||
|
- Develop and establish a decentralized, efficient waste collection and sorting system.
|
||||||
|
- Pilot a circular economy initiati",true,on LinkedIn,Received,https://drive.google.com/drive/folders/1QwH-xxVgLXAaa91V1tsoH7-0ZDNjcIsf?usp=drive_link,
|
||||||
|
,Perry Chua,+639175184168,Envoyer le message,seaformsofficial@gmail.com,Seaforms,"Dino Chia, Julius Ang, Perry Chua","Singapour, Singapour",Asia,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-02-21,Restoration of marine habitats & ecosystems,"We would like to bring Seaforms to the Monaco Ocean Protection Challenge.
|
||||||
|
Seaforms deploys modular Electric Reef Systems (ERS) to accelerate coral reef restoration in sites where traditional restoration methods underperform. Using low‑voltage seawater electrolysis (mineral accretion) to collect calcium carbonate onto steel structures, we create a foundation that is biologically similar to coral skeleton and ideal for attaching coral fragments. Our innovation is converting a proven but often hard‑to‑scale electrified‑reef approach into standardized, plug‑and‑play components that can be configured for different sites, client preferences, and resource levels.
|
||||||
|
|
||||||
|
Objectives:
|
||||||
|
(1) increase coral growth, survival and stress recovery vs non‑electrified controls
|
||||||
|
(2) enable restoration in harsher environments where coastal protection is needed
|
||||||
|
(3) scale through repeatable installation and maintenance.
|
||||||
|
|
||||||
|
A 6‑month pilot (ONE15 Marina in Singapore) showed ~50% additional cumulative coral growth (Turbinaria & Pachyseris) vs control, faster recovery of stressed fragments, and markedly reduced biofouling. This supports SDG14 through measurable reef recovery.
|
||||||
|
|
||||||
|
We envision delivering the system as an end‑to‑end “underwater landscaping” service (installation + maintenance) that also serves as a form of eco-tourism (plant‑a‑coral, eco‑tours, adopt‑a‑coral) to help fund long‑term stewardship",true,Desk research,Received,https://drive.google.com/drive/folders/16pge5DYr5iABlKshi8cQBk2O7xMJpgmW?usp=drive_link,
|
||||||
|
,Sarah Driege,+32492836596,Envoyer le message,marescenter@gmail.com,marescenter,"Sarah Driege , Cassiopea Carrier Doneys and Pablo Calderon Cadiz","Mahahual, Othón P Blanco, QRoo, Mexique",South America,"Maastricht Univeristy, The Netherlands",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,Reef Guardianship Zone,true,instagram,Received,https://drive.google.com/drive/folders/1-ZKkAjeUFB6s5MH4TZwUXDeumgVQhnZE?usp=drive_link,
|
||||||
|
,Mohamed Amine Koubaa,+212762032608,Envoyer le message,koubaa.ma@gmail.com,Blue Fields Company,Mohamed Amine Koubaa,"Nador, L'Oriental, Maroc","Africa, Morocco",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-03-13,Sustainable fishing and aquaculture & blue food,"Our project, led by Blue Fields Company, is a nature-based and technology-driven solution to restore marine ecosystem health, improve water quality, and create sustainable economic opportunities in the Marchica Lagoon (Nador, Morocco). Marchica is a large Mediterranean lagoon facing significant environmental degradation from agricultural runoff, wastewater pollution, nutrient overload, and declining biodiversity, threatening coastal livelihoods.
|
||||||
|
|
||||||
|
The core idea is to implement a sustainable seaweed aquaculture model that functions as a living bioremediation system. By cultivating native species such as Gracilaria and Ulva across a 16-hectare marine concession, we enhance nutrient uptake, reduce eutrophication, and mitigate localized ocean acidification. Seaweed biomass is also valorized through markets — including bioplastics, biofertilizers, food additives, and carbon credit systems — creating a viable blue economy value chain.
|
||||||
|
|
||||||
|
Our objectives are to:
|
||||||
|
|
||||||
|
Regenerate and improve coastal water quality by reducing excess nutrients, pollutants, and hypoxic conditions.
|
||||||
|
|
||||||
|
Restore marine biodiversity through habitat creation and ecological balance.
|
||||||
|
|
||||||
|
Empower local communities by creating sustainable employment and training in seaweed cultivation and processing.
|
||||||
|
|
||||||
|
Demonstrate scalable innovation by integrating automated monitoring technologies — including underwater drones (ROVs) with imaging, sensors, and AI-assisted water quality analysis — to track ecological progress in real time.
|
||||||
|
|
||||||
|
Generate climate benefits through carbon sequestration, supporting blue carbon markets.
|
||||||
|
|
||||||
|
The project combines cutting-edge technology with community capacity building, making it replicable across similar coastal environments in the Mediterranean and beyond. By linking environmental restoration with tangible economic impact, our solution supports both ocean protection and sustainable development.",true,LinkedIn,Received,https://drive.google.com/drive/folders/11DQCGbj7bXhoyyjtzN3HJA7ay4qUt_kN?usp=drive_link,
|
||||||
|
,Rishav Mitra,+917044106267,Envoyer le message,sanjumitra1@gmail.com,The Four Module Framework,"Arnaaz Ali, Anshika Sharaaf","Delhi, Inde",Asia,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-05-12,Blue Carbon,"The project has 4 key modules that are currently being implemented in Delhi, and will soon be implemented in other cities as well, the goal being to make efficient use of the blue economy. We found our project idea highly relevant to this challenge, hence why we applied.",true,Through our University!,Received,https://drive.google.com/drive/folders/1PBalSBSOp9qIFJOvMIgf2_8mqDtniD-r?usp=drive_link,
|
||||||
|
,Mainul Islam Labib,+8801618376869,Envoyer le message,mainulislamlabib@gmail.com,Nirmol,Sadia Afrin,"Dumuria, Khulna, Bangladesh",Asia,"Khulna University, Khulna, Bangladesh",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"Nirmol is our startup based in Bangladesh. We remove microplastics from fish farms by building a filter that attaches directly to existing water pumps, cleaning out over 82% of microplastics. We also built an app that monitors water quality in real time and gives farmers a digital certificate proving their fish are raised in a microplastic-free environment. This helps farmers sell at higher prices and unlock export opportunities. We are targeting the 500,000+ small and medium fish farmers across Bangladesh.",true,Linkedin,Received,https://drive.google.com/drive/folders/1qORciecrc1xgimhrV46melcjJmIe6xIO?usp=drive_link,
|
||||||
|
,Sofie Boggio Sella,+61448568796,Envoyer le message,sofie.boggiosella@my.jcu.edu.au,PMRF: Probabilistic Multi Modal Reef Fusion,Lily Lewis | Mohammad Jahanbakht,Australie,Oceania,James Cook University Australia,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"Our project develops a scalable marine protection analytics pipeline that transforms multi-source ocean data into actionable conservation insights. Using automated data processing, ecological indicators, and predictive modeling, the system identifies priority protection zones, biodiversity risk signals, and monitoring gaps to support faster, evidence-based ocean management decisions. The objective is to make advanced marine analysis accessible to conservation teams and decision-makers through a lightweight, reproducible framework that can run on public or partner-provided datasets while respecting data governance constraints.",true,Linkedln,Received,https://drive.google.com/drive/folders/134oV9x28Nq3SsXYdSLl8yRQqq5fr8sJr?usp=drive_link,
|
||||||
|
,Kristian Ojala,+358451955314,Envoyer le message,ojalakristian@outlook.com,AquaAdvisor,Aarne Leinonen,"Helsinki, Finlande du Sud, Finlande","Europe, Finland","Aalto University, Espoo Finland and International University of Monaco, Monaco",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"AquaAdvisor is a data-driven SaaS platform that helps salmon farms make environmentally and financially optimal decisions. By integrating operational farm data with real-time market and cost data, its machine learning models recommend the most efficient responses to environmental risks and market fluctuations",true,We discovered the MOPC through online research while looking for entrepreneurship and sustainability-focused competitions in Monaco. We came across it via the official website and related coverage highlighting innovation initiatives in the region.,Received,https://drive.google.com/drive/folders/1C6174kO6cLZfSFx6p7wGy1iOCF4aWOwh?usp=drive_link,
|
||||||
|
,Heiner Camacho,+821042968546,Envoyer le message,2020925730@snu.ac.kr,TGB-Tidal Gravity Battery,"Heiner Camacho, Yimmy Hortua, Ifa Wahyunny, So Hee Lee","Bogota, Colombie",South America,Seoul National University,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,"TGB is a blue energy generation based on the gravitational power contained in the oceans as an alternative for contaminating oil rigs.
|
||||||
|
|
||||||
|
Objective 1. Build the blue hydrogen silo generator.
|
||||||
|
|
||||||
|
Objective 2. Create a silo- generators network along coastal areas.
|
||||||
|
|
||||||
|
Objective 3. Provides a hydrogen fueling service for small maritime vehicles.",true,Linkedin,,,
|
||||||
|
,Vera Lúcia Silva Morgado,+351964525998,Envoyer le message,veramorgado@agovi.pt,#SteelToTheBone Lab,"Vítor Fernandes, Francisco Morgado","Braga, Portugal","Europe, Portugal",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-11-20,Reduction of pollution (plastics chemicals noise light...),"Sustainable Innovation and Processes - Waste recovery and transformation, through industrial equipment and new value chains (fishing, industrial factories, algae, oysters shells)",true,Social media and local network,,,
|
||||||
|
,Lia Cara Schenkel,+4915225826793,Envoyer le message,lia.schenkel@myhsba.de,MarineBuddy Network,"Nina Schmits, Paula von Brand, Julietta Sivaschinski","Hambourg, Allemagne","Europe, Germany","HSBA Hamburg School of Business Administration, Hamburg",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,Corporate SeaAnimal Sponsorship to reduce the severe funding gap of SDG 14 - Life below water,false,from our lecturer,Received,https://drive.google.com/drive/folders/17y6Hh6ZWOr3_6dQxRN35NMYvkc3EZIaI?usp=drive_link,
|
||||||
|
,Gianfederico Guastalla,+41779802409,Envoyer le message,Info@marineseafloor.org,Marine Seafloor Conservation,Gianfederico Guastalla,"Lugano, Tessin, Suisse","Europe, Switzerland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-06-25,Reduction of pollution (plastics chemicals noise light...),Removing fishing ghost nets and gears from Mediterranean seafloor using a survey vessel and ROV.,true,,,,
|
||||||
|
,Bozhidar Hinkov,+359886319048,Envoyer le message,hinkov@arrchitects.com,Waves of renewal,"Martin Krastev, Jacquelline Dimitrova, Petko Bulkin","Sofia, Bulgarie","Bulgaria, Europe",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-07-03,Restoration of marine habitats & ecosystems,"The ""Waves of Renewal"" project represents more than a solution for a single location; it heralds a fundamental change in perspective and a universally applicable approach to addressing waterfront challenges and fostering ecological regeneration.
|
||||||
|
While the unique dilemma of Gyaros island – its stark past and rich marine biodiversity – served as our initial inspiration, the principles underpinning ""Waves of Renewal"" are designed for broad adaptability and scalability, offering a practical yet groundbreaking toolkit for numerous global contexts.We are not merely proposing a development; we are offering a replicable blueprint for transforming environmentally stressed and historically burdened coastal locations into beacons of sustainability and innovation.
|
||||||
|
The Gyaros Catalyst – A New Paradigm: Gyaros, with its legacy as a political prison and its delicate marine sanctuary, embodies the complex interplay between human history and natural ecosystems. It challenged us to think differently. Where many see insurmountable obstacles, Arrogant Architects identified an unparalleled opportunity: to develop and demonstrate a methodology where targeted, technologically advanced, and ecologically sensitive interventions allow the sea to heal the land, creating self-sufficient and thriving environments. This methodology, proven on Gyaros, is ready for wider application.
|
||||||
|
Our Solution – A Symphony of Sea and Land, Designed for Adaptability:
|
||||||
|
The ""Waves of Renewal"" masterplan is a holistic system, not a collection of disparate parts. Its core components are:
|
||||||
|
SEA CYCLE – The Floating Catalyst: A state-of-the-art, modular, and temporary marine research institute. Designed for minimal environmental impact and rapid deployment/retrieval, SEA CYCLE serves as an offshore hub for coastal ecosystem study, preservation, and the incubation of water management technologies. Its modularity allows for customized scale and function depending on site-specific needs. It is a living laboratory, not a pe",true,"We have been following the organization’s impactful work for several years now. However, we first officially learned about the MOPC through the professional network we built during our participation in the Smart and Sustainable Marinas event, organized by M3 a year ago.",,,
|
||||||
|
,Manuela MIRUKU,+33767491647,Envoyer le message,manuela.miruku@skema.edu,Track your Treasure,"Amandine BOUVET, Marie GIBOUDEAUX, Stéphanie ZHAO, Thérèse-Anne MONBUREAU","Paris, Île-de-France, France","Europe, France","Skema Business School, Paris, France",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),Track your Treasure tackles the problem of pollution in the ocean by helping fishermen locate and recover their lost equipment. Our approach would benefit both the environment and financial loss.,false,Our professor is deeply invested in the environmental issues and introduced the MOPC to us.,Received,https://drive.google.com/drive/folders/1UKTEqXH_0c2NCDc9Xn6mnRzphPVntN0Y?usp=drive_link,
|
||||||
|
,Hüseyin Çiloğlu,+905320659986,Envoyer le message,info@skywatt.tech,Floating Ocean Safe Water/Pikare SkySource,1.Hüseyin Çiloğlu 2.Hülya Özbudun 3. Nihat Akın Ercan 4. Hakan Uğur,"Istanbul, Turquie","Africa, Turkey",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-09-10,Restoration of marine habitats & ecosystems,"Protect Marine Life: Eliminate seabed intake pipes (saving billions of organisms) and reduce toxic brine discharge by 40%.
|
||||||
|
|
||||||
|
Provide Affordable Water: Produce off-grid clean water at a target cost of $0.025–$0.075 per liter.
|
||||||
|
|
||||||
|
Ensure Coastal Resilience: Deploy modular, scalable units without permanent coastal scarring or infrastructure damage.",true,LinkedIn / Social Media channels focusing on ocean sustainability and innovation challenges,Received,https://drive.google.com/drive/folders/1scPDdeiOKGk4GkbubRaQC6aZkGxKwK-_?usp=drive_link,
|
||||||
|
,Halima Mwazembe,+255758023760,Envoyer le message,halimamwazembe1@gmail.com,WasteGreen Company.,"Halima Mwazembe,Steward Amuli and Atupele Mwandenuka.","Dar es Salam, Tanzanie","Africa, Tanzania",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-11-02,Reduction of pollution (plastics chemicals noise light...),"WasteGreen is a Tanzanian-based plastic recycling company transforming plastic waste into valuable,sustainable products through innovative recycling technologies and strategic collaborations.The company produces high-quality plastic furniture designed from plastic lumber.
|
||||||
|
Our mission goes beyond recycling, We empower women and youth by creating jobs, promote environmental sustainability awareness and provide education to help mitigate environmental pollution.
|
||||||
|
WasteGreen Project supports communities, households and businesses by providing durable, eco-friendly products that reduce plastic pollution,clean our oceans and landfills.
|
||||||
|
We are committed to fostering
|
||||||
|
sustainability, improving living standards and building an inclusive circular economy that benefits both the environment and society.",true,I heard MOPC through social media(LinkedIn).,Received,https://drive.google.com/drive/folders/1gMf9O6zigb5bZyof7egT-XH2Nw277pfb?usp=drive_link,
|
||||||
|
,Amal Chebbi,+21694301242,Envoyer le message,amal.chebbi@pigmentoco.com,Pigmentoco,Dr. Ayoub Lassoued / Dr. Wided Fersi / Mahmoud Belhaouane,"Ben Arous, Tunisie","Africa, Tunisia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-06-06,Reduction of pollution (plastics chemicals noise light...),"PigmentOCO develops a water-free textile dyeing technology using supercritical CO₂, eliminating wastewater entirely from the dyeing process. Conventional textile dyeing generates highly saline and chemically loaded effluents that often reach rivers and coastal waters, contributing to marine pollution and ecosystem degradation.
|
||||||
|
|
||||||
|
Our objective is to prevent pollution at its source by removing water from the process altogether, rather than attempting costly post-treatment. By eliminating saline discharge and dye effluents, PigmentOCO directly reduces chemical pollution entering marine environments, particularly in coastal textile-producing regions.
|
||||||
|
|
||||||
|
The project aims to scale this technology across industrial textile hubs, contributing to SDG 14 by protecting marine ecosystems through systemic industrial innovation.",true,"I was referred to the Monaco Ocean Protection Challenge by an ecosystem builder at The Wave Global in Saudi Arabia, who believed PigmentOCO’s mission to prevent marine pollution through industrial innovation strongly aligns with the objectives of MOPC.",Received,https://drive.google.com/drive/folders/1yaU3NVGpghLQoygp_mOjVgy8S1ZxOhoc?usp=drive_link,
|
||||||
|
,Chloe So Tsz Man,+852 9884 5907,Envoyer le message,chloesotm@gmail.com,Our Gaia Museum,Chloe So,"Hong Kong, Hong Kong (Région admin. spéciale)",Asia,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"Our Gaia Museum is a museum that educates the world the importance of sustainability. Through art, we hope to create a better future by letting everyone appreciate nature of our Gaia.",true,LinkedIn,Received,https://drive.google.com/drive/folders/1QFDao_toPoF3jY2ufkWU9FJcwmkjWnmQ?usp=drive_link,
|
||||||
|
,Nele Christin Jordan,+4917622996672,Envoyer le message,nele.jordan@myhsba.de,Blue Score,"Jan Seller, Laura Volk Santamaria, Johannes Kopatz, Nele Jordan","Hambourg, Allemagne","Europe, Germany","Hamburg School of Business Administration, Hamburg",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Mitigation of ocean acidification,"Blue Score translates ocean acidification into financial risk scores to protect the Blue Economy. Our objective is to standardize marine risk monitoring, enabling institutions to mitigate hidden acidification threats while automating regulatory compliance.",false,Our Sustainability Professor suggested participating in the MOPC.,Received,https://drive.google.com/drive/folders/1FsGC6eNcB9T0ETB1lJSoakO_tpn2hGSF?usp=drive_link,
|
||||||
|
,Bozhidar Hinkov,+359886319048,Envoyer le message,hinkov@arrchitects.com,THE SQUARE SYSTEM,"Bozhidar Hinkov, Martin Krastev, Jacquelline Dimitrova, Alberto Carpanese, Camilla Bertolini, Eileen Horowitz, Petko Bulkin","Sofia, Bulgarie","Bulgaria, Europe",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-03-01,Technology & innovations,"Project Idea: THE SQUARE SYSTEM™
|
||||||
|
|
||||||
|
The project is a modular, regenerative ""man-made geology"" designed to transform inactive waterfronts into productive ecological hubs. Using a rigorous 6x6m grid, it creates a terraced public piazza that functions as both a social destination and a high-tech ""productive core"" for marine research and the circular economy.
|
||||||
|
|
||||||
|
Core Objectives
|
||||||
|
|
||||||
|
Decentralized Regeneration: To move beyond passive monuments toward ""living landscapes"" that generate more energy, biodiversity, and communal value than they consume.
|
||||||
|
|
||||||
|
Blue Circular Economy: To establish on-site ""Upcycling Hubs"" that intercept maritime waste (like decommissioned fiberglass boats) and transform it into new structural materials.
|
||||||
|
|
||||||
|
Energy Autonomy: To achieve 100% self-sufficiency through integrated floating photovoltaic farms and lagoon-sourced thermal regulation.
|
||||||
|
|
||||||
|
Relevant Details & Environmental Impact
|
||||||
|
|
||||||
|
Circular Material Innovation: The system pioneers a ""Precast Tectonic System."" The architectural skin uses recycled Glass-Reinforced Plastic (GRP) aggregates from local boat graveyards, creating a carbon-sequestering, terrazzo-like finish that tells the story of waste transformation.
|
||||||
|
|
||||||
|
Marine Habitat Restoration: The waterfront edge is designed as a ""Living Seawall""—using biologically receptive concrete to actively encourage the growth of local marine flora and fauna, turning infrastructure into a reef.
|
||||||
|
|
||||||
|
Minimal Intervention: Construction utilizes a steel pilot foundation system integrated into precast modules, allowing for ""surgical precision"" assembly from the seabed up with zero disruption to the delicate underwater ecosystem.
|
||||||
|
|
||||||
|
Integrated Programs: The framework houses Wet Labs for real-time water quality monitoring, ""Blue"" workshops for 3D-printing with reclaimed plastics, and closed-loop ""CEA"" (Controlled Environment Agriculture) to support sustainable local gastronomy.",true,"We've been long-time admirers of your initiatives. The deciding factor for our application was the positive feedback we received from the community at the Smart and Sustainable Marinas event by M3 last year, where we first heard about the MOPC.",,,
|
||||||
|
,Ivaldo De Jesus Fumo,+258846271354,Envoyer le message,ivaldodejesusfumo19@gmail.com,Ehopa Solutions,"Ivaldo Fumo, Gerson Simbine, Noemia Antonio","Beira, Sofala, Mozambique","Africa, Mozambic",Eduardo Mondlane University,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,"Project Idea and Objectives
|
||||||
|
|
||||||
|
EHOPA is a community-centred digital platform designed to strengthen small-scale fisheries in Mozambique by reconnecting fishers directly to markets, improving transparency, and enhancing sustainable resource management.
|
||||||
|
The project combines simple, low-bandwidth digital tools with strong on-the-ground support (“Tech + Touch”) to ensure inclusion in contexts where smartphone access and internet connectivity are limited. EHOPA will enable fishers to record catches, access fair market prices, reduce post-harvest losses, and connect directly with local buyers and consumers.
|
||||||
|
Objectives:
|
||||||
|
Increase income and price transparency for small-scale fishers.
|
||||||
|
Reduce post-harvest losses through better coordination and basic processing linkages.
|
||||||
|
Improve data collection on catch and effort to support sustainable fisheries management.
|
||||||
|
Strengthen co-management by sharing aggregated data with local authorities and associations.
|
||||||
|
Build digital literacy and community ownership of the platform.
|
||||||
|
The initiative will begin with a small pilot in one coastal community, focusing on practical validation, user trust, and gradual scaling based on measurable economic and social impact.",true,linkedin and I've applied before.,,,
|
||||||
|
,Alexandre Khaida,+33666390895,Envoyer le message,bulkoc.eu@gmail.com,BULKOC,Lamya Mahjoub & Abdelatif Majoub,France,"Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2015-09-02,Reduction of pollution (plastics chemicals noise light...),"BULKOC is an innovative antifouling system that protects boat hulls through the controlled diffusion of micro air bubbles across the entire submerged surface. Diffuser modules, positioned beneath the hull, generate a continuous and uniform flow of bubbles. As they rise along the hull, these bubbles create a dynamic barrier between the water and the vessel’s surface, preventing the adhesion of micro-organisms responsible for fouling.
|
||||||
|
BULKOC uses controlled micro-bubble diffusion to create a dynamic barrier between the hull and marine micro-organisms, preventing their adhesion without the use of chemical substances.
|
||||||
|
The protection is uniform, continuous, and highly effective when the vessel is stationary. The system operates preventively, without direct contact with the hull and without altering its structure.",true,"I discovered the MOPC while exploring key innovation platforms within the marine industry. Given BULKOC’s focus on sustainable antifouling technologies, the MOPC immediately stood out as a natural and inspiring fit.",Received,https://drive.google.com/drive/folders/1P4EudDWb0le8eugywuu5ZYeB4nhWkkJy?usp=drive_link,
|
||||||
|
,Jann Brunken,+4915151530049,Envoyer le message,brunken.jann@gmail.com,Marine Growth Solutions,"Joelle Geller, Jonah Dahncke, Gesine Hanebuth, Larissa Willhoeft","Hambourg, Allemagne","Europe, Germany",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-27,Reduction of pollution (plastics chemicals noise light...),Create a consulting network to make solutions against marine growth more accesible e.G. for ship owners and ship companies to reduce emissions,true,Our professor at university presented about it :),Received,https://drive.google.com/drive/folders/1bDHM3Ofug6hNPaASz7RAJ4yLolAADjma?usp=drive_link,
|
||||||
|
,Solomon Ekundayo,+2347037184018,Envoyer le message,ekundayosolomon10@gmail.com,Blue Mapping,"Rodney Ighalo, Bello Haleemah, Adebimpe Hamzat",Nigéria,"Africa, Nigeria","Project COR / Ogun state, Nigeria",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,"Blue Mapping is an inland-to-ocean pollution prevention project tackling plastic waste before it reaches the Atlantic Ocean. In Nigeria, rivers like the Ogun flow directly into the Lagos Lagoon, acting as hidden highways that carry mismanaged plastic waste from inland communities into marine ecosystems. This land-based pollution is silently damaging fisheries, mangroves, livelihoods, and the blue-economy potential of coastal communities.
|
||||||
|
|
||||||
|
Blue Mapping addresses this gap by using high-resolution drones and AI to map, detect, and quantify plastic waste along riverbanks and lagoons in Ogun and Oyo States. The project creates Nigeria’s first open-access Plastic Waste Distribution Database, giving governments, conservation actors, and donors the real-time evidence needed to design targeted, upstream interventions.
|
||||||
|
|
||||||
|
Beyond technology, Blue Mapping trains local youth and students as drone operators, embedding skills, ownership, and awareness within affected communities. By stopping plastic at the source before it reaches the ocean, the project protects marine ecosystems, supports coastal livelihoods, and connects inland action to ocean protection in a practical, scalable way.",true,Through a friend,Received,https://drive.google.com/drive/folders/1KGSMBzTOe296tvB2mA_EiPom7UB-W2zy?usp=drive_link,
|
||||||
|
,Mohamed Elamir,+358449737733,Envoyer le message,mohamed.elamir@sealeva.com,Sealevä,"Xuefei Shi , Ani Järvimäki, Mohamed Elamir, Coleman Piburn, Botond Kiss, Sini Kanerva","Helsinki, Finlande du Sud, Finlande","Europe, Finland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-09-01,Other,"Sealevä valorizes industrial seaweed waste into high-performance materials. From injection-moldable pellets to 3D printing filaments, our materials contain 30-100% seaweed content and offer unique materiality and natural aesthetics. All products are supported by verified LCA data.
|
||||||
|
* 85% lower CO₂ vs conventional bioplastics
|
||||||
|
* 30-100% bio-content options
|
||||||
|
* No fresh water or land mass required for production",true,Found through Blue biomatch community,Received,https://drive.google.com/drive/folders/1oxf8C8elWn8Yfnc0Nodp_IYpIJ4tHgA4?usp=drive_link,
|
||||||
|
,Anthony Akpan,+2347068329371,Envoyer le message,ajakpan@yahoo.com,Nigeria Youth Blue Economy Value-Chain Local Capacities Development Project (NYBE-VCLCD),"Anthony Akpan, Victoria Aghaji, Solomon Ekundayo","Lagos, Nigéria","Africa, Nigeria",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2007-12-04,Capacity building for coastal communities,"The goal of this project is to empower 10,000 Nigerian youths, aged 18 to 35, by providing specialized training in impactful value chains within the blue economy. It aims to shift traditional maritime practices towards a sustainable ""Blue Growth"" model, utilizing Nigeria’s 850km coastline to tackle unemployment and safeguard marine biodiversity.
|
||||||
|
|
||||||
|
The project zeroes in on four essential sectors:
|
||||||
|
- Sustainable Aquaculture: Expanding eco-friendly fish farming methods that curb nutrient pollution and protect wild fish populations.
|
||||||
|
- Marine Biotechnology & Waste-to-Value: Equipping youth with the skills to transform marine waste (both plastic and organic) into bio-fertilizers or sustainable packaging solutions.
|
||||||
|
- Eco-Tourism & Coastal Management: Professionalizing youth-led eco-tours and marine protection services, including roles akin to a ""Coast Guard"" to fight against piracy and pollution.
|
||||||
|
- Renewable Marine Energy: Building technical expertise for the maintenance of offshore wind and tidal energy infrastructures.
|
||||||
|
|
||||||
|
Innovative Business Model: The project introduces a ""Digital Blue Hub"" that connects trained youth with investors from the Blue Economy & Finance Forum (BEFF), ensuring their business ideas are market-ready.
|
||||||
|
Environmental Impact: A crucial 20% of the training is dedicated to ocean literacy and conservation, with the aim of achieving a measurable decrease in coastal plastic waste.",true,"Through Professor Ronán Long, Director, WMU-Sasakawa Global Ocean Institute, Nippon Foundation Professorial Chair of Ocean Governance & the Law of the Sea, World Maritime University (WMU) of the International Maritime Organization (IMO), a Specialized Agency of the United Nations.",Received,https://drive.google.com/drive/folders/1pdYCcCq7-SqLIiBomyF0kyQKAZzRxOhq?usp=drive_link,
|
||||||
|
,Logan Kiser,+16462518100,Envoyer le message,solutions@kisertech.io,Kiser Technologies,Logan Kiser,"WY, États-Unis",US,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-30,Technology & innovations,Using private AI for any business need.,true,Gemini,,,
|
||||||
|
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.
|
||||||
@@ -2,10 +2,20 @@ import type { NextConfig } from 'next'
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
typedRoutes: true,
|
|
||||||
serverExternalPackages: ['@prisma/client', 'minio'],
|
serverExternalPackages: ['@prisma/client', 'minio'],
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: false,
|
||||||
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ['lucide-react'],
|
optimizePackageImports: [
|
||||||
|
'lucide-react',
|
||||||
|
'sonner',
|
||||||
|
'date-fns',
|
||||||
|
'recharts',
|
||||||
|
'motion/react',
|
||||||
|
'zod',
|
||||||
|
'@radix-ui/react-icons',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
@@ -40,12 +50,12 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/applicant/pipeline',
|
source: '/applicant/pipeline',
|
||||||
destination: '/applicant/competitions',
|
destination: '/applicant/competition',
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/applicant/pipeline/:path*',
|
source: '/applicant/pipeline/:path*',
|
||||||
destination: '/applicant/competitions',
|
destination: '/applicant/competition',
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
3378
package-lock.json
generated
3378
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
"prebuild": "node -e \"require('fs').writeFileSync('public/build-id.json', JSON.stringify({buildId: Date.now().toString()}))\"",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
@@ -21,16 +22,15 @@
|
|||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.78.0",
|
||||||
"@auth/prisma-adapter": "^2.7.4",
|
"@auth/prisma-adapter": "^2.7.4",
|
||||||
"@blocknote/core": "^0.46.2",
|
"@blocknote/core": "^0.46.2",
|
||||||
"@blocknote/mantine": "^0.46.2",
|
|
||||||
"@blocknote/react": "^0.46.2",
|
"@blocknote/react": "^0.46.2",
|
||||||
|
"@blocknote/shadcn": "^0.46.2",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@mantine/core": "^8.3.13",
|
|
||||||
"@mantine/hooks": "^8.3.13",
|
|
||||||
"@notionhq/client": "^2.3.0",
|
"@notionhq/client": "^2.3.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
@@ -50,9 +50,11 @@
|
|||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"@tremor/react": "^3.18.7",
|
||||||
"@trpc/client": "^11.0.0-rc.678",
|
"@trpc/client": "^11.0.0-rc.678",
|
||||||
"@trpc/react-query": "^11.0.0-rc.678",
|
"@trpc/react-query": "^11.0.0-rc.678",
|
||||||
"@trpc/server": "^11.0.0-rc.678",
|
"@trpc/server": "^11.0.0-rc.678",
|
||||||
@@ -62,11 +64,13 @@
|
|||||||
"cmdk": "^1.0.4",
|
"cmdk": "^1.0.4",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"franc": "^6.2.0",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^4.1.0",
|
"jspdf": "^4.1.0",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
|
"mammoth": "^1.11.0",
|
||||||
"minio": "^8.0.2",
|
"minio": "^8.0.2",
|
||||||
"motion": "^11.15.0",
|
"motion": "^11.15.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
@@ -75,6 +79,7 @@
|
|||||||
"nodemailer": "^7.0.7",
|
"nodemailer": "^7.0.7",
|
||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
|
"pdf-parse": "^2.4.5",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -82,20 +87,22 @@
|
|||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-phone-number-input": "^3.4.14",
|
"react-phone-number-input": "^3.4.14",
|
||||||
"recharts": "^3.7.0",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"superjson": "^2.2.2",
|
"superjson": "^2.2.2",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"unpdf": "^1.4.0",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.49.1",
|
"@playwright/test": "^1.49.1",
|
||||||
|
"@react-grab/mcp": "^0.1.25",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/papaparse": "^5.3.15",
|
"@types/papaparse": "^5.3.15",
|
||||||
|
"@types/pdf-parse": "^1.1.5",
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
@@ -104,6 +111,7 @@
|
|||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
|
"react-grab": "^0.1.25",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
|
|||||||
@@ -16,105 +16,143 @@
|
|||||||
-- the enum.
|
-- the enum.
|
||||||
|
|
||||||
|
|
||||||
ALTER TYPE "SettingCategory" ADD VALUE 'DIGEST';
|
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'DIGEST'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TYPE "SettingCategory" ADD VALUE 'ANALYTICS';
|
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'ANALYTICS'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TYPE "SettingCategory" ADD VALUE 'AUDIT_CONFIG';
|
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'AUDIT_CONFIG'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TYPE "SettingCategory" ADD VALUE 'INTEGRATIONS';
|
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'INTEGRATIONS'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TYPE "SettingCategory" ADD VALUE 'LOCALIZATION';
|
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'LOCALIZATION'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TYPE "SettingCategory" ADD VALUE 'COMMUNICATION';
|
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'COMMUNICATION'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- DropForeignKey
|
-- DropForeignKey
|
||||||
ALTER TABLE "ApplicationForm" DROP CONSTRAINT "ApplicationForm_programId_fkey";
|
ALTER TABLE "ApplicationForm" DROP CONSTRAINT IF EXISTS "ApplicationForm_programId_fkey";
|
||||||
|
|
||||||
-- DropForeignKey
|
-- DropForeignKey
|
||||||
ALTER TABLE "ApplicationForm" DROP CONSTRAINT "ApplicationForm_roundId_fkey";
|
ALTER TABLE "ApplicationForm" DROP CONSTRAINT IF EXISTS "ApplicationForm_roundId_fkey";
|
||||||
|
|
||||||
-- DropForeignKey
|
-- DropForeignKey
|
||||||
ALTER TABLE "ApplicationFormField" DROP CONSTRAINT "ApplicationFormField_formId_fkey";
|
ALTER TABLE "ApplicationFormField" DROP CONSTRAINT IF EXISTS "ApplicationFormField_formId_fkey";
|
||||||
|
|
||||||
-- DropForeignKey
|
-- DropForeignKey
|
||||||
ALTER TABLE "ApplicationFormField" DROP CONSTRAINT "ApplicationFormField_stepId_fkey";
|
ALTER TABLE "ApplicationFormField" DROP CONSTRAINT IF EXISTS "ApplicationFormField_stepId_fkey";
|
||||||
|
|
||||||
-- DropForeignKey
|
-- DropForeignKey
|
||||||
ALTER TABLE "ApplicationFormSubmission" DROP CONSTRAINT "ApplicationFormSubmission_formId_fkey";
|
ALTER TABLE "ApplicationFormSubmission" DROP CONSTRAINT IF EXISTS "ApplicationFormSubmission_formId_fkey";
|
||||||
|
|
||||||
-- DropForeignKey
|
-- DropForeignKey
|
||||||
ALTER TABLE "OnboardingStep" DROP CONSTRAINT "OnboardingStep_formId_fkey";
|
ALTER TABLE "OnboardingStep" DROP CONSTRAINT IF EXISTS "OnboardingStep_formId_fkey";
|
||||||
|
|
||||||
-- DropForeignKey
|
-- DropForeignKey
|
||||||
ALTER TABLE "SubmissionFile" DROP CONSTRAINT "SubmissionFile_submissionId_fkey";
|
ALTER TABLE "SubmissionFile" DROP CONSTRAINT IF EXISTS "SubmissionFile_submissionId_fkey";
|
||||||
|
|
||||||
-- DropIndex
|
-- DropIndex
|
||||||
DROP INDEX "User_email_idx";
|
DROP INDEX IF EXISTS "User_email_idx";
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "AssignmentJob" ALTER COLUMN "updatedAt" DROP DEFAULT;
|
DO $$ BEGIN ALTER TABLE "AssignmentJob" ALTER COLUMN "updatedAt" DROP DEFAULT; EXCEPTION WHEN others THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "AuditLog" ADD COLUMN "previousDataJson" JSONB,
|
DO $$ BEGIN
|
||||||
ADD COLUMN "sessionId" TEXT;
|
ALTER TABLE "AuditLog" ADD COLUMN "previousDataJson" JSONB;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AuditLog" ADD COLUMN "sessionId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "FilteringJob" ALTER COLUMN "updatedAt" DROP DEFAULT;
|
DO $$ BEGIN ALTER TABLE "FilteringJob" ALTER COLUMN "updatedAt" DROP DEFAULT; EXCEPTION WHEN others THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "LiveVote" ADD COLUMN "isAudienceVote" BOOLEAN NOT NULL DEFAULT false;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "LiveVote" ADD COLUMN "isAudienceVote" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "LiveVotingSession" ADD COLUMN "allowAudienceVotes" BOOLEAN NOT NULL DEFAULT false,
|
DO $$ BEGIN
|
||||||
ADD COLUMN "audienceVoteWeight" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
ALTER TABLE "LiveVotingSession" ADD COLUMN "allowAudienceVotes" BOOLEAN NOT NULL DEFAULT false;
|
||||||
ADD COLUMN "presentationSettingsJson" JSONB,
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
ADD COLUMN "tieBreakerMethod" TEXT NOT NULL DEFAULT 'admin_decides';
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "LiveVotingSession" ADD COLUMN "audienceVoteWeight" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "LiveVotingSession" ADD COLUMN "presentationSettingsJson" JSONB;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "LiveVotingSession" ADD COLUMN "tieBreakerMethod" TEXT NOT NULL DEFAULT 'admin_decides';
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "MentorAssignment" ADD COLUMN "completionStatus" TEXT NOT NULL DEFAULT 'in_progress',
|
DO $$ BEGIN
|
||||||
ADD COLUMN "lastViewedAt" TIMESTAMP(3);
|
ALTER TABLE "MentorAssignment" ADD COLUMN "completionStatus" TEXT NOT NULL DEFAULT 'in_progress';
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorAssignment" ADD COLUMN "lastViewedAt" TIMESTAMP(3);
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "NotificationEmailSetting" ALTER COLUMN "updatedAt" DROP DEFAULT;
|
DO $$ BEGIN ALTER TABLE "NotificationEmailSetting" ALTER COLUMN "updatedAt" DROP DEFAULT; EXCEPTION WHEN others THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "Project" ADD COLUMN "draftDataJson" JSONB,
|
DO $$ BEGIN
|
||||||
ADD COLUMN "draftExpiresAt" TIMESTAMP(3),
|
ALTER TABLE "Project" ADD COLUMN "draftDataJson" JSONB;
|
||||||
ADD COLUMN "isDraft" BOOLEAN NOT NULL DEFAULT false;
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Project" ADD COLUMN "draftExpiresAt" TIMESTAMP(3);
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Project" ADD COLUMN "isDraft" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "ProjectFile" ADD COLUMN "isLate" BOOLEAN NOT NULL DEFAULT false,
|
DO $$ BEGIN
|
||||||
ADD COLUMN "replacedById" TEXT,
|
ALTER TABLE "ProjectFile" ADD COLUMN "isLate" BOOLEAN NOT NULL DEFAULT false;
|
||||||
ADD COLUMN "roundId" TEXT,
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "replacedById" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "roundId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "TaggingJob" ALTER COLUMN "updatedAt" DROP DEFAULT;
|
DO $$ BEGIN ALTER TABLE "TaggingJob" ALTER COLUMN "updatedAt" DROP DEFAULT; EXCEPTION WHEN others THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "User" ADD COLUMN "availabilityJson" JSONB,
|
DO $$ BEGIN
|
||||||
ADD COLUMN "digestFrequency" TEXT NOT NULL DEFAULT 'none',
|
ALTER TABLE "User" ADD COLUMN "availabilityJson" JSONB;
|
||||||
ADD COLUMN "preferredWorkload" INTEGER;
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "User" ADD COLUMN "digestFrequency" TEXT NOT NULL DEFAULT 'none';
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "User" ADD COLUMN "preferredWorkload" INTEGER;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- DropTable
|
-- DropTable
|
||||||
DROP TABLE "ApplicationForm";
|
DROP TABLE IF EXISTS "ApplicationForm";
|
||||||
|
|
||||||
-- DropTable
|
-- DropTable
|
||||||
DROP TABLE "ApplicationFormField";
|
DROP TABLE IF EXISTS "ApplicationFormField";
|
||||||
|
|
||||||
-- DropTable
|
-- DropTable
|
||||||
DROP TABLE "ApplicationFormSubmission";
|
DROP TABLE IF EXISTS "ApplicationFormSubmission";
|
||||||
|
|
||||||
-- DropTable
|
-- DropTable
|
||||||
DROP TABLE "OnboardingStep";
|
DROP TABLE IF EXISTS "OnboardingStep";
|
||||||
|
|
||||||
-- DropTable
|
-- DropTable
|
||||||
DROP TABLE "SubmissionFile";
|
DROP TABLE IF EXISTS "SubmissionFile";
|
||||||
|
|
||||||
-- DropEnum
|
-- DropEnum
|
||||||
DROP TYPE "FormFieldType";
|
DROP TYPE IF EXISTS "FormFieldType";
|
||||||
|
|
||||||
-- DropEnum
|
-- DropEnum
|
||||||
DROP TYPE "SpecialFieldType";
|
DROP TYPE IF EXISTS "SpecialFieldType";
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "ReminderLog" (
|
CREATE TABLE IF NOT EXISTS "ReminderLog" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
@@ -125,7 +163,7 @@ CREATE TABLE "ReminderLog" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "ConflictOfInterest" (
|
CREATE TABLE IF NOT EXISTS "ConflictOfInterest" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"assignmentId" TEXT NOT NULL,
|
"assignmentId" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
@@ -143,7 +181,7 @@ CREATE TABLE "ConflictOfInterest" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "EvaluationSummary" (
|
CREATE TABLE IF NOT EXISTS "EvaluationSummary" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"projectId" TEXT NOT NULL,
|
"projectId" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
@@ -157,7 +195,7 @@ CREATE TABLE "EvaluationSummary" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "ProjectStatusHistory" (
|
CREATE TABLE IF NOT EXISTS "ProjectStatusHistory" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"projectId" TEXT NOT NULL,
|
"projectId" TEXT NOT NULL,
|
||||||
"status" "ProjectStatus" NOT NULL,
|
"status" "ProjectStatus" NOT NULL,
|
||||||
@@ -168,7 +206,7 @@ CREATE TABLE "ProjectStatusHistory" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "MentorMessage" (
|
CREATE TABLE IF NOT EXISTS "MentorMessage" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"projectId" TEXT NOT NULL,
|
"projectId" TEXT NOT NULL,
|
||||||
"senderId" TEXT NOT NULL,
|
"senderId" TEXT NOT NULL,
|
||||||
@@ -180,7 +218,7 @@ CREATE TABLE "MentorMessage" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "DigestLog" (
|
CREATE TABLE IF NOT EXISTS "DigestLog" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
"digestType" TEXT NOT NULL,
|
"digestType" TEXT NOT NULL,
|
||||||
@@ -191,7 +229,7 @@ CREATE TABLE "DigestLog" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "RoundTemplate" (
|
CREATE TABLE IF NOT EXISTS "RoundTemplate" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
"description" TEXT,
|
"description" TEXT,
|
||||||
@@ -208,7 +246,7 @@ CREATE TABLE "RoundTemplate" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "MentorNote" (
|
CREATE TABLE IF NOT EXISTS "MentorNote" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"mentorAssignmentId" TEXT NOT NULL,
|
"mentorAssignmentId" TEXT NOT NULL,
|
||||||
"authorId" TEXT NOT NULL,
|
"authorId" TEXT NOT NULL,
|
||||||
@@ -221,7 +259,7 @@ CREATE TABLE "MentorNote" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "MentorMilestone" (
|
CREATE TABLE IF NOT EXISTS "MentorMilestone" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"programId" TEXT NOT NULL,
|
"programId" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
@@ -236,7 +274,7 @@ CREATE TABLE "MentorMilestone" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "MentorMilestoneCompletion" (
|
CREATE TABLE IF NOT EXISTS "MentorMilestoneCompletion" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"milestoneId" TEXT NOT NULL,
|
"milestoneId" TEXT NOT NULL,
|
||||||
"mentorAssignmentId" TEXT NOT NULL,
|
"mentorAssignmentId" TEXT NOT NULL,
|
||||||
@@ -247,7 +285,7 @@ CREATE TABLE "MentorMilestoneCompletion" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "Message" (
|
CREATE TABLE IF NOT EXISTS "Message" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"senderId" TEXT NOT NULL,
|
"senderId" TEXT NOT NULL,
|
||||||
"recipientType" TEXT NOT NULL,
|
"recipientType" TEXT NOT NULL,
|
||||||
@@ -266,7 +304,7 @@ CREATE TABLE "Message" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "MessageTemplate" (
|
CREATE TABLE IF NOT EXISTS "MessageTemplate" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
"category" TEXT NOT NULL,
|
"category" TEXT NOT NULL,
|
||||||
@@ -282,7 +320,7 @@ CREATE TABLE "MessageTemplate" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "MessageRecipient" (
|
CREATE TABLE IF NOT EXISTS "MessageRecipient" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"messageId" TEXT NOT NULL,
|
"messageId" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
@@ -295,7 +333,7 @@ CREATE TABLE "MessageRecipient" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "Webhook" (
|
CREATE TABLE IF NOT EXISTS "Webhook" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
"url" TEXT NOT NULL,
|
"url" TEXT NOT NULL,
|
||||||
@@ -312,7 +350,7 @@ CREATE TABLE "Webhook" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "WebhookDelivery" (
|
CREATE TABLE IF NOT EXISTS "WebhookDelivery" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"webhookId" TEXT NOT NULL,
|
"webhookId" TEXT NOT NULL,
|
||||||
"event" TEXT NOT NULL,
|
"event" TEXT NOT NULL,
|
||||||
@@ -328,7 +366,7 @@ CREATE TABLE "WebhookDelivery" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "EvaluationDiscussion" (
|
CREATE TABLE IF NOT EXISTS "EvaluationDiscussion" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"projectId" TEXT NOT NULL,
|
"projectId" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
@@ -341,7 +379,7 @@ CREATE TABLE "EvaluationDiscussion" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "DiscussionComment" (
|
CREATE TABLE IF NOT EXISTS "DiscussionComment" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"discussionId" TEXT NOT NULL,
|
"discussionId" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
@@ -352,199 +390,257 @@ CREATE TABLE "DiscussionComment" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "ReminderLog_roundId_idx" ON "ReminderLog"("roundId");
|
CREATE INDEX IF NOT EXISTS "ReminderLog_roundId_idx" ON "ReminderLog"("roundId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "ReminderLog_roundId_userId_type_key" ON "ReminderLog"("roundId", "userId", "type");
|
CREATE UNIQUE INDEX IF NOT EXISTS "ReminderLog_roundId_userId_type_key" ON "ReminderLog"("roundId", "userId", "type");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "ConflictOfInterest_assignmentId_key" ON "ConflictOfInterest"("assignmentId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "ConflictOfInterest_assignmentId_key" ON "ConflictOfInterest"("assignmentId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "ConflictOfInterest_userId_idx" ON "ConflictOfInterest"("userId");
|
CREATE INDEX IF NOT EXISTS "ConflictOfInterest_userId_idx" ON "ConflictOfInterest"("userId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "ConflictOfInterest_roundId_hasConflict_idx" ON "ConflictOfInterest"("roundId", "hasConflict");
|
CREATE INDEX IF NOT EXISTS "ConflictOfInterest_roundId_hasConflict_idx" ON "ConflictOfInterest"("roundId", "hasConflict");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "EvaluationSummary_roundId_idx" ON "EvaluationSummary"("roundId");
|
CREATE INDEX IF NOT EXISTS "EvaluationSummary_roundId_idx" ON "EvaluationSummary"("roundId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "EvaluationSummary_projectId_roundId_key" ON "EvaluationSummary"("projectId", "roundId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "EvaluationSummary_projectId_roundId_key" ON "EvaluationSummary"("projectId", "roundId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "ProjectStatusHistory_projectId_changedAt_idx" ON "ProjectStatusHistory"("projectId", "changedAt");
|
CREATE INDEX IF NOT EXISTS "ProjectStatusHistory_projectId_changedAt_idx" ON "ProjectStatusHistory"("projectId", "changedAt");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MentorMessage_projectId_createdAt_idx" ON "MentorMessage"("projectId", "createdAt");
|
CREATE INDEX IF NOT EXISTS "MentorMessage_projectId_createdAt_idx" ON "MentorMessage"("projectId", "createdAt");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "DigestLog_userId_idx" ON "DigestLog"("userId");
|
CREATE INDEX IF NOT EXISTS "DigestLog_userId_idx" ON "DigestLog"("userId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "DigestLog_sentAt_idx" ON "DigestLog"("sentAt");
|
CREATE INDEX IF NOT EXISTS "DigestLog_sentAt_idx" ON "DigestLog"("sentAt");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "RoundTemplate_programId_idx" ON "RoundTemplate"("programId");
|
CREATE INDEX IF NOT EXISTS "RoundTemplate_programId_idx" ON "RoundTemplate"("programId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MentorNote_mentorAssignmentId_idx" ON "MentorNote"("mentorAssignmentId");
|
CREATE INDEX IF NOT EXISTS "MentorNote_mentorAssignmentId_idx" ON "MentorNote"("mentorAssignmentId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MentorMilestone_programId_idx" ON "MentorMilestone"("programId");
|
CREATE INDEX IF NOT EXISTS "MentorMilestone_programId_idx" ON "MentorMilestone"("programId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MentorMilestone_sortOrder_idx" ON "MentorMilestone"("sortOrder");
|
CREATE INDEX IF NOT EXISTS "MentorMilestone_sortOrder_idx" ON "MentorMilestone"("sortOrder");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MentorMilestoneCompletion_mentorAssignmentId_idx" ON "MentorMilestoneCompletion"("mentorAssignmentId");
|
CREATE INDEX IF NOT EXISTS "MentorMilestoneCompletion_mentorAssignmentId_idx" ON "MentorMilestoneCompletion"("mentorAssignmentId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "MentorMilestoneCompletion_milestoneId_mentorAssignmentId_key" ON "MentorMilestoneCompletion"("milestoneId", "mentorAssignmentId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "MentorMilestoneCompletion_milestoneId_mentorAssignmentId_key" ON "MentorMilestoneCompletion"("milestoneId", "mentorAssignmentId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "Message_senderId_idx" ON "Message"("senderId");
|
CREATE INDEX IF NOT EXISTS "Message_senderId_idx" ON "Message"("senderId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "Message_sentAt_idx" ON "Message"("sentAt");
|
CREATE INDEX IF NOT EXISTS "Message_sentAt_idx" ON "Message"("sentAt");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "Message_scheduledAt_idx" ON "Message"("scheduledAt");
|
CREATE INDEX IF NOT EXISTS "Message_scheduledAt_idx" ON "Message"("scheduledAt");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MessageTemplate_category_idx" ON "MessageTemplate"("category");
|
CREATE INDEX IF NOT EXISTS "MessageTemplate_category_idx" ON "MessageTemplate"("category");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MessageTemplate_isActive_idx" ON "MessageTemplate"("isActive");
|
CREATE INDEX IF NOT EXISTS "MessageTemplate_isActive_idx" ON "MessageTemplate"("isActive");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MessageRecipient_messageId_idx" ON "MessageRecipient"("messageId");
|
CREATE INDEX IF NOT EXISTS "MessageRecipient_messageId_idx" ON "MessageRecipient"("messageId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "MessageRecipient_userId_isRead_idx" ON "MessageRecipient"("userId", "isRead");
|
CREATE INDEX IF NOT EXISTS "MessageRecipient_userId_isRead_idx" ON "MessageRecipient"("userId", "isRead");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "Webhook_isActive_idx" ON "Webhook"("isActive");
|
CREATE INDEX IF NOT EXISTS "Webhook_isActive_idx" ON "Webhook"("isActive");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "WebhookDelivery_webhookId_idx" ON "WebhookDelivery"("webhookId");
|
CREATE INDEX IF NOT EXISTS "WebhookDelivery_webhookId_idx" ON "WebhookDelivery"("webhookId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "WebhookDelivery_status_idx" ON "WebhookDelivery"("status");
|
CREATE INDEX IF NOT EXISTS "WebhookDelivery_status_idx" ON "WebhookDelivery"("status");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "WebhookDelivery_createdAt_idx" ON "WebhookDelivery"("createdAt");
|
CREATE INDEX IF NOT EXISTS "WebhookDelivery_createdAt_idx" ON "WebhookDelivery"("createdAt");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "EvaluationDiscussion_roundId_idx" ON "EvaluationDiscussion"("roundId");
|
CREATE INDEX IF NOT EXISTS "EvaluationDiscussion_roundId_idx" ON "EvaluationDiscussion"("roundId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "EvaluationDiscussion_status_idx" ON "EvaluationDiscussion"("status");
|
CREATE INDEX IF NOT EXISTS "EvaluationDiscussion_status_idx" ON "EvaluationDiscussion"("status");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "EvaluationDiscussion_projectId_roundId_key" ON "EvaluationDiscussion"("projectId", "roundId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "EvaluationDiscussion_projectId_roundId_key" ON "EvaluationDiscussion"("projectId", "roundId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "DiscussionComment_discussionId_createdAt_idx" ON "DiscussionComment"("discussionId", "createdAt");
|
CREATE INDEX IF NOT EXISTS "DiscussionComment_discussionId_createdAt_idx" ON "DiscussionComment"("discussionId", "createdAt");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "AuditLog_entityType_entityId_timestamp_idx" ON "AuditLog"("entityType", "entityId", "timestamp");
|
CREATE INDEX IF NOT EXISTS "AuditLog_entityType_entityId_timestamp_idx" ON "AuditLog"("entityType", "entityId", "timestamp");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "Evaluation_status_formId_idx" ON "Evaluation"("status", "formId");
|
CREATE INDEX IF NOT EXISTS "Evaluation_status_formId_idx" ON "Evaluation"("status", "formId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "GracePeriod_roundId_userId_extendedUntil_idx" ON "GracePeriod"("roundId", "userId", "extendedUntil");
|
CREATE INDEX IF NOT EXISTS "GracePeriod_roundId_userId_extendedUntil_idx" ON "GracePeriod"("roundId", "userId", "extendedUntil");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "LiveVote_isAudienceVote_idx" ON "LiveVote"("isAudienceVote");
|
CREATE INDEX IF NOT EXISTS "LiveVote_isAudienceVote_idx" ON "LiveVote"("isAudienceVote");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "ProjectFile_roundId_idx" ON "ProjectFile"("roundId");
|
CREATE INDEX IF NOT EXISTS "ProjectFile_roundId_idx" ON "ProjectFile"("roundId");
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_winnerOverriddenBy_fkey" FOREIGN KEY ("winnerOverriddenBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_winnerOverriddenBy_fkey" FOREIGN KEY ("winnerOverriddenBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ReminderLog" ADD CONSTRAINT "ReminderLog_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ReminderLog" ADD CONSTRAINT "ReminderLog_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ReminderLog" ADD CONSTRAINT "ReminderLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ReminderLog" ADD CONSTRAINT "ReminderLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "EvaluationSummary" ADD CONSTRAINT "EvaluationSummary_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationSummary" ADD CONSTRAINT "EvaluationSummary_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "EvaluationSummary" ADD CONSTRAINT "EvaluationSummary_generatedById_fkey" FOREIGN KEY ("generatedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationSummary" ADD CONSTRAINT "EvaluationSummary_generatedById_fkey" FOREIGN KEY ("generatedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ProjectStatusHistory" ADD CONSTRAINT "ProjectStatusHistory_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectStatusHistory" ADD CONSTRAINT "ProjectStatusHistory_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MentorMessage" ADD CONSTRAINT "MentorMessage_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorMessage" ADD CONSTRAINT "MentorMessage_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MentorMessage" ADD CONSTRAINT "MentorMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorMessage" ADD CONSTRAINT "MentorMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "DigestLog" ADD CONSTRAINT "DigestLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DigestLog" ADD CONSTRAINT "DigestLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MentorNote" ADD CONSTRAINT "MentorNote_mentorAssignmentId_fkey" FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorNote" ADD CONSTRAINT "MentorNote_mentorAssignmentId_fkey" FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MentorNote" ADD CONSTRAINT "MentorNote_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorNote" ADD CONSTRAINT "MentorNote_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MentorMilestone" ADD CONSTRAINT "MentorMilestone_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorMilestone" ADD CONSTRAINT "MentorMilestone_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MentorMilestoneCompletion" ADD CONSTRAINT "MentorMilestoneCompletion_milestoneId_fkey" FOREIGN KEY ("milestoneId") REFERENCES "MentorMilestone"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorMilestoneCompletion" ADD CONSTRAINT "MentorMilestoneCompletion_milestoneId_fkey" FOREIGN KEY ("milestoneId") REFERENCES "MentorMilestone"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MentorMilestoneCompletion" ADD CONSTRAINT "MentorMilestoneCompletion_mentorAssignmentId_fkey" FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorMilestoneCompletion" ADD CONSTRAINT "MentorMilestoneCompletion_mentorAssignmentId_fkey" FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MentorMilestoneCompletion" ADD CONSTRAINT "MentorMilestoneCompletion_completedById_fkey" FOREIGN KEY ("completedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorMilestoneCompletion" ADD CONSTRAINT "MentorMilestoneCompletion_completedById_fkey" FOREIGN KEY ("completedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "Message" ADD CONSTRAINT "Message_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Message" ADD CONSTRAINT "Message_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "Message" ADD CONSTRAINT "Message_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "MessageTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Message" ADD CONSTRAINT "Message_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "MessageTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MessageRecipient" ADD CONSTRAINT "MessageRecipient_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MessageRecipient" ADD CONSTRAINT "MessageRecipient_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "MessageRecipient" ADD CONSTRAINT "MessageRecipient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MessageRecipient" ADD CONSTRAINT "MessageRecipient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "Webhook"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "Webhook"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_closedById_fkey" FOREIGN KEY ("closedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_closedById_fkey" FOREIGN KEY ("closedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "DiscussionComment" ADD CONSTRAINT "DiscussionComment_discussionId_fkey" FOREIGN KEY ("discussionId") REFERENCES "EvaluationDiscussion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DiscussionComment" ADD CONSTRAINT "DiscussionComment_discussionId_fkey" FOREIGN KEY ("discussionId") REFERENCES "EvaluationDiscussion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "DiscussionComment" ADD CONSTRAINT "DiscussionComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DiscussionComment" ADD CONSTRAINT "DiscussionComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|||||||
@@ -6,36 +6,46 @@
|
|||||||
-- Missing Foreign Keys
|
-- Missing Foreign Keys
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|
||||||
-- RoundTemplate → Program
|
-- RoundTemplate -> Program
|
||||||
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_programId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_programId_fkey"
|
||||||
|
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- RoundTemplate → User (creator)
|
-- RoundTemplate -> User (creator)
|
||||||
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_createdBy_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_createdBy_fkey"
|
||||||
|
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- Message → Round
|
-- Message -> Round
|
||||||
ALTER TABLE "Message" ADD CONSTRAINT "Message_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
ALTER TABLE "Message" ADD CONSTRAINT "Message_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- EvaluationDiscussion → Round
|
-- EvaluationDiscussion -> Round
|
||||||
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ProjectFile → ProjectFile (self-relation for file versioning)
|
-- ProjectFile -> ProjectFile (self-relation for file versioning)
|
||||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_replacedById_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("replacedById") REFERENCES "ProjectFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_replacedById_fkey"
|
||||||
|
FOREIGN KEY ("replacedById") REFERENCES "ProjectFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- Missing Indexes
|
-- Missing Indexes
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|
||||||
CREATE INDEX "RoundTemplate_roundType_idx" ON "RoundTemplate"("roundType");
|
CREATE INDEX IF NOT EXISTS "RoundTemplate_roundType_idx" ON "RoundTemplate"("roundType");
|
||||||
CREATE INDEX "MentorNote_authorId_idx" ON "MentorNote"("authorId");
|
CREATE INDEX IF NOT EXISTS "MentorNote_authorId_idx" ON "MentorNote"("authorId");
|
||||||
CREATE INDEX "MentorMilestoneCompletion_completedById_idx" ON "MentorMilestoneCompletion"("completedById");
|
CREATE INDEX IF NOT EXISTS "MentorMilestoneCompletion_completedById_idx" ON "MentorMilestoneCompletion"("completedById");
|
||||||
CREATE INDEX "Webhook_createdById_idx" ON "Webhook"("createdById");
|
CREATE INDEX IF NOT EXISTS "Webhook_createdById_idx" ON "Webhook"("createdById");
|
||||||
CREATE INDEX "WebhookDelivery_event_idx" ON "WebhookDelivery"("event");
|
CREATE INDEX IF NOT EXISTS "WebhookDelivery_event_idx" ON "WebhookDelivery"("event");
|
||||||
CREATE INDEX "Message_roundId_idx" ON "Message"("roundId");
|
CREATE INDEX IF NOT EXISTS "Message_roundId_idx" ON "Message"("roundId");
|
||||||
CREATE INDEX "EvaluationDiscussion_closedById_idx" ON "EvaluationDiscussion"("closedById");
|
CREATE INDEX IF NOT EXISTS "EvaluationDiscussion_closedById_idx" ON "EvaluationDiscussion"("closedById");
|
||||||
CREATE INDEX "DiscussionComment_discussionId_idx" ON "DiscussionComment"("discussionId");
|
CREATE INDEX IF NOT EXISTS "DiscussionComment_discussionId_idx" ON "DiscussionComment"("discussionId");
|
||||||
CREATE INDEX "DiscussionComment_userId_idx" ON "DiscussionComment"("userId");
|
CREATE INDEX IF NOT EXISTS "DiscussionComment_userId_idx" ON "DiscussionComment"("userId");
|
||||||
|
|||||||
@@ -3,11 +3,15 @@
|
|||||||
-- Add SET NULL on ProjectFile.roundId so deleting Round nullifies the reference
|
-- Add SET NULL on ProjectFile.roundId so deleting Round nullifies the reference
|
||||||
|
|
||||||
-- AlterTable: Evaluation.formId -> onDelete CASCADE
|
-- AlterTable: Evaluation.formId -> onDelete CASCADE
|
||||||
ALTER TABLE "Evaluation" DROP CONSTRAINT "Evaluation_formId_fkey";
|
ALTER TABLE "Evaluation" DROP CONSTRAINT IF EXISTS "Evaluation_formId_fkey";
|
||||||
ALTER TABLE "Evaluation" ADD CONSTRAINT "Evaluation_formId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("formId") REFERENCES "EvaluationForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "Evaluation" ADD CONSTRAINT "Evaluation_formId_fkey"
|
||||||
|
FOREIGN KEY ("formId") REFERENCES "EvaluationForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable: ProjectFile.roundId -> onDelete SET NULL
|
-- AlterTable: ProjectFile.roundId -> onDelete SET NULL
|
||||||
ALTER TABLE "ProjectFile" DROP CONSTRAINT "ProjectFile_roundId_fkey";
|
ALTER TABLE "ProjectFile" DROP CONSTRAINT IF EXISTS "ProjectFile_roundId_fkey";
|
||||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "FileRequirement" (
|
CREATE TABLE IF NOT EXISTS "FileRequirement" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
@@ -15,16 +15,22 @@ CREATE TABLE "FileRequirement" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "FileRequirement_roundId_idx" ON "FileRequirement"("roundId");
|
CREATE INDEX IF NOT EXISTS "FileRequirement_roundId_idx" ON "FileRequirement"("roundId");
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "FileRequirement" ADD CONSTRAINT "FileRequirement_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "FileRequirement" ADD CONSTRAINT "FileRequirement_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AlterTable: add requirementId to ProjectFile
|
-- AlterTable: add requirementId to ProjectFile
|
||||||
ALTER TABLE "ProjectFile" ADD COLUMN "requirementId" TEXT;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "requirementId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "ProjectFile_requirementId_idx" ON "ProjectFile"("requirementId");
|
CREATE INDEX IF NOT EXISTS "ProjectFile_requirementId_idx" ON "ProjectFile"("requirementId");
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_requirementId_fkey" FOREIGN KEY ("requirementId") REFERENCES "FileRequirement"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_requirementId_fkey" FOREIGN KEY ("requirementId") REFERENCES "FileRequirement"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "AwardVote_awardId_userId_idx" ON "AwardVote"("awardId", "userId");
|
CREATE INDEX IF NOT EXISTS "AwardVote_awardId_userId_idx" ON "AwardVote"("awardId", "userId");
|
||||||
|
|||||||
@@ -1,28 +1,36 @@
|
|||||||
-- Simplify RoutingMode enum: remove POST_MAIN, rename PARALLEL → SHARED
|
-- Simplify RoutingMode enum: remove POST_MAIN, rename PARALLEL -> SHARED
|
||||||
-- Drop RoutingRule table (routing is now handled via award assignment)
|
-- Drop RoutingRule table (routing is now handled via award assignment)
|
||||||
|
|
||||||
-- 1. Update existing PARALLEL values to SHARED, POST_MAIN to SHARED
|
-- 1. Update existing PARALLEL values to SHARED, POST_MAIN to SHARED
|
||||||
|
-- (safe to run even if no rows match)
|
||||||
UPDATE "Track" SET "routingMode" = 'PARALLEL' WHERE "routingMode" = 'POST_MAIN';
|
UPDATE "Track" SET "routingMode" = 'PARALLEL' WHERE "routingMode" = 'POST_MAIN';
|
||||||
|
|
||||||
-- 2. Rename PARALLEL → SHARED in the enum
|
-- 2. Rename PARALLEL -> SHARED in the enum (only if PARALLEL still exists)
|
||||||
ALTER TYPE "RoutingMode" RENAME VALUE 'PARALLEL' TO 'SHARED';
|
DO $$ BEGIN
|
||||||
|
ALTER TYPE "RoutingMode" RENAME VALUE 'PARALLEL' TO 'SHARED';
|
||||||
|
EXCEPTION WHEN invalid_parameter_value THEN NULL; WHEN others THEN NULL; END $$;
|
||||||
|
|
||||||
-- 3. Remove POST_MAIN from the enum
|
-- 3. Remove POST_MAIN from the enum
|
||||||
-- PostgreSQL doesn't support DROP VALUE directly, so we recreate the enum
|
-- PostgreSQL doesn't support DROP VALUE directly, so we recreate the enum
|
||||||
-- Since we already converted POST_MAIN values to PARALLEL (now SHARED), this is safe
|
-- Since we already converted POST_MAIN values to PARALLEL (now SHARED), this is safe
|
||||||
|
|
||||||
-- Create new enum without POST_MAIN
|
-- Only recreate if the old enum still has POST_MAIN (i.e., hasn't been done yet)
|
||||||
-- Actually, since we already renamed PARALLEL to SHARED and converted POST_MAIN rows,
|
DO $$ BEGIN
|
||||||
-- we just need to remove the POST_MAIN value. PostgreSQL 13+ doesn't support dropping
|
IF EXISTS (
|
||||||
-- enum values natively, but since all rows are already migrated, we can:
|
SELECT 1 FROM pg_enum
|
||||||
CREATE TYPE "RoutingMode_new" AS ENUM ('SHARED', 'EXCLUSIVE');
|
WHERE enumlabel = 'POST_MAIN'
|
||||||
|
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'RoutingMode')
|
||||||
|
) THEN
|
||||||
|
CREATE TYPE "RoutingMode_new" AS ENUM ('SHARED', 'EXCLUSIVE');
|
||||||
|
|
||||||
ALTER TABLE "Track"
|
ALTER TABLE "Track"
|
||||||
ALTER COLUMN "routingMode" TYPE "RoutingMode_new"
|
ALTER COLUMN "routingMode" TYPE "RoutingMode_new"
|
||||||
USING ("routingMode"::text::"RoutingMode_new");
|
USING ("routingMode"::text::"RoutingMode_new");
|
||||||
|
|
||||||
DROP TYPE "RoutingMode";
|
DROP TYPE "RoutingMode";
|
||||||
ALTER TYPE "RoutingMode_new" RENAME TO "RoutingMode";
|
ALTER TYPE "RoutingMode_new" RENAME TO "RoutingMode";
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
-- 4. Drop the RoutingRule table (no longer needed)
|
-- 4. Drop the RoutingRule table (no longer needed)
|
||||||
DROP TABLE IF EXISTS "RoutingRule";
|
DROP TABLE IF EXISTS "RoutingRule";
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- Phase 0+1: Add Competition/Round Architecture (additive — no breaking changes)
|
-- Phase 0+1: Add Competition/Round Architecture (additive -- no breaking changes)
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- New enums, new tables, new optional columns on existing tables.
|
-- New enums, new tables, new optional columns on existing tables.
|
||||||
-- Old Pipeline/Track/Stage tables are untouched.
|
-- Old Pipeline/Track/Stage tables are untouched.
|
||||||
|
|
||||||
-- ─── New Enum Types ──────────────────────────────────────────────────────────
|
-- --- New Enum Types ---
|
||||||
|
|
||||||
CREATE TYPE "CompetitionStatus" AS ENUM ('DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED');
|
DO $$ BEGIN CREATE TYPE "CompetitionStatus" AS ENUM ('DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "RoundType" AS ENUM ('INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'DELIBERATION');
|
DO $$ BEGIN CREATE TYPE "RoundType" AS ENUM ('INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'DELIBERATION'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "RoundStatus" AS ENUM ('ROUND_DRAFT', 'ROUND_ACTIVE', 'ROUND_CLOSED', 'ROUND_ARCHIVED');
|
DO $$ BEGIN CREATE TYPE "RoundStatus" AS ENUM ('ROUND_DRAFT', 'ROUND_ACTIVE', 'ROUND_CLOSED', 'ROUND_ARCHIVED'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "ProjectRoundStateValue" AS ENUM ('PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN');
|
DO $$ BEGIN CREATE TYPE "ProjectRoundStateValue" AS ENUM ('PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "AdvancementRuleType" AS ENUM ('AUTO_ADVANCE', 'SCORE_THRESHOLD', 'TOP_N', 'ADMIN_SELECTION', 'AI_RECOMMENDED');
|
DO $$ BEGIN CREATE TYPE "AdvancementRuleType" AS ENUM ('AUTO_ADVANCE', 'SCORE_THRESHOLD', 'TOP_N', 'ADMIN_SELECTION', 'AI_RECOMMENDED'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "CapMode" AS ENUM ('HARD', 'SOFT', 'NONE');
|
DO $$ BEGIN CREATE TYPE "CapMode" AS ENUM ('HARD', 'SOFT', 'NONE'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "DeadlinePolicy" AS ENUM ('HARD_DEADLINE', 'FLAG', 'GRACE');
|
DO $$ BEGIN CREATE TYPE "DeadlinePolicy" AS ENUM ('HARD_DEADLINE', 'FLAG', 'GRACE'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "JuryGroupMemberRole" AS ENUM ('CHAIR', 'MEMBER', 'OBSERVER');
|
DO $$ BEGIN CREATE TYPE "JuryGroupMemberRole" AS ENUM ('CHAIR', 'MEMBER', 'OBSERVER'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "AssignmentIntentSource" AS ENUM ('INVITE', 'ADMIN', 'SYSTEM');
|
DO $$ BEGIN CREATE TYPE "AssignmentIntentSource" AS ENUM ('INVITE', 'ADMIN', 'SYSTEM'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "AssignmentIntentStatus" AS ENUM ('INTENT_PENDING', 'HONORED', 'OVERRIDDEN', 'EXPIRED', 'CANCELLED');
|
DO $$ BEGIN CREATE TYPE "AssignmentIntentStatus" AS ENUM ('INTENT_PENDING', 'HONORED', 'OVERRIDDEN', 'EXPIRED', 'CANCELLED'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "MentorMessageRole" AS ENUM ('MENTOR_ROLE', 'APPLICANT_ROLE', 'ADMIN_ROLE');
|
DO $$ BEGIN CREATE TYPE "MentorMessageRole" AS ENUM ('MENTOR_ROLE', 'APPLICANT_ROLE', 'ADMIN_ROLE'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "SubmissionPromotionSource" AS ENUM ('MENTOR_FILE', 'ADMIN_REPLACEMENT');
|
DO $$ BEGIN CREATE TYPE "SubmissionPromotionSource" AS ENUM ('MENTOR_FILE', 'ADMIN_REPLACEMENT'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "DeliberationMode" AS ENUM ('SINGLE_WINNER_VOTE', 'FULL_RANKING');
|
DO $$ BEGIN CREATE TYPE "DeliberationMode" AS ENUM ('SINGLE_WINNER_VOTE', 'FULL_RANKING'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "DeliberationStatus" AS ENUM ('DELIB_OPEN', 'VOTING', 'TALLYING', 'RUNOFF', 'DELIB_LOCKED');
|
DO $$ BEGIN CREATE TYPE "DeliberationStatus" AS ENUM ('DELIB_OPEN', 'VOTING', 'TALLYING', 'RUNOFF', 'DELIB_LOCKED'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "TieBreakMethod" AS ENUM ('TIE_RUNOFF', 'TIE_ADMIN_DECIDES', 'SCORE_FALLBACK');
|
DO $$ BEGIN CREATE TYPE "TieBreakMethod" AS ENUM ('TIE_RUNOFF', 'TIE_ADMIN_DECIDES', 'SCORE_FALLBACK'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "DeliberationParticipantStatus" AS ENUM ('REQUIRED', 'ABSENT_EXCUSED', 'REPLACED', 'REPLACEMENT_ACTIVE');
|
DO $$ BEGIN CREATE TYPE "DeliberationParticipantStatus" AS ENUM ('REQUIRED', 'ABSENT_EXCUSED', 'REPLACED', 'REPLACEMENT_ACTIVE'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
CREATE TYPE "AwardEligibilityMode" AS ENUM ('SEPARATE_POOL', 'STAY_IN_MAIN');
|
DO $$ BEGIN CREATE TYPE "AwardEligibilityMode" AS ENUM ('SEPARATE_POOL', 'STAY_IN_MAIN'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- Add FEATURE_FLAGS to SettingCategory enum
|
-- Add FEATURE_FLAGS to SettingCategory enum
|
||||||
ALTER TYPE "SettingCategory" ADD VALUE 'FEATURE_FLAGS';
|
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'FEATURE_FLAGS'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── New Tables ──────────────────────────────────────────────────────────────
|
-- --- New Tables ---
|
||||||
|
|
||||||
-- Competition (replaces Pipeline)
|
-- Competition (replaces Pipeline)
|
||||||
CREATE TABLE "Competition" (
|
CREATE TABLE IF NOT EXISTS "Competition" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"programId" TEXT NOT NULL,
|
"programId" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
@@ -49,7 +49,7 @@ CREATE TABLE "Competition" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Round (replaces Stage)
|
-- Round (replaces Stage)
|
||||||
CREATE TABLE "Round" (
|
CREATE TABLE IF NOT EXISTS "Round" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"competitionId" TEXT NOT NULL,
|
"competitionId" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
@@ -70,7 +70,7 @@ CREATE TABLE "Round" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- ProjectRoundState
|
-- ProjectRoundState
|
||||||
CREATE TABLE "ProjectRoundState" (
|
CREATE TABLE IF NOT EXISTS "ProjectRoundState" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"projectId" TEXT NOT NULL,
|
"projectId" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
@@ -85,7 +85,7 @@ CREATE TABLE "ProjectRoundState" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- AdvancementRule
|
-- AdvancementRule
|
||||||
CREATE TABLE "AdvancementRule" (
|
CREATE TABLE IF NOT EXISTS "AdvancementRule" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
"targetRoundId" TEXT,
|
"targetRoundId" TEXT,
|
||||||
@@ -99,7 +99,7 @@ CREATE TABLE "AdvancementRule" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- JuryGroup
|
-- JuryGroup
|
||||||
CREATE TABLE "JuryGroup" (
|
CREATE TABLE IF NOT EXISTS "JuryGroup" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"competitionId" TEXT NOT NULL,
|
"competitionId" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
@@ -120,7 +120,7 @@ CREATE TABLE "JuryGroup" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- JuryGroupMember
|
-- JuryGroupMember
|
||||||
CREATE TABLE "JuryGroupMember" (
|
CREATE TABLE IF NOT EXISTS "JuryGroupMember" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"juryGroupId" TEXT NOT NULL,
|
"juryGroupId" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
@@ -138,7 +138,7 @@ CREATE TABLE "JuryGroupMember" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- SubmissionWindow
|
-- SubmissionWindow
|
||||||
CREATE TABLE "SubmissionWindow" (
|
CREATE TABLE IF NOT EXISTS "SubmissionWindow" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"competitionId" TEXT NOT NULL,
|
"competitionId" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
@@ -158,7 +158,7 @@ CREATE TABLE "SubmissionWindow" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- SubmissionFileRequirement
|
-- SubmissionFileRequirement
|
||||||
CREATE TABLE "SubmissionFileRequirement" (
|
CREATE TABLE IF NOT EXISTS "SubmissionFileRequirement" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"submissionWindowId" TEXT NOT NULL,
|
"submissionWindowId" TEXT NOT NULL,
|
||||||
"label" TEXT NOT NULL,
|
"label" TEXT NOT NULL,
|
||||||
@@ -175,7 +175,7 @@ CREATE TABLE "SubmissionFileRequirement" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- RoundSubmissionVisibility
|
-- RoundSubmissionVisibility
|
||||||
CREATE TABLE "RoundSubmissionVisibility" (
|
CREATE TABLE IF NOT EXISTS "RoundSubmissionVisibility" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
"submissionWindowId" TEXT NOT NULL,
|
"submissionWindowId" TEXT NOT NULL,
|
||||||
@@ -186,7 +186,7 @@ CREATE TABLE "RoundSubmissionVisibility" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- AssignmentIntent
|
-- AssignmentIntent
|
||||||
CREATE TABLE "AssignmentIntent" (
|
CREATE TABLE IF NOT EXISTS "AssignmentIntent" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"juryGroupMemberId" TEXT NOT NULL,
|
"juryGroupMemberId" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
@@ -200,7 +200,7 @@ CREATE TABLE "AssignmentIntent" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- AssignmentException
|
-- AssignmentException
|
||||||
CREATE TABLE "AssignmentException" (
|
CREATE TABLE IF NOT EXISTS "AssignmentException" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"assignmentId" TEXT NOT NULL,
|
"assignmentId" TEXT NOT NULL,
|
||||||
"reason" TEXT NOT NULL,
|
"reason" TEXT NOT NULL,
|
||||||
@@ -212,7 +212,7 @@ CREATE TABLE "AssignmentException" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- MentorFile
|
-- MentorFile
|
||||||
CREATE TABLE "MentorFile" (
|
CREATE TABLE IF NOT EXISTS "MentorFile" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"mentorAssignmentId" TEXT NOT NULL,
|
"mentorAssignmentId" TEXT NOT NULL,
|
||||||
"uploadedByUserId" TEXT NOT NULL,
|
"uploadedByUserId" TEXT NOT NULL,
|
||||||
@@ -232,7 +232,7 @@ CREATE TABLE "MentorFile" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- MentorFileComment
|
-- MentorFileComment
|
||||||
CREATE TABLE "MentorFileComment" (
|
CREATE TABLE IF NOT EXISTS "MentorFileComment" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"mentorFileId" TEXT NOT NULL,
|
"mentorFileId" TEXT NOT NULL,
|
||||||
"authorId" TEXT NOT NULL,
|
"authorId" TEXT NOT NULL,
|
||||||
@@ -245,7 +245,7 @@ CREATE TABLE "MentorFileComment" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- SubmissionPromotionEvent
|
-- SubmissionPromotionEvent
|
||||||
CREATE TABLE "SubmissionPromotionEvent" (
|
CREATE TABLE IF NOT EXISTS "SubmissionPromotionEvent" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"projectId" TEXT NOT NULL,
|
"projectId" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
@@ -259,7 +259,7 @@ CREATE TABLE "SubmissionPromotionEvent" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- DeliberationSession
|
-- DeliberationSession
|
||||||
CREATE TABLE "DeliberationSession" (
|
CREATE TABLE IF NOT EXISTS "DeliberationSession" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"competitionId" TEXT NOT NULL,
|
"competitionId" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
@@ -277,7 +277,7 @@ CREATE TABLE "DeliberationSession" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- DeliberationVote
|
-- DeliberationVote
|
||||||
CREATE TABLE "DeliberationVote" (
|
CREATE TABLE IF NOT EXISTS "DeliberationVote" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"sessionId" TEXT NOT NULL,
|
"sessionId" TEXT NOT NULL,
|
||||||
"juryMemberId" TEXT NOT NULL,
|
"juryMemberId" TEXT NOT NULL,
|
||||||
@@ -291,7 +291,7 @@ CREATE TABLE "DeliberationVote" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- DeliberationResult
|
-- DeliberationResult
|
||||||
CREATE TABLE "DeliberationResult" (
|
CREATE TABLE IF NOT EXISTS "DeliberationResult" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"sessionId" TEXT NOT NULL,
|
"sessionId" TEXT NOT NULL,
|
||||||
"projectId" TEXT NOT NULL,
|
"projectId" TEXT NOT NULL,
|
||||||
@@ -304,7 +304,7 @@ CREATE TABLE "DeliberationResult" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- DeliberationParticipant
|
-- DeliberationParticipant
|
||||||
CREATE TABLE "DeliberationParticipant" (
|
CREATE TABLE IF NOT EXISTS "DeliberationParticipant" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"sessionId" TEXT NOT NULL,
|
"sessionId" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
"userId" TEXT NOT NULL,
|
||||||
@@ -315,7 +315,7 @@ CREATE TABLE "DeliberationParticipant" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- ResultLock
|
-- ResultLock
|
||||||
CREATE TABLE "ResultLock" (
|
CREATE TABLE IF NOT EXISTS "ResultLock" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"competitionId" TEXT NOT NULL,
|
"competitionId" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
@@ -328,7 +328,7 @@ CREATE TABLE "ResultLock" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- ResultUnlockEvent
|
-- ResultUnlockEvent
|
||||||
CREATE TABLE "ResultUnlockEvent" (
|
CREATE TABLE IF NOT EXISTS "ResultUnlockEvent" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"resultLockId" TEXT NOT NULL,
|
"resultLockId" TEXT NOT NULL,
|
||||||
"unlockedById" TEXT NOT NULL,
|
"unlockedById" TEXT NOT NULL,
|
||||||
@@ -338,235 +338,365 @@ CREATE TABLE "ResultUnlockEvent" (
|
|||||||
CONSTRAINT "ResultUnlockEvent_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "ResultUnlockEvent_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ─── Add Columns to Existing Tables ──────────────────────────────────────────
|
-- --- Add Columns to Existing Tables ---
|
||||||
|
|
||||||
-- Assignment: add juryGroupId
|
-- Assignment: add juryGroupId
|
||||||
ALTER TABLE "Assignment" ADD COLUMN "juryGroupId" TEXT;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Assignment" ADD COLUMN "juryGroupId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- SpecialAward: add competition/round architecture fields
|
-- SpecialAward: add competition/round architecture fields
|
||||||
ALTER TABLE "SpecialAward" ADD COLUMN "competitionId" TEXT;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "SpecialAward" ADD COLUMN "evaluationRoundId" TEXT;
|
ALTER TABLE "SpecialAward" ADD COLUMN "competitionId" TEXT;
|
||||||
ALTER TABLE "SpecialAward" ADD COLUMN "juryGroupId" TEXT;
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
ALTER TABLE "SpecialAward" ADD COLUMN "eligibilityMode" "AwardEligibilityMode" NOT NULL DEFAULT 'STAY_IN_MAIN';
|
DO $$ BEGIN
|
||||||
ALTER TABLE "SpecialAward" ADD COLUMN "decisionMode" TEXT;
|
ALTER TABLE "SpecialAward" ADD COLUMN "evaluationRoundId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SpecialAward" ADD COLUMN "juryGroupId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SpecialAward" ADD COLUMN "eligibilityMode" "AwardEligibilityMode" NOT NULL DEFAULT 'STAY_IN_MAIN';
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SpecialAward" ADD COLUMN "decisionMode" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- MentorAssignment: add workspace fields
|
-- MentorAssignment: add workspace fields
|
||||||
ALTER TABLE "MentorAssignment" ADD COLUMN "workspaceEnabled" BOOLEAN NOT NULL DEFAULT false;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "MentorAssignment" ADD COLUMN "workspaceOpenAt" TIMESTAMP(3);
|
ALTER TABLE "MentorAssignment" ADD COLUMN "workspaceEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||||
ALTER TABLE "MentorAssignment" ADD COLUMN "workspaceCloseAt" TIMESTAMP(3);
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorAssignment" ADD COLUMN "workspaceOpenAt" TIMESTAMP(3);
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorAssignment" ADD COLUMN "workspaceCloseAt" TIMESTAMP(3);
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- MentorMessage: add workspace fields
|
-- MentorMessage: add workspace fields
|
||||||
ALTER TABLE "MentorMessage" ADD COLUMN "workspaceId" TEXT;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "MentorMessage" ADD COLUMN "senderRole" "MentorMessageRole";
|
ALTER TABLE "MentorMessage" ADD COLUMN "workspaceId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorMessage" ADD COLUMN "senderRole" "MentorMessageRole";
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- ProjectFile: add submission window link
|
-- ProjectFile: add submission window link
|
||||||
ALTER TABLE "ProjectFile" ADD COLUMN "submissionWindowId" TEXT;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "ProjectFile" ADD COLUMN "submissionFileRequirementId" TEXT;
|
ALTER TABLE "ProjectFile" ADD COLUMN "submissionWindowId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "submissionFileRequirementId" TEXT;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── Unique Constraints ──────────────────────────────────────────────────────
|
-- --- Unique Constraints ---
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "Competition_slug_key" ON "Competition"("slug");
|
CREATE UNIQUE INDEX IF NOT EXISTS "Competition_slug_key" ON "Competition"("slug");
|
||||||
CREATE UNIQUE INDEX "Round_competitionId_slug_key" ON "Round"("competitionId", "slug");
|
CREATE UNIQUE INDEX IF NOT EXISTS "Round_competitionId_slug_key" ON "Round"("competitionId", "slug");
|
||||||
CREATE UNIQUE INDEX "Round_competitionId_sortOrder_key" ON "Round"("competitionId", "sortOrder");
|
CREATE UNIQUE INDEX IF NOT EXISTS "Round_competitionId_sortOrder_key" ON "Round"("competitionId", "sortOrder");
|
||||||
CREATE UNIQUE INDEX "ProjectRoundState_projectId_roundId_key" ON "ProjectRoundState"("projectId", "roundId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "ProjectRoundState_projectId_roundId_key" ON "ProjectRoundState"("projectId", "roundId");
|
||||||
CREATE UNIQUE INDEX "JuryGroup_competitionId_slug_key" ON "JuryGroup"("competitionId", "slug");
|
CREATE UNIQUE INDEX IF NOT EXISTS "JuryGroup_competitionId_slug_key" ON "JuryGroup"("competitionId", "slug");
|
||||||
CREATE UNIQUE INDEX "JuryGroupMember_juryGroupId_userId_key" ON "JuryGroupMember"("juryGroupId", "userId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "JuryGroupMember_juryGroupId_userId_key" ON "JuryGroupMember"("juryGroupId", "userId");
|
||||||
CREATE UNIQUE INDEX "SubmissionWindow_competitionId_slug_key" ON "SubmissionWindow"("competitionId", "slug");
|
CREATE UNIQUE INDEX IF NOT EXISTS "SubmissionWindow_competitionId_slug_key" ON "SubmissionWindow"("competitionId", "slug");
|
||||||
CREATE UNIQUE INDEX "SubmissionWindow_competitionId_roundNumber_key" ON "SubmissionWindow"("competitionId", "roundNumber");
|
CREATE UNIQUE INDEX IF NOT EXISTS "SubmissionWindow_competitionId_roundNumber_key" ON "SubmissionWindow"("competitionId", "roundNumber");
|
||||||
CREATE UNIQUE INDEX "RoundSubmissionVisibility_roundId_submissionWindowId_key" ON "RoundSubmissionVisibility"("roundId", "submissionWindowId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "RoundSubmissionVisibility_roundId_submissionWindowId_key" ON "RoundSubmissionVisibility"("roundId", "submissionWindowId");
|
||||||
CREATE UNIQUE INDEX "AssignmentIntent_juryGroupMemberId_roundId_projectId_key" ON "AssignmentIntent"("juryGroupMemberId", "roundId", "projectId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "AssignmentIntent_juryGroupMemberId_roundId_projectId_key" ON "AssignmentIntent"("juryGroupMemberId", "roundId", "projectId");
|
||||||
CREATE UNIQUE INDEX "MentorFile_promotedToFileId_key" ON "MentorFile"("promotedToFileId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "MentorFile_promotedToFileId_key" ON "MentorFile"("promotedToFileId");
|
||||||
CREATE UNIQUE INDEX "DeliberationVote_sessionId_juryMemberId_projectId_runoffRo_key" ON "DeliberationVote"("sessionId", "juryMemberId", "projectId", "runoffRound");
|
CREATE UNIQUE INDEX IF NOT EXISTS "DeliberationVote_sessionId_juryMemberId_projectId_runoffRo_key" ON "DeliberationVote"("sessionId", "juryMemberId", "projectId", "runoffRound");
|
||||||
CREATE UNIQUE INDEX "DeliberationResult_sessionId_projectId_key" ON "DeliberationResult"("sessionId", "projectId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "DeliberationResult_sessionId_projectId_key" ON "DeliberationResult"("sessionId", "projectId");
|
||||||
CREATE UNIQUE INDEX "DeliberationParticipant_sessionId_userId_key" ON "DeliberationParticipant"("sessionId", "userId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "DeliberationParticipant_sessionId_userId_key" ON "DeliberationParticipant"("sessionId", "userId");
|
||||||
CREATE UNIQUE INDEX "SubmissionFileRequirement_submissionWindowId_slug_key" ON "SubmissionFileRequirement"("submissionWindowId", "slug");
|
CREATE UNIQUE INDEX IF NOT EXISTS "SubmissionFileRequirement_submissionWindowId_slug_key" ON "SubmissionFileRequirement"("submissionWindowId", "slug");
|
||||||
CREATE UNIQUE INDEX "AdvancementRule_roundId_sortOrder_key" ON "AdvancementRule"("roundId", "sortOrder");
|
CREATE UNIQUE INDEX IF NOT EXISTS "AdvancementRule_roundId_sortOrder_key" ON "AdvancementRule"("roundId", "sortOrder");
|
||||||
|
|
||||||
-- ─── Indexes ─────────────────────────────────────────────────────────────────
|
-- --- Indexes ---
|
||||||
|
|
||||||
-- Competition
|
-- Competition
|
||||||
CREATE INDEX "Competition_programId_idx" ON "Competition"("programId");
|
CREATE INDEX IF NOT EXISTS "Competition_programId_idx" ON "Competition"("programId");
|
||||||
CREATE INDEX "Competition_status_idx" ON "Competition"("status");
|
CREATE INDEX IF NOT EXISTS "Competition_status_idx" ON "Competition"("status");
|
||||||
|
|
||||||
-- Round
|
-- Round
|
||||||
CREATE INDEX "Round_competitionId_idx" ON "Round"("competitionId");
|
CREATE INDEX IF NOT EXISTS "Round_competitionId_idx" ON "Round"("competitionId");
|
||||||
CREATE INDEX "Round_roundType_idx" ON "Round"("roundType");
|
CREATE INDEX IF NOT EXISTS "Round_roundType_idx" ON "Round"("roundType");
|
||||||
CREATE INDEX "Round_status_idx" ON "Round"("status");
|
CREATE INDEX IF NOT EXISTS "Round_status_idx" ON "Round"("status");
|
||||||
|
|
||||||
-- ProjectRoundState
|
-- ProjectRoundState
|
||||||
CREATE INDEX "ProjectRoundState_projectId_idx" ON "ProjectRoundState"("projectId");
|
CREATE INDEX IF NOT EXISTS "ProjectRoundState_projectId_idx" ON "ProjectRoundState"("projectId");
|
||||||
CREATE INDEX "ProjectRoundState_roundId_idx" ON "ProjectRoundState"("roundId");
|
CREATE INDEX IF NOT EXISTS "ProjectRoundState_roundId_idx" ON "ProjectRoundState"("roundId");
|
||||||
CREATE INDEX "ProjectRoundState_state_idx" ON "ProjectRoundState"("state");
|
CREATE INDEX IF NOT EXISTS "ProjectRoundState_state_idx" ON "ProjectRoundState"("state");
|
||||||
|
|
||||||
-- AdvancementRule
|
-- AdvancementRule
|
||||||
CREATE INDEX "AdvancementRule_roundId_idx" ON "AdvancementRule"("roundId");
|
CREATE INDEX IF NOT EXISTS "AdvancementRule_roundId_idx" ON "AdvancementRule"("roundId");
|
||||||
|
|
||||||
-- JuryGroup
|
-- JuryGroup
|
||||||
CREATE INDEX "JuryGroup_competitionId_idx" ON "JuryGroup"("competitionId");
|
CREATE INDEX IF NOT EXISTS "JuryGroup_competitionId_idx" ON "JuryGroup"("competitionId");
|
||||||
|
|
||||||
-- JuryGroupMember
|
-- JuryGroupMember
|
||||||
CREATE INDEX "JuryGroupMember_juryGroupId_idx" ON "JuryGroupMember"("juryGroupId");
|
CREATE INDEX IF NOT EXISTS "JuryGroupMember_juryGroupId_idx" ON "JuryGroupMember"("juryGroupId");
|
||||||
CREATE INDEX "JuryGroupMember_userId_idx" ON "JuryGroupMember"("userId");
|
CREATE INDEX IF NOT EXISTS "JuryGroupMember_userId_idx" ON "JuryGroupMember"("userId");
|
||||||
|
|
||||||
-- SubmissionWindow
|
-- SubmissionWindow
|
||||||
CREATE INDEX "SubmissionWindow_competitionId_idx" ON "SubmissionWindow"("competitionId");
|
CREATE INDEX IF NOT EXISTS "SubmissionWindow_competitionId_idx" ON "SubmissionWindow"("competitionId");
|
||||||
|
|
||||||
-- SubmissionFileRequirement
|
-- SubmissionFileRequirement
|
||||||
CREATE INDEX "SubmissionFileRequirement_submissionWindowId_idx" ON "SubmissionFileRequirement"("submissionWindowId");
|
CREATE INDEX IF NOT EXISTS "SubmissionFileRequirement_submissionWindowId_idx" ON "SubmissionFileRequirement"("submissionWindowId");
|
||||||
|
|
||||||
-- RoundSubmissionVisibility
|
-- RoundSubmissionVisibility
|
||||||
CREATE INDEX "RoundSubmissionVisibility_roundId_idx" ON "RoundSubmissionVisibility"("roundId");
|
CREATE INDEX IF NOT EXISTS "RoundSubmissionVisibility_roundId_idx" ON "RoundSubmissionVisibility"("roundId");
|
||||||
|
|
||||||
-- AssignmentIntent
|
-- AssignmentIntent
|
||||||
CREATE INDEX "AssignmentIntent_roundId_idx" ON "AssignmentIntent"("roundId");
|
CREATE INDEX IF NOT EXISTS "AssignmentIntent_roundId_idx" ON "AssignmentIntent"("roundId");
|
||||||
CREATE INDEX "AssignmentIntent_projectId_idx" ON "AssignmentIntent"("projectId");
|
CREATE INDEX IF NOT EXISTS "AssignmentIntent_projectId_idx" ON "AssignmentIntent"("projectId");
|
||||||
CREATE INDEX "AssignmentIntent_status_idx" ON "AssignmentIntent"("status");
|
CREATE INDEX IF NOT EXISTS "AssignmentIntent_status_idx" ON "AssignmentIntent"("status");
|
||||||
|
|
||||||
-- AssignmentException
|
-- AssignmentException
|
||||||
CREATE INDEX "AssignmentException_assignmentId_idx" ON "AssignmentException"("assignmentId");
|
CREATE INDEX IF NOT EXISTS "AssignmentException_assignmentId_idx" ON "AssignmentException"("assignmentId");
|
||||||
CREATE INDEX "AssignmentException_approvedById_idx" ON "AssignmentException"("approvedById");
|
CREATE INDEX IF NOT EXISTS "AssignmentException_approvedById_idx" ON "AssignmentException"("approvedById");
|
||||||
|
|
||||||
-- MentorFile
|
-- MentorFile
|
||||||
CREATE INDEX "MentorFile_mentorAssignmentId_idx" ON "MentorFile"("mentorAssignmentId");
|
CREATE INDEX IF NOT EXISTS "MentorFile_mentorAssignmentId_idx" ON "MentorFile"("mentorAssignmentId");
|
||||||
CREATE INDEX "MentorFile_uploadedByUserId_idx" ON "MentorFile"("uploadedByUserId");
|
CREATE INDEX IF NOT EXISTS "MentorFile_uploadedByUserId_idx" ON "MentorFile"("uploadedByUserId");
|
||||||
|
|
||||||
-- MentorFileComment
|
-- MentorFileComment
|
||||||
CREATE INDEX "MentorFileComment_mentorFileId_idx" ON "MentorFileComment"("mentorFileId");
|
CREATE INDEX IF NOT EXISTS "MentorFileComment_mentorFileId_idx" ON "MentorFileComment"("mentorFileId");
|
||||||
CREATE INDEX "MentorFileComment_authorId_idx" ON "MentorFileComment"("authorId");
|
CREATE INDEX IF NOT EXISTS "MentorFileComment_authorId_idx" ON "MentorFileComment"("authorId");
|
||||||
CREATE INDEX "MentorFileComment_parentCommentId_idx" ON "MentorFileComment"("parentCommentId");
|
CREATE INDEX IF NOT EXISTS "MentorFileComment_parentCommentId_idx" ON "MentorFileComment"("parentCommentId");
|
||||||
|
|
||||||
-- SubmissionPromotionEvent
|
-- SubmissionPromotionEvent
|
||||||
CREATE INDEX "SubmissionPromotionEvent_projectId_idx" ON "SubmissionPromotionEvent"("projectId");
|
CREATE INDEX IF NOT EXISTS "SubmissionPromotionEvent_projectId_idx" ON "SubmissionPromotionEvent"("projectId");
|
||||||
CREATE INDEX "SubmissionPromotionEvent_roundId_idx" ON "SubmissionPromotionEvent"("roundId");
|
CREATE INDEX IF NOT EXISTS "SubmissionPromotionEvent_roundId_idx" ON "SubmissionPromotionEvent"("roundId");
|
||||||
CREATE INDEX "SubmissionPromotionEvent_sourceFileId_idx" ON "SubmissionPromotionEvent"("sourceFileId");
|
CREATE INDEX IF NOT EXISTS "SubmissionPromotionEvent_sourceFileId_idx" ON "SubmissionPromotionEvent"("sourceFileId");
|
||||||
|
|
||||||
-- DeliberationSession
|
-- DeliberationSession
|
||||||
CREATE INDEX "DeliberationSession_competitionId_idx" ON "DeliberationSession"("competitionId");
|
CREATE INDEX IF NOT EXISTS "DeliberationSession_competitionId_idx" ON "DeliberationSession"("competitionId");
|
||||||
CREATE INDEX "DeliberationSession_roundId_idx" ON "DeliberationSession"("roundId");
|
CREATE INDEX IF NOT EXISTS "DeliberationSession_roundId_idx" ON "DeliberationSession"("roundId");
|
||||||
CREATE INDEX "DeliberationSession_status_idx" ON "DeliberationSession"("status");
|
CREATE INDEX IF NOT EXISTS "DeliberationSession_status_idx" ON "DeliberationSession"("status");
|
||||||
|
|
||||||
-- DeliberationVote
|
-- DeliberationVote
|
||||||
CREATE INDEX "DeliberationVote_sessionId_idx" ON "DeliberationVote"("sessionId");
|
CREATE INDEX IF NOT EXISTS "DeliberationVote_sessionId_idx" ON "DeliberationVote"("sessionId");
|
||||||
CREATE INDEX "DeliberationVote_juryMemberId_idx" ON "DeliberationVote"("juryMemberId");
|
CREATE INDEX IF NOT EXISTS "DeliberationVote_juryMemberId_idx" ON "DeliberationVote"("juryMemberId");
|
||||||
CREATE INDEX "DeliberationVote_projectId_idx" ON "DeliberationVote"("projectId");
|
CREATE INDEX IF NOT EXISTS "DeliberationVote_projectId_idx" ON "DeliberationVote"("projectId");
|
||||||
|
|
||||||
-- DeliberationResult
|
-- DeliberationResult
|
||||||
CREATE INDEX "DeliberationResult_sessionId_idx" ON "DeliberationResult"("sessionId");
|
CREATE INDEX IF NOT EXISTS "DeliberationResult_sessionId_idx" ON "DeliberationResult"("sessionId");
|
||||||
CREATE INDEX "DeliberationResult_projectId_idx" ON "DeliberationResult"("projectId");
|
CREATE INDEX IF NOT EXISTS "DeliberationResult_projectId_idx" ON "DeliberationResult"("projectId");
|
||||||
|
|
||||||
-- DeliberationParticipant
|
-- DeliberationParticipant
|
||||||
CREATE INDEX "DeliberationParticipant_sessionId_idx" ON "DeliberationParticipant"("sessionId");
|
CREATE INDEX IF NOT EXISTS "DeliberationParticipant_sessionId_idx" ON "DeliberationParticipant"("sessionId");
|
||||||
CREATE INDEX "DeliberationParticipant_userId_idx" ON "DeliberationParticipant"("userId");
|
CREATE INDEX IF NOT EXISTS "DeliberationParticipant_userId_idx" ON "DeliberationParticipant"("userId");
|
||||||
|
|
||||||
-- ResultLock
|
-- ResultLock
|
||||||
CREATE INDEX "ResultLock_competitionId_idx" ON "ResultLock"("competitionId");
|
CREATE INDEX IF NOT EXISTS "ResultLock_competitionId_idx" ON "ResultLock"("competitionId");
|
||||||
CREATE INDEX "ResultLock_roundId_idx" ON "ResultLock"("roundId");
|
CREATE INDEX IF NOT EXISTS "ResultLock_roundId_idx" ON "ResultLock"("roundId");
|
||||||
CREATE INDEX "ResultLock_category_idx" ON "ResultLock"("category");
|
CREATE INDEX IF NOT EXISTS "ResultLock_category_idx" ON "ResultLock"("category");
|
||||||
|
|
||||||
-- ResultUnlockEvent
|
-- ResultUnlockEvent
|
||||||
CREATE INDEX "ResultUnlockEvent_resultLockId_idx" ON "ResultUnlockEvent"("resultLockId");
|
CREATE INDEX IF NOT EXISTS "ResultUnlockEvent_resultLockId_idx" ON "ResultUnlockEvent"("resultLockId");
|
||||||
CREATE INDEX "ResultUnlockEvent_unlockedById_idx" ON "ResultUnlockEvent"("unlockedById");
|
CREATE INDEX IF NOT EXISTS "ResultUnlockEvent_unlockedById_idx" ON "ResultUnlockEvent"("unlockedById");
|
||||||
|
|
||||||
-- Indexes on modified existing tables
|
-- Indexes on modified existing tables
|
||||||
CREATE INDEX "Assignment_juryGroupId_idx" ON "Assignment"("juryGroupId");
|
CREATE INDEX IF NOT EXISTS "Assignment_juryGroupId_idx" ON "Assignment"("juryGroupId");
|
||||||
CREATE INDEX "SpecialAward_competitionId_idx" ON "SpecialAward"("competitionId");
|
CREATE INDEX IF NOT EXISTS "SpecialAward_competitionId_idx" ON "SpecialAward"("competitionId");
|
||||||
CREATE INDEX "SpecialAward_evaluationRoundId_idx" ON "SpecialAward"("evaluationRoundId");
|
CREATE INDEX IF NOT EXISTS "SpecialAward_evaluationRoundId_idx" ON "SpecialAward"("evaluationRoundId");
|
||||||
CREATE INDEX "MentorMessage_workspaceId_idx" ON "MentorMessage"("workspaceId");
|
CREATE INDEX IF NOT EXISTS "MentorMessage_workspaceId_idx" ON "MentorMessage"("workspaceId");
|
||||||
CREATE INDEX "ProjectFile_submissionWindowId_idx" ON "ProjectFile"("submissionWindowId");
|
CREATE INDEX IF NOT EXISTS "ProjectFile_submissionWindowId_idx" ON "ProjectFile"("submissionWindowId");
|
||||||
CREATE INDEX "ProjectFile_submissionFileRequirementId_idx" ON "ProjectFile"("submissionFileRequirementId");
|
CREATE INDEX IF NOT EXISTS "ProjectFile_submissionFileRequirementId_idx" ON "ProjectFile"("submissionFileRequirementId");
|
||||||
|
|
||||||
-- ─── Foreign Keys ────────────────────────────────────────────────────────────
|
-- --- Foreign Keys ---
|
||||||
|
|
||||||
-- Competition
|
-- Competition
|
||||||
ALTER TABLE "Competition" ADD CONSTRAINT "Competition_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Competition" ADD CONSTRAINT "Competition_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- Round
|
-- Round
|
||||||
ALTER TABLE "Round" ADD CONSTRAINT "Round_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "Round" ADD CONSTRAINT "Round_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
ALTER TABLE "Round" ADD CONSTRAINT "Round_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "Round" ADD CONSTRAINT "Round_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Round" ADD CONSTRAINT "Round_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Round" ADD CONSTRAINT "Round_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ProjectRoundState
|
-- ProjectRoundState
|
||||||
ALTER TABLE "ProjectRoundState" ADD CONSTRAINT "ProjectRoundState_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "ProjectRoundState" ADD CONSTRAINT "ProjectRoundState_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "ProjectRoundState" ADD CONSTRAINT "ProjectRoundState_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectRoundState" ADD CONSTRAINT "ProjectRoundState_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AdvancementRule
|
-- AdvancementRule
|
||||||
ALTER TABLE "AdvancementRule" ADD CONSTRAINT "AdvancementRule_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AdvancementRule" ADD CONSTRAINT "AdvancementRule_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- JuryGroup
|
-- JuryGroup
|
||||||
ALTER TABLE "JuryGroup" ADD CONSTRAINT "JuryGroup_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "JuryGroup" ADD CONSTRAINT "JuryGroup_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- JuryGroupMember
|
-- JuryGroupMember
|
||||||
ALTER TABLE "JuryGroupMember" ADD CONSTRAINT "JuryGroupMember_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "JuryGroupMember" ADD CONSTRAINT "JuryGroupMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "JuryGroupMember" ADD CONSTRAINT "JuryGroupMember_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "JuryGroupMember" ADD CONSTRAINT "JuryGroupMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- SubmissionWindow
|
-- SubmissionWindow
|
||||||
ALTER TABLE "SubmissionWindow" ADD CONSTRAINT "SubmissionWindow_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SubmissionWindow" ADD CONSTRAINT "SubmissionWindow_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- SubmissionFileRequirement
|
-- SubmissionFileRequirement
|
||||||
ALTER TABLE "SubmissionFileRequirement" ADD CONSTRAINT "SubmissionFileRequirement_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SubmissionFileRequirement" ADD CONSTRAINT "SubmissionFileRequirement_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- RoundSubmissionVisibility
|
-- RoundSubmissionVisibility
|
||||||
ALTER TABLE "RoundSubmissionVisibility" ADD CONSTRAINT "RoundSubmissionVisibility_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "RoundSubmissionVisibility" ADD CONSTRAINT "RoundSubmissionVisibility_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "RoundSubmissionVisibility" ADD CONSTRAINT "RoundSubmissionVisibility_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "RoundSubmissionVisibility" ADD CONSTRAINT "RoundSubmissionVisibility_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AssignmentIntent
|
-- AssignmentIntent
|
||||||
ALTER TABLE "AssignmentIntent" ADD CONSTRAINT "AssignmentIntent_juryGroupMemberId_fkey" FOREIGN KEY ("juryGroupMemberId") REFERENCES "JuryGroupMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "AssignmentIntent" ADD CONSTRAINT "AssignmentIntent_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "AssignmentIntent" ADD CONSTRAINT "AssignmentIntent_juryGroupMemberId_fkey" FOREIGN KEY ("juryGroupMemberId") REFERENCES "JuryGroupMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "AssignmentIntent" ADD CONSTRAINT "AssignmentIntent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AssignmentIntent" ADD CONSTRAINT "AssignmentIntent_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AssignmentIntent" ADD CONSTRAINT "AssignmentIntent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- AssignmentException
|
-- AssignmentException
|
||||||
ALTER TABLE "AssignmentException" ADD CONSTRAINT "AssignmentException_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "AssignmentException" ADD CONSTRAINT "AssignmentException_approvedById_fkey" FOREIGN KEY ("approvedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "AssignmentException" ADD CONSTRAINT "AssignmentException_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AssignmentException" ADD CONSTRAINT "AssignmentException_approvedById_fkey" FOREIGN KEY ("approvedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- MentorFile
|
-- MentorFile
|
||||||
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_mentorAssignmentId_fkey" FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_uploadedByUserId_fkey" FOREIGN KEY ("uploadedByUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_mentorAssignmentId_fkey" FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_promotedByUserId_fkey" FOREIGN KEY ("promotedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_promotedToFileId_fkey" FOREIGN KEY ("promotedToFileId") REFERENCES "ProjectFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_uploadedByUserId_fkey" FOREIGN KEY ("uploadedByUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_promotedByUserId_fkey" FOREIGN KEY ("promotedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_promotedToFileId_fkey" FOREIGN KEY ("promotedToFileId") REFERENCES "ProjectFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- MentorFileComment
|
-- MentorFileComment
|
||||||
ALTER TABLE "MentorFileComment" ADD CONSTRAINT "MentorFileComment_mentorFileId_fkey" FOREIGN KEY ("mentorFileId") REFERENCES "MentorFile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "MentorFileComment" ADD CONSTRAINT "MentorFileComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "MentorFileComment" ADD CONSTRAINT "MentorFileComment_mentorFileId_fkey" FOREIGN KEY ("mentorFileId") REFERENCES "MentorFile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "MentorFileComment" ADD CONSTRAINT "MentorFileComment_parentCommentId_fkey" FOREIGN KEY ("parentCommentId") REFERENCES "MentorFileComment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorFileComment" ADD CONSTRAINT "MentorFileComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorFileComment" ADD CONSTRAINT "MentorFileComment_parentCommentId_fkey" FOREIGN KEY ("parentCommentId") REFERENCES "MentorFileComment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- SubmissionPromotionEvent
|
-- SubmissionPromotionEvent
|
||||||
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_sourceFileId_fkey" FOREIGN KEY ("sourceFileId") REFERENCES "MentorFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_promotedById_fkey" FOREIGN KEY ("promotedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_sourceFileId_fkey" FOREIGN KEY ("sourceFileId") REFERENCES "MentorFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_promotedById_fkey" FOREIGN KEY ("promotedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- DeliberationSession
|
-- DeliberationSession
|
||||||
ALTER TABLE "DeliberationSession" ADD CONSTRAINT "DeliberationSession_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "DeliberationSession" ADD CONSTRAINT "DeliberationSession_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "DeliberationSession" ADD CONSTRAINT "DeliberationSession_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DeliberationSession" ADD CONSTRAINT "DeliberationSession_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- DeliberationVote
|
-- DeliberationVote
|
||||||
ALTER TABLE "DeliberationVote" ADD CONSTRAINT "DeliberationVote_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "DeliberationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "DeliberationVote" ADD CONSTRAINT "DeliberationVote_juryMemberId_fkey" FOREIGN KEY ("juryMemberId") REFERENCES "JuryGroupMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "DeliberationVote" ADD CONSTRAINT "DeliberationVote_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "DeliberationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "DeliberationVote" ADD CONSTRAINT "DeliberationVote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DeliberationVote" ADD CONSTRAINT "DeliberationVote_juryMemberId_fkey" FOREIGN KEY ("juryMemberId") REFERENCES "JuryGroupMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DeliberationVote" ADD CONSTRAINT "DeliberationVote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- DeliberationResult
|
-- DeliberationResult
|
||||||
ALTER TABLE "DeliberationResult" ADD CONSTRAINT "DeliberationResult_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "DeliberationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "DeliberationResult" ADD CONSTRAINT "DeliberationResult_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "DeliberationResult" ADD CONSTRAINT "DeliberationResult_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "DeliberationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DeliberationResult" ADD CONSTRAINT "DeliberationResult_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- DeliberationParticipant
|
-- DeliberationParticipant
|
||||||
ALTER TABLE "DeliberationParticipant" ADD CONSTRAINT "DeliberationParticipant_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "DeliberationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "DeliberationParticipant" ADD CONSTRAINT "DeliberationParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "JuryGroupMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "DeliberationParticipant" ADD CONSTRAINT "DeliberationParticipant_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "DeliberationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "DeliberationParticipant" ADD CONSTRAINT "DeliberationParticipant_replacedById_fkey" FOREIGN KEY ("replacedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DeliberationParticipant" ADD CONSTRAINT "DeliberationParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "JuryGroupMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "DeliberationParticipant" ADD CONSTRAINT "DeliberationParticipant_replacedById_fkey" FOREIGN KEY ("replacedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ResultLock
|
-- ResultLock
|
||||||
ALTER TABLE "ResultLock" ADD CONSTRAINT "ResultLock_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "ResultLock" ADD CONSTRAINT "ResultLock_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "ResultLock" ADD CONSTRAINT "ResultLock_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "ResultLock" ADD CONSTRAINT "ResultLock_lockedById_fkey" FOREIGN KEY ("lockedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ResultLock" ADD CONSTRAINT "ResultLock_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ResultLock" ADD CONSTRAINT "ResultLock_lockedById_fkey" FOREIGN KEY ("lockedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ResultUnlockEvent
|
-- ResultUnlockEvent
|
||||||
ALTER TABLE "ResultUnlockEvent" ADD CONSTRAINT "ResultUnlockEvent_resultLockId_fkey" FOREIGN KEY ("resultLockId") REFERENCES "ResultLock"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "ResultUnlockEvent" ADD CONSTRAINT "ResultUnlockEvent_unlockedById_fkey" FOREIGN KEY ("unlockedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "ResultUnlockEvent" ADD CONSTRAINT "ResultUnlockEvent_resultLockId_fkey" FOREIGN KEY ("resultLockId") REFERENCES "ResultLock"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ResultUnlockEvent" ADD CONSTRAINT "ResultUnlockEvent_unlockedById_fkey" FOREIGN KEY ("unlockedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- FKs on modified existing tables
|
-- FKs on modified existing tables
|
||||||
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_evaluationRoundId_fkey" FOREIGN KEY ("evaluationRoundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "MentorMessage" ADD CONSTRAINT "MentorMessage_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_submissionFileRequirementId_fkey" FOREIGN KEY ("submissionFileRequirementId") REFERENCES "SubmissionFileRequirement"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_evaluationRoundId_fkey" FOREIGN KEY ("evaluationRoundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MentorMessage" ADD CONSTRAINT "MentorMessage_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_submissionFileRequirementId_fkey" FOREIGN KEY ("submissionFileRequirementId") REFERENCES "SubmissionFileRequirement"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "JuryGroupMember" ADD COLUMN "selfServiceCap" INTEGER,
|
DO $$ BEGIN
|
||||||
ADD COLUMN "selfServiceRatio" DOUBLE PRECISION;
|
ALTER TABLE "JuryGroupMember" ADD COLUMN "selfServiceCap" INTEGER;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "JuryGroupMember" ADD COLUMN "selfServiceRatio" DOUBLE PRECISION;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- Phase 7/8 Migration Part 1: Rename stageId → roundId on 15 tables
|
-- Phase 7/8 Migration Part 1: Rename stageId -> roundId on 15 tables
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- This migration renames stageId columns to roundId and updates FK constraints
|
-- This migration renames stageId columns to roundId and updates FK constraints
|
||||||
-- to point to the Round table instead of Stage table.
|
-- to point to the Round table instead of Stage table.
|
||||||
--
|
--
|
||||||
-- NOTE: After the pipeline migration (20260213), most tables have BOTH a
|
-- NOTE: After the pipeline migration (20260213), most tables have BOTH a
|
||||||
-- nullable roundId column (legacy, no FK) AND a stageId column. We must
|
-- nullable roundId column (legacy, no FK) AND a stageId column. We must
|
||||||
-- drop the old roundId column before renaming stageId → roundId.
|
-- drop the old roundId column before renaming stageId -> roundId.
|
||||||
|
|
||||||
-- ─── 1. EvaluationForm ───────────────────────────────────────────────────────
|
-- --- 1. EvaluationForm ---
|
||||||
|
|
||||||
-- Drop old roundId column (nullable, no FK since 20260213 migration)
|
-- Drop old roundId column (nullable, no FK since 20260213 migration)
|
||||||
ALTER TABLE "EvaluationForm" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "EvaluationForm" DROP COLUMN IF EXISTS "roundId";
|
||||||
@@ -20,18 +20,22 @@ ALTER TABLE "EvaluationForm" DROP CONSTRAINT IF EXISTS "EvaluationForm_stageId_f
|
|||||||
DROP INDEX IF EXISTS "EvaluationForm_stageId_version_key";
|
DROP INDEX IF EXISTS "EvaluationForm_stageId_version_key";
|
||||||
DROP INDEX IF EXISTS "EvaluationForm_stageId_isActive_idx";
|
DROP INDEX IF EXISTS "EvaluationForm_stageId_isActive_idx";
|
||||||
|
|
||||||
-- Rename column
|
-- Rename column (only if stageId exists)
|
||||||
ALTER TABLE "EvaluationForm" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationForm" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
-- Recreate indexes with new name
|
-- Recreate indexes with new name
|
||||||
CREATE UNIQUE INDEX "EvaluationForm_roundId_version_key" ON "EvaluationForm"("roundId", "version");
|
CREATE UNIQUE INDEX IF NOT EXISTS "EvaluationForm_roundId_version_key" ON "EvaluationForm"("roundId", "version");
|
||||||
CREATE INDEX "EvaluationForm_roundId_isActive_idx" ON "EvaluationForm"("roundId", "isActive");
|
CREATE INDEX IF NOT EXISTS "EvaluationForm_roundId_isActive_idx" ON "EvaluationForm"("roundId", "isActive");
|
||||||
|
|
||||||
-- Recreate FK pointing to Round
|
-- Recreate FK pointing to Round
|
||||||
ALTER TABLE "EvaluationForm" ADD CONSTRAINT "EvaluationForm_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "EvaluationForm" ADD CONSTRAINT "EvaluationForm_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 2. FileRequirement ──────────────────────────────────────────────────────
|
-- --- 2. FileRequirement ---
|
||||||
|
|
||||||
ALTER TABLE "FileRequirement" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "FileRequirement" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -39,14 +43,18 @@ ALTER TABLE "FileRequirement" DROP CONSTRAINT IF EXISTS "FileRequirement_stageId
|
|||||||
|
|
||||||
DROP INDEX IF EXISTS "FileRequirement_stageId_idx";
|
DROP INDEX IF EXISTS "FileRequirement_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "FileRequirement" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "FileRequirement" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE INDEX "FileRequirement_roundId_idx" ON "FileRequirement"("roundId");
|
CREATE INDEX IF NOT EXISTS "FileRequirement_roundId_idx" ON "FileRequirement"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "FileRequirement" ADD CONSTRAINT "FileRequirement_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "FileRequirement" ADD CONSTRAINT "FileRequirement_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 3. Assignment ───────────────────────────────────────────────────────────
|
-- --- 3. Assignment ---
|
||||||
|
|
||||||
ALTER TABLE "Assignment" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "Assignment" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -55,15 +63,19 @@ ALTER TABLE "Assignment" DROP CONSTRAINT IF EXISTS "Assignment_stageId_fkey";
|
|||||||
DROP INDEX IF EXISTS "Assignment_userId_projectId_stageId_key";
|
DROP INDEX IF EXISTS "Assignment_userId_projectId_stageId_key";
|
||||||
DROP INDEX IF EXISTS "Assignment_stageId_idx";
|
DROP INDEX IF EXISTS "Assignment_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "Assignment" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Assignment" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "Assignment_userId_projectId_roundId_key" ON "Assignment"("userId", "projectId", "roundId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "Assignment_userId_projectId_roundId_key" ON "Assignment"("userId", "projectId", "roundId");
|
||||||
CREATE INDEX "Assignment_roundId_idx" ON "Assignment"("roundId");
|
CREATE INDEX IF NOT EXISTS "Assignment_roundId_idx" ON "Assignment"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 4. GracePeriod ──────────────────────────────────────────────────────────
|
-- --- 4. GracePeriod ---
|
||||||
|
|
||||||
ALTER TABLE "GracePeriod" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "GracePeriod" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -72,15 +84,19 @@ ALTER TABLE "GracePeriod" DROP CONSTRAINT IF EXISTS "GracePeriod_stageId_fkey";
|
|||||||
DROP INDEX IF EXISTS "GracePeriod_stageId_idx";
|
DROP INDEX IF EXISTS "GracePeriod_stageId_idx";
|
||||||
DROP INDEX IF EXISTS "GracePeriod_stageId_userId_extendedUntil_idx";
|
DROP INDEX IF EXISTS "GracePeriod_stageId_userId_extendedUntil_idx";
|
||||||
|
|
||||||
ALTER TABLE "GracePeriod" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "GracePeriod" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE INDEX "GracePeriod_roundId_idx" ON "GracePeriod"("roundId");
|
CREATE INDEX IF NOT EXISTS "GracePeriod_roundId_idx" ON "GracePeriod"("roundId");
|
||||||
CREATE INDEX "GracePeriod_roundId_userId_extendedUntil_idx" ON "GracePeriod"("roundId", "userId", "extendedUntil");
|
CREATE INDEX IF NOT EXISTS "GracePeriod_roundId_userId_extendedUntil_idx" ON "GracePeriod"("roundId", "userId", "extendedUntil");
|
||||||
|
|
||||||
ALTER TABLE "GracePeriod" ADD CONSTRAINT "GracePeriod_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "GracePeriod" ADD CONSTRAINT "GracePeriod_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 5. LiveVotingSession ────────────────────────────────────────────────────
|
-- --- 5. LiveVotingSession ---
|
||||||
|
|
||||||
ALTER TABLE "LiveVotingSession" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "LiveVotingSession" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -88,14 +104,18 @@ ALTER TABLE "LiveVotingSession" DROP CONSTRAINT IF EXISTS "LiveVotingSession_sta
|
|||||||
|
|
||||||
DROP INDEX IF EXISTS "LiveVotingSession_stageId_key";
|
DROP INDEX IF EXISTS "LiveVotingSession_stageId_key";
|
||||||
|
|
||||||
ALTER TABLE "LiveVotingSession" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "LiveVotingSession" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "LiveVotingSession_roundId_key" ON "LiveVotingSession"("roundId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "LiveVotingSession_roundId_key" ON "LiveVotingSession"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "LiveVotingSession" ADD CONSTRAINT "LiveVotingSession_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "LiveVotingSession" ADD CONSTRAINT "LiveVotingSession_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 6. FilteringRule ────────────────────────────────────────────────────────
|
-- --- 6. FilteringRule ---
|
||||||
|
|
||||||
ALTER TABLE "FilteringRule" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "FilteringRule" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -103,14 +123,18 @@ ALTER TABLE "FilteringRule" DROP CONSTRAINT IF EXISTS "FilteringRule_stageId_fke
|
|||||||
|
|
||||||
DROP INDEX IF EXISTS "FilteringRule_stageId_idx";
|
DROP INDEX IF EXISTS "FilteringRule_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "FilteringRule" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "FilteringRule" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE INDEX "FilteringRule_roundId_idx" ON "FilteringRule"("roundId");
|
CREATE INDEX IF NOT EXISTS "FilteringRule_roundId_idx" ON "FilteringRule"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "FilteringRule" ADD CONSTRAINT "FilteringRule_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "FilteringRule" ADD CONSTRAINT "FilteringRule_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 7. FilteringResult ──────────────────────────────────────────────────────
|
-- --- 7. FilteringResult ---
|
||||||
|
|
||||||
ALTER TABLE "FilteringResult" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "FilteringResult" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -119,15 +143,19 @@ ALTER TABLE "FilteringResult" DROP CONSTRAINT IF EXISTS "FilteringResult_stageId
|
|||||||
DROP INDEX IF EXISTS "FilteringResult_stageId_projectId_key";
|
DROP INDEX IF EXISTS "FilteringResult_stageId_projectId_key";
|
||||||
DROP INDEX IF EXISTS "FilteringResult_stageId_idx";
|
DROP INDEX IF EXISTS "FilteringResult_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "FilteringResult" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "FilteringResult" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "FilteringResult_roundId_projectId_key" ON "FilteringResult"("roundId", "projectId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "FilteringResult_roundId_projectId_key" ON "FilteringResult"("roundId", "projectId");
|
||||||
CREATE INDEX "FilteringResult_roundId_idx" ON "FilteringResult"("roundId");
|
CREATE INDEX IF NOT EXISTS "FilteringResult_roundId_idx" ON "FilteringResult"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "FilteringResult" ADD CONSTRAINT "FilteringResult_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "FilteringResult" ADD CONSTRAINT "FilteringResult_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 8. FilteringJob ─────────────────────────────────────────────────────────
|
-- --- 8. FilteringJob ---
|
||||||
|
|
||||||
ALTER TABLE "FilteringJob" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "FilteringJob" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -135,14 +163,18 @@ ALTER TABLE "FilteringJob" DROP CONSTRAINT IF EXISTS "FilteringJob_stageId_fkey"
|
|||||||
|
|
||||||
DROP INDEX IF EXISTS "FilteringJob_stageId_idx";
|
DROP INDEX IF EXISTS "FilteringJob_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "FilteringJob" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "FilteringJob" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE INDEX "FilteringJob_roundId_idx" ON "FilteringJob"("roundId");
|
CREATE INDEX IF NOT EXISTS "FilteringJob_roundId_idx" ON "FilteringJob"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "FilteringJob" ADD CONSTRAINT "FilteringJob_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "FilteringJob" ADD CONSTRAINT "FilteringJob_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 9. AssignmentJob ────────────────────────────────────────────────────────
|
-- --- 9. AssignmentJob ---
|
||||||
|
|
||||||
ALTER TABLE "AssignmentJob" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "AssignmentJob" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -150,14 +182,18 @@ ALTER TABLE "AssignmentJob" DROP CONSTRAINT IF EXISTS "AssignmentJob_stageId_fke
|
|||||||
|
|
||||||
DROP INDEX IF EXISTS "AssignmentJob_stageId_idx";
|
DROP INDEX IF EXISTS "AssignmentJob_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "AssignmentJob" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AssignmentJob" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE INDEX "AssignmentJob_roundId_idx" ON "AssignmentJob"("roundId");
|
CREATE INDEX IF NOT EXISTS "AssignmentJob_roundId_idx" ON "AssignmentJob"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "AssignmentJob" ADD CONSTRAINT "AssignmentJob_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "AssignmentJob" ADD CONSTRAINT "AssignmentJob_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 10. ReminderLog ─────────────────────────────────────────────────────────
|
-- --- 10. ReminderLog ---
|
||||||
|
|
||||||
ALTER TABLE "ReminderLog" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "ReminderLog" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -166,15 +202,19 @@ ALTER TABLE "ReminderLog" DROP CONSTRAINT IF EXISTS "ReminderLog_stageId_fkey";
|
|||||||
DROP INDEX IF EXISTS "ReminderLog_stageId_userId_type_key";
|
DROP INDEX IF EXISTS "ReminderLog_stageId_userId_type_key";
|
||||||
DROP INDEX IF EXISTS "ReminderLog_stageId_idx";
|
DROP INDEX IF EXISTS "ReminderLog_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "ReminderLog" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ReminderLog" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "ReminderLog_roundId_userId_type_key" ON "ReminderLog"("roundId", "userId", "type");
|
CREATE UNIQUE INDEX IF NOT EXISTS "ReminderLog_roundId_userId_type_key" ON "ReminderLog"("roundId", "userId", "type");
|
||||||
CREATE INDEX "ReminderLog_roundId_idx" ON "ReminderLog"("roundId");
|
CREATE INDEX IF NOT EXISTS "ReminderLog_roundId_idx" ON "ReminderLog"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "ReminderLog" ADD CONSTRAINT "ReminderLog_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "ReminderLog" ADD CONSTRAINT "ReminderLog_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 11. EvaluationSummary ───────────────────────────────────────────────────
|
-- --- 11. EvaluationSummary ---
|
||||||
|
|
||||||
ALTER TABLE "EvaluationSummary" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "EvaluationSummary" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -183,15 +223,19 @@ ALTER TABLE "EvaluationSummary" DROP CONSTRAINT IF EXISTS "EvaluationSummary_sta
|
|||||||
DROP INDEX IF EXISTS "EvaluationSummary_projectId_stageId_key";
|
DROP INDEX IF EXISTS "EvaluationSummary_projectId_stageId_key";
|
||||||
DROP INDEX IF EXISTS "EvaluationSummary_stageId_idx";
|
DROP INDEX IF EXISTS "EvaluationSummary_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "EvaluationSummary" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationSummary" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "EvaluationSummary_projectId_roundId_key" ON "EvaluationSummary"("projectId", "roundId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "EvaluationSummary_projectId_roundId_key" ON "EvaluationSummary"("projectId", "roundId");
|
||||||
CREATE INDEX "EvaluationSummary_roundId_idx" ON "EvaluationSummary"("roundId");
|
CREATE INDEX IF NOT EXISTS "EvaluationSummary_roundId_idx" ON "EvaluationSummary"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "EvaluationSummary" ADD CONSTRAINT "EvaluationSummary_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "EvaluationSummary" ADD CONSTRAINT "EvaluationSummary_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 12. EvaluationDiscussion ────────────────────────────────────────────────
|
-- --- 12. EvaluationDiscussion ---
|
||||||
|
|
||||||
ALTER TABLE "EvaluationDiscussion" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "EvaluationDiscussion" DROP COLUMN IF EXISTS "roundId";
|
||||||
|
|
||||||
@@ -200,15 +244,19 @@ ALTER TABLE "EvaluationDiscussion" DROP CONSTRAINT IF EXISTS "EvaluationDiscussi
|
|||||||
DROP INDEX IF EXISTS "EvaluationDiscussion_projectId_stageId_key";
|
DROP INDEX IF EXISTS "EvaluationDiscussion_projectId_stageId_key";
|
||||||
DROP INDEX IF EXISTS "EvaluationDiscussion_stageId_idx";
|
DROP INDEX IF EXISTS "EvaluationDiscussion_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "EvaluationDiscussion" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "EvaluationDiscussion" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "EvaluationDiscussion_projectId_roundId_key" ON "EvaluationDiscussion"("projectId", "roundId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "EvaluationDiscussion_projectId_roundId_key" ON "EvaluationDiscussion"("projectId", "roundId");
|
||||||
CREATE INDEX "EvaluationDiscussion_roundId_idx" ON "EvaluationDiscussion"("roundId");
|
CREATE INDEX IF NOT EXISTS "EvaluationDiscussion_roundId_idx" ON "EvaluationDiscussion"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 13. Message ─────────────────────────────────────────────────────────────
|
-- --- 13. Message ---
|
||||||
|
|
||||||
-- Message has roundId (from init, nullable) and stageId (from pipeline, nullable)
|
-- Message has roundId (from init, nullable) and stageId (from pipeline, nullable)
|
||||||
ALTER TABLE "Message" DROP COLUMN IF EXISTS "roundId";
|
ALTER TABLE "Message" DROP COLUMN IF EXISTS "roundId";
|
||||||
@@ -217,42 +265,54 @@ ALTER TABLE "Message" DROP CONSTRAINT IF EXISTS "Message_stageId_fkey";
|
|||||||
|
|
||||||
DROP INDEX IF EXISTS "Message_stageId_idx";
|
DROP INDEX IF EXISTS "Message_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "Message" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Message" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE INDEX "Message_roundId_idx" ON "Message"("roundId");
|
CREATE INDEX IF NOT EXISTS "Message_roundId_idx" ON "Message"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "Message" ADD CONSTRAINT "Message_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
ALTER TABLE "Message" ADD CONSTRAINT "Message_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 14. Cohort ──────────────────────────────────────────────────────────────
|
-- --- 14. Cohort ---
|
||||||
-- Cohort was created in pipeline migration with stageId only (no roundId)
|
-- Cohort was created in pipeline migration with stageId only (no roundId)
|
||||||
|
|
||||||
ALTER TABLE "Cohort" DROP CONSTRAINT IF EXISTS "Cohort_stageId_fkey";
|
ALTER TABLE "Cohort" DROP CONSTRAINT IF EXISTS "Cohort_stageId_fkey";
|
||||||
|
|
||||||
DROP INDEX IF EXISTS "Cohort_stageId_idx";
|
DROP INDEX IF EXISTS "Cohort_stageId_idx";
|
||||||
|
|
||||||
ALTER TABLE "Cohort" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Cohort" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE INDEX "Cohort_roundId_idx" ON "Cohort"("roundId");
|
CREATE INDEX IF NOT EXISTS "Cohort_roundId_idx" ON "Cohort"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "Cohort" ADD CONSTRAINT "Cohort_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "Cohort" ADD CONSTRAINT "Cohort_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 15. LiveProgressCursor ──────────────────────────────────────────────────
|
-- --- 15. LiveProgressCursor ---
|
||||||
-- LiveProgressCursor was created in pipeline migration with stageId only (no roundId)
|
-- LiveProgressCursor was created in pipeline migration with stageId only (no roundId)
|
||||||
|
|
||||||
ALTER TABLE "LiveProgressCursor" DROP CONSTRAINT IF EXISTS "LiveProgressCursor_stageId_fkey";
|
ALTER TABLE "LiveProgressCursor" DROP CONSTRAINT IF EXISTS "LiveProgressCursor_stageId_fkey";
|
||||||
|
|
||||||
DROP INDEX IF EXISTS "LiveProgressCursor_stageId_key";
|
DROP INDEX IF EXISTS "LiveProgressCursor_stageId_key";
|
||||||
|
|
||||||
ALTER TABLE "LiveProgressCursor" RENAME COLUMN "stageId" TO "roundId";
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "LiveProgressCursor" RENAME COLUMN "stageId" TO "roundId";
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL; END $$;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "LiveProgressCursor_roundId_key" ON "LiveProgressCursor"("roundId");
|
CREATE UNIQUE INDEX IF NOT EXISTS "LiveProgressCursor_roundId_key" ON "LiveProgressCursor"("roundId");
|
||||||
|
|
||||||
ALTER TABLE "LiveProgressCursor" ADD CONSTRAINT "LiveProgressCursor_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "LiveProgressCursor" ADD CONSTRAINT "LiveProgressCursor_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 16. SpecialAward: Drop trackId column ───────────────────────────────────
|
-- --- 16. SpecialAward: Drop trackId column ---
|
||||||
|
|
||||||
ALTER TABLE "SpecialAward" DROP CONSTRAINT IF EXISTS "SpecialAward_trackId_fkey";
|
ALTER TABLE "SpecialAward" DROP CONSTRAINT IF EXISTS "SpecialAward_trackId_fkey";
|
||||||
|
|
||||||
@@ -260,12 +320,16 @@ DROP INDEX IF EXISTS "SpecialAward_trackId_key";
|
|||||||
|
|
||||||
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "trackId";
|
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "trackId";
|
||||||
|
|
||||||
-- ─── 17. ConflictOfInterest: roundId was made nullable in pipeline migration ─
|
-- --- 17. ConflictOfInterest: roundId was made nullable in pipeline migration ---
|
||||||
-- It still exists, just restore FK to new Round table
|
-- It still exists, just restore FK to new Round table
|
||||||
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- ─── 18. TaggingJob: roundId was made nullable in pipeline migration ─────────
|
-- --- 18. TaggingJob: roundId was made nullable in pipeline migration ---
|
||||||
-- Restore FK to new Round table
|
-- Restore FK to new Round table
|
||||||
ALTER TABLE "TaggingJob" ADD CONSTRAINT "TaggingJob_roundId_fkey"
|
DO $$ BEGIN
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
ALTER TABLE "TaggingJob" ADD CONSTRAINT "TaggingJob_roundId_fkey"
|
||||||
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add pageCount column to ProjectFile (was in schema but missing migration)
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN IF NOT EXISTS "pageCount" INTEGER;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Schema Reconciliation: Fill remaining gaps between migrations and schema.prisma
|
||||||
|
-- =============================================================================
|
||||||
|
-- All statements are idempotent (safe to re-run on any database state).
|
||||||
|
|
||||||
|
-- 1. ConflictOfInterest: add standalone hasConflict index (schema has @@index([hasConflict]))
|
||||||
|
-- Migration 20260205223133 only created composite (roundId, hasConflict) index.
|
||||||
|
CREATE INDEX IF NOT EXISTS "ConflictOfInterest_hasConflict_idx" ON "ConflictOfInterest"("hasConflict");
|
||||||
|
|
||||||
|
-- 2. Ensure ConflictOfInterest.roundId is nullable (schema says String?)
|
||||||
|
-- Pipeline migration (20260213) makes it nullable, but guard for safety.
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ConflictOfInterest" ALTER COLUMN "roundId" DROP NOT NULL;
|
||||||
|
EXCEPTION WHEN others THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 3. Drop stale composite index that no longer matches schema
|
||||||
|
-- Schema only has @@index([hasConflict]) and @@index([userId]), not (roundId, hasConflict).
|
||||||
|
DROP INDEX IF EXISTS "ConflictOfInterest_roundId_hasConflict_idx";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "textPreview" TEXT;
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "detectedLang" TEXT;
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "langConfidence" DOUBLE PRECISION;
|
||||||
|
ALTER TABLE "ProjectFile" ADD COLUMN "analyzedAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- AlterTable: Add shortlistSize to SpecialAward
|
||||||
|
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "shortlistSize" INTEGER NOT NULL DEFAULT 10;
|
||||||
|
|
||||||
|
-- AlterTable: Add qualityScore, shortlisted, confirmedAt, confirmedBy to AwardEligibility
|
||||||
|
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "qualityScore" DOUBLE PRECISION;
|
||||||
|
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "shortlisted" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "confirmedAt" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "confirmedBy" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable: Add specialAwardId to Round
|
||||||
|
ALTER TABLE "Round" ADD COLUMN IF NOT EXISTS "specialAwardId" TEXT;
|
||||||
|
|
||||||
|
-- AddForeignKey: AwardEligibility.confirmedBy -> User.id
|
||||||
|
ALTER TABLE "AwardEligibility" ADD CONSTRAINT "AwardEligibility_confirmedBy_fkey" FOREIGN KEY ("confirmedBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey: Round.specialAwardId -> SpecialAward.id
|
||||||
|
ALTER TABLE "Round" ADD CONSTRAINT "Round_specialAwardId_fkey" FOREIGN KEY ("specialAwardId") REFERENCES "SpecialAward"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX IF NOT EXISTS "Round_specialAwardId_idx" ON "Round"("specialAwardId");
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Delete any existing LOCALIZATION settings
|
||||||
|
DELETE FROM "SystemSettings" WHERE category = 'LOCALIZATION';
|
||||||
|
|
||||||
|
-- Add provider field to AIUsageLog for cross-provider cost tracking
|
||||||
|
ALTER TABLE "AIUsageLog" ADD COLUMN "provider" TEXT;
|
||||||
|
|
||||||
|
-- Remove LOCALIZATION from SettingCategory enum
|
||||||
|
-- First create new enum without the value, then swap
|
||||||
|
CREATE TYPE "SettingCategory_new" AS ENUM ('AI', 'BRANDING', 'EMAIL', 'STORAGE', 'SECURITY', 'DEFAULTS', 'WHATSAPP', 'AUDIT_CONFIG', 'DIGEST', 'ANALYTICS', 'INTEGRATIONS', 'COMMUNICATION', 'FEATURE_FLAGS');
|
||||||
|
ALTER TABLE "SystemSettings" ALTER COLUMN "category" TYPE "SettingCategory_new" USING ("category"::text::"SettingCategory_new");
|
||||||
|
ALTER TYPE "SettingCategory" RENAME TO "SettingCategory_old";
|
||||||
|
ALTER TYPE "SettingCategory_new" RENAME TO "SettingCategory";
|
||||||
|
DROP TYPE "SettingCategory_old";
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Learning Hub Overhaul: Remove ResourceType/CohortLevel enums, add accessJson + coverImageKey
|
||||||
|
|
||||||
|
-- Drop columns that reference the enums
|
||||||
|
ALTER TABLE "LearningResource" DROP COLUMN "resourceType";
|
||||||
|
ALTER TABLE "LearningResource" DROP COLUMN "cohortLevel";
|
||||||
|
|
||||||
|
-- Drop the cohortLevel index
|
||||||
|
DROP INDEX IF EXISTS "LearningResource_cohortLevel_idx";
|
||||||
|
|
||||||
|
-- Add new columns
|
||||||
|
ALTER TABLE "LearningResource" ADD COLUMN "accessJson" JSONB;
|
||||||
|
ALTER TABLE "LearningResource" ADD COLUMN "coverImageKey" TEXT;
|
||||||
|
|
||||||
|
-- Drop the enum types
|
||||||
|
DROP TYPE IF EXISTS "ResourceType";
|
||||||
|
DROP TYPE IF EXISTS "CohortLevel";
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "ConflictOfInterest" DROP CONSTRAINT "ConflictOfInterest_roundId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Project" DROP CONSTRAINT "Project_programId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "TaggingJob" DROP CONSTRAINT "TaggingJob_roundId_fkey";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "DiscussionComment_discussionId_createdAt_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "EvaluationDiscussion_status_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "LiveVote_isAudienceVote_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Message_scheduledAt_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "MessageRecipient_messageId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "MessageRecipient_userId_isRead_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Project_programId_roundId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Project_roundId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "ProjectFile_projectId_roundId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "ProjectFile_roundId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "TaggingJob_roundId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "WebhookDelivery_createdAt_idx";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "roles" "UserRole"[] DEFAULT ARRAY[]::"UserRole"[];
|
||||||
|
|
||||||
|
-- Backfill: populate roles array from existing role column
|
||||||
|
UPDATE "User" SET "roles" = ARRAY["role"]::"UserRole"[];
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Project" ADD CONSTRAINT "Project_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MessageTemplate" ADD CONSTRAINT "MessageTemplate_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- RenameIndex
|
||||||
|
ALTER INDEX "DeliberationVote_sessionId_juryMemberId_projectId_runoffRo_key" RENAME TO "DeliberationVote_sessionId_juryMemberId_projectId_runoffRou_key";
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "nationality" TEXT;
|
||||||
|
ALTER TABLE "User" ADD COLUMN "institution" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "RankingMode" ADD VALUE 'FORMULA';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "AwardEligibility" ADD COLUMN "notifiedAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
-- Round finalization fields
|
||||||
|
ALTER TABLE "Round" ADD COLUMN "gracePeriodEndsAt" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "Round" ADD COLUMN "finalizedAt" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "Round" ADD COLUMN "finalizedBy" TEXT;
|
||||||
|
|
||||||
|
-- ProjectRoundState proposed outcome for finalization pool
|
||||||
|
ALTER TABLE "ProjectRoundState" ADD COLUMN "proposedOutcome" "ProjectRoundStateValue";
|
||||||
|
|
||||||
|
-- Mark already-closed rounds as pre-finalized IF their projects were already
|
||||||
|
-- advanced to the IMMEDIATELY NEXT round (sortOrder = current + 1).
|
||||||
|
-- We check the next sequential round only, not any subsequent round, because
|
||||||
|
-- projects can appear in non-adjacent rounds (e.g. special award tracks) without
|
||||||
|
-- implying the current round was finalized.
|
||||||
|
UPDATE "Round" r
|
||||||
|
SET "finalizedAt" = NOW(), "finalizedBy" = 'system-migration'
|
||||||
|
WHERE r.status IN ('ROUND_CLOSED', 'ROUND_ARCHIVED')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "Round" next_r
|
||||||
|
JOIN "ProjectRoundState" next_prs ON next_prs."roundId" = next_r.id
|
||||||
|
JOIN "ProjectRoundState" cur_prs ON cur_prs."roundId" = r.id
|
||||||
|
AND cur_prs."projectId" = next_prs."projectId"
|
||||||
|
WHERE next_r."competitionId" = r."competitionId"
|
||||||
|
AND next_r."sortOrder" = r."sortOrder" + 1
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ─── Backfill terminal states for already-finalized rounds ───────────────────
|
||||||
|
-- These rounds were finalized manually before this system existed.
|
||||||
|
-- Set ProjectRoundState to accurate terminal states so the data matches reality.
|
||||||
|
-- All updates are guarded by current state + round type to avoid touching anything unexpected.
|
||||||
|
|
||||||
|
-- R0 (INTAKE, closed): All 214 projects completed intake successfully → PASSED
|
||||||
|
-- Guard: only touch COMPLETED states in closed INTAKE rounds marked as finalized
|
||||||
|
UPDATE "ProjectRoundState" prs
|
||||||
|
SET state = 'PASSED', "proposedOutcome" = 'PASSED'
|
||||||
|
FROM "Round" r
|
||||||
|
WHERE prs."roundId" = r.id
|
||||||
|
AND r."roundType" = 'INTAKE'
|
||||||
|
AND r.status = 'ROUND_CLOSED'
|
||||||
|
AND r."finalizedAt" IS NOT NULL
|
||||||
|
AND prs.state = 'COMPLETED';
|
||||||
|
|
||||||
|
-- R1 (FILTERING, closed): Set states based on FilteringResult outcomes
|
||||||
|
-- Projects that passed filtering → PASSED
|
||||||
|
UPDATE "ProjectRoundState" prs
|
||||||
|
SET state = 'PASSED', "proposedOutcome" = 'PASSED'
|
||||||
|
FROM "Round" r
|
||||||
|
WHERE prs."roundId" = r.id
|
||||||
|
AND r."roundType" = 'FILTERING'
|
||||||
|
AND r.status = 'ROUND_CLOSED'
|
||||||
|
AND r."finalizedAt" IS NOT NULL
|
||||||
|
AND prs.state = 'PENDING'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM "FilteringResult" fr
|
||||||
|
WHERE fr."projectId" = prs."projectId"
|
||||||
|
AND (
|
||||||
|
fr."finalOutcome" = 'PASSED'
|
||||||
|
OR (fr."finalOutcome" IS NULL AND fr.outcome IN ('PASSED', 'FLAGGED'))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Projects that were filtered out → REJECTED
|
||||||
|
UPDATE "ProjectRoundState" prs
|
||||||
|
SET state = 'REJECTED', "proposedOutcome" = 'REJECTED'
|
||||||
|
FROM "Round" r
|
||||||
|
WHERE prs."roundId" = r.id
|
||||||
|
AND r."roundType" = 'FILTERING'
|
||||||
|
AND r.status = 'ROUND_CLOSED'
|
||||||
|
AND r."finalizedAt" IS NOT NULL
|
||||||
|
AND prs.state = 'PENDING'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM "FilteringResult" fr
|
||||||
|
WHERE fr."projectId" = prs."projectId"
|
||||||
|
AND (
|
||||||
|
fr."finalOutcome" = 'FILTERED_OUT'
|
||||||
|
OR (fr."finalOutcome" IS NULL AND fr.outcome = 'FILTERED_OUT')
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "NotificationLog" DROP CONSTRAINT "NotificationLog_userId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "NotificationLog" ADD COLUMN "batchId" TEXT,
|
||||||
|
ADD COLUMN "email" TEXT,
|
||||||
|
ADD COLUMN "projectId" TEXT,
|
||||||
|
ADD COLUMN "roundId" TEXT,
|
||||||
|
ALTER COLUMN "userId" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "channel" SET DEFAULT 'EMAIL';
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_roundId_type_idx" ON "NotificationLog"("roundId", "type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_projectId_idx" ON "NotificationLog"("projectId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_batchId_idx" ON "NotificationLog"("batchId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_email_idx" ON "NotificationLog"("email");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "passwordResetToken" TEXT,
|
||||||
|
ADD COLUMN "passwordResetExpiresAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_passwordResetToken_key" ON "User"("passwordResetToken");
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'APPLICANT';
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Assignment_roundId_isCompleted_idx" ON "Assignment"("roundId", "isCompleted");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConflictOfInterest_projectId_idx" ON "ConflictOfInterest"("projectId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConflictOfInterest_userId_hasConflict_idx" ON "ConflictOfInterest"("userId", "hasConflict");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_type_status_idx" ON "NotificationLog"("type", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProjectRoundState_roundId_state_idx" ON "ProjectRoundState"("roundId", "state");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RankingSnapshot_roundId_createdAt_idx" ON "RankingSnapshot"("roundId", "createdAt");
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "AdvancementRule" DROP CONSTRAINT "AdvancementRule_roundId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "AssignmentException" DROP CONSTRAINT "AssignmentException_approvedById_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "AssignmentException" DROP CONSTRAINT "AssignmentException_assignmentId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ConflictOfInterest" DROP COLUMN "roundId";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Evaluation" DROP COLUMN "version";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Project" DROP COLUMN "roundId";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "AdvancementRule";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "AssignmentException";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "NotificationPolicy";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "OverrideAction";
|
||||||
|
|
||||||
|
-- DropEnum
|
||||||
|
DROP TYPE "AdvancementRuleType";
|
||||||
|
|
||||||
|
-- DropEnum
|
||||||
|
DROP TYPE "OverrideReasonCode";
|
||||||
38
prisma/migrations/insert-dropout-reassigned-setting.sql
Normal file
38
prisma/migrations/insert-dropout-reassigned-setting.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
-- Insert missing notification email settings into production DB
|
||||||
|
-- Run manually: psql -d mopc -f prisma/migrations/insert-dropout-reassigned-setting.sql
|
||||||
|
-- Safe to run multiple times (uses ON CONFLICT to skip if already exists)
|
||||||
|
|
||||||
|
INSERT INTO "NotificationEmailSetting" (
|
||||||
|
"id", "notificationType", "category", "label", "description", "sendEmail", "createdAt", "updatedAt"
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
gen_random_uuid()::text,
|
||||||
|
'COI_REASSIGNED',
|
||||||
|
'jury',
|
||||||
|
'COI Reassignment',
|
||||||
|
'When a project is reassigned to you due to another juror''s conflict of interest',
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
),
|
||||||
|
(
|
||||||
|
gen_random_uuid()::text,
|
||||||
|
'MANUAL_REASSIGNED',
|
||||||
|
'jury',
|
||||||
|
'Manual Reassignment',
|
||||||
|
'When an admin manually reassigns a project to you',
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
),
|
||||||
|
(
|
||||||
|
gen_random_uuid()::text,
|
||||||
|
'DROPOUT_REASSIGNED',
|
||||||
|
'jury',
|
||||||
|
'Juror Dropout Reassignment',
|
||||||
|
'When projects are reassigned to you because a juror dropped out or became unavailable',
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT ("notificationType") DO NOTHING;
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (e.g., Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ generator client {
|
|||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
// connection_limit and pool_timeout are set via query params in DATABASE_URL:
|
||||||
|
// ?connection_limit=10&pool_timeout=30
|
||||||
|
// Defaults: connection_limit = num_cpus * 2 + 1, pool_timeout = 10s.
|
||||||
|
// Override in .env for production to prevent connection exhaustion.
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +105,6 @@ enum SettingCategory {
|
|||||||
DEFAULTS
|
DEFAULTS
|
||||||
WHATSAPP
|
WHATSAPP
|
||||||
AUDIT_CONFIG
|
AUDIT_CONFIG
|
||||||
LOCALIZATION
|
|
||||||
DIGEST
|
DIGEST
|
||||||
ANALYTICS
|
ANALYTICS
|
||||||
INTEGRATIONS
|
INTEGRATIONS
|
||||||
@@ -116,19 +119,6 @@ enum NotificationChannel {
|
|||||||
NONE
|
NONE
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ResourceType {
|
|
||||||
PDF
|
|
||||||
VIDEO
|
|
||||||
DOCUMENT
|
|
||||||
LINK
|
|
||||||
OTHER
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CohortLevel {
|
|
||||||
ALL
|
|
||||||
SEMIFINALIST
|
|
||||||
FINALIST
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PartnerVisibility {
|
enum PartnerVisibility {
|
||||||
ADMIN_ONLY
|
ADMIN_ONLY
|
||||||
@@ -144,13 +134,6 @@ enum PartnerType {
|
|||||||
OTHER
|
OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OverrideReasonCode {
|
|
||||||
DATA_CORRECTION
|
|
||||||
POLICY_EXCEPTION
|
|
||||||
JURY_CONFLICT
|
|
||||||
SPONSOR_DECISION
|
|
||||||
ADMIN_DISCRETION
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// COMPETITION / ROUND ENGINE ENUMS
|
// COMPETITION / ROUND ENGINE ENUMS
|
||||||
@@ -189,13 +172,6 @@ enum ProjectRoundStateValue {
|
|||||||
WITHDRAWN
|
WITHDRAWN
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AdvancementRuleType {
|
|
||||||
AUTO_ADVANCE
|
|
||||||
SCORE_THRESHOLD
|
|
||||||
TOP_N
|
|
||||||
ADMIN_SELECTION
|
|
||||||
AI_RECOMMENDED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CapMode {
|
enum CapMode {
|
||||||
HARD
|
HARD
|
||||||
@@ -316,11 +292,14 @@ model User {
|
|||||||
email String @unique
|
email String @unique
|
||||||
name String?
|
name String?
|
||||||
emailVerified DateTime? // Required by NextAuth Prisma adapter
|
emailVerified DateTime? // Required by NextAuth Prisma adapter
|
||||||
role UserRole @default(JURY_MEMBER)
|
role UserRole @default(APPLICANT)
|
||||||
|
roles UserRole[] @default([])
|
||||||
status UserStatus @default(INVITED)
|
status UserStatus @default(INVITED)
|
||||||
expertiseTags String[] @default([])
|
expertiseTags String[] @default([])
|
||||||
maxAssignments Int? // Per-round limit
|
maxAssignments Int? // Per-round limit
|
||||||
country String? // User's home country (for mentor matching)
|
country String? // User's home country (for mentor matching)
|
||||||
|
nationality String? // User's nationality (for applicant profiles)
|
||||||
|
institution String? // User's institution/organization
|
||||||
metadataJson Json? @db.JsonB
|
metadataJson Json? @db.JsonB
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
@@ -346,6 +325,10 @@ model User {
|
|||||||
inviteToken String? @unique
|
inviteToken String? @unique
|
||||||
inviteTokenExpiresAt DateTime?
|
inviteTokenExpiresAt DateTime?
|
||||||
|
|
||||||
|
// Password reset token
|
||||||
|
passwordResetToken String? @unique
|
||||||
|
passwordResetExpiresAt DateTime?
|
||||||
|
|
||||||
// Digest & availability preferences
|
// Digest & availability preferences
|
||||||
digestFrequency String @default("none") // 'none' | 'daily' | 'weekly'
|
digestFrequency String @default("none") // 'none' | 'daily' | 'weekly'
|
||||||
preferredWorkload Int?
|
preferredWorkload Int?
|
||||||
@@ -378,8 +361,9 @@ model User {
|
|||||||
filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy")
|
filteringOverrides FilteringResult[] @relation("FilteringOverriddenBy")
|
||||||
|
|
||||||
// Award overrides
|
// Award overrides
|
||||||
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
|
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
|
||||||
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
|
awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer")
|
||||||
|
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
|
||||||
|
|
||||||
// In-app notifications
|
// In-app notifications
|
||||||
notifications InAppNotification[] @relation("UserNotifications")
|
notifications InAppNotification[] @relation("UserNotifications")
|
||||||
@@ -433,10 +417,12 @@ model User {
|
|||||||
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
|
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
|
||||||
resultLocksCreated ResultLock[] @relation("ResultLockCreator")
|
resultLocksCreated ResultLock[] @relation("ResultLockCreator")
|
||||||
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
|
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
|
||||||
assignmentExceptionsApproved AssignmentException[] @relation("AssignmentExceptionApprover")
|
|
||||||
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
|
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
|
||||||
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
|
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
|
||||||
|
|
||||||
|
// AI Ranking
|
||||||
|
rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots")
|
||||||
|
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
@@ -562,7 +548,6 @@ model EvaluationForm {
|
|||||||
model Project {
|
model Project {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
programId String
|
programId String
|
||||||
roundId String?
|
|
||||||
status ProjectStatus @default(SUBMITTED)
|
status ProjectStatus @default(SUBMITTED)
|
||||||
|
|
||||||
// Core fields
|
// Core fields
|
||||||
@@ -645,6 +630,7 @@ model Project {
|
|||||||
deliberationVotes DeliberationVote[]
|
deliberationVotes DeliberationVote[]
|
||||||
deliberationResults DeliberationResult[]
|
deliberationResults DeliberationResult[]
|
||||||
submissionPromotions SubmissionPromotionEvent[]
|
submissionPromotions SubmissionPromotionEvent[]
|
||||||
|
notificationLogs NotificationLog[]
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -683,10 +669,17 @@ model ProjectFile {
|
|||||||
requirementId String? // FK to FileRequirement (if uploaded against a requirement)
|
requirementId String? // FK to FileRequirement (if uploaded against a requirement)
|
||||||
|
|
||||||
// File info
|
// File info
|
||||||
fileType FileType
|
fileType FileType
|
||||||
fileName String
|
fileName String
|
||||||
mimeType String
|
mimeType String
|
||||||
size Int // bytes
|
size Int // bytes
|
||||||
|
pageCount Int? // Number of pages (PDFs, presentations, etc.)
|
||||||
|
|
||||||
|
// Document analysis (optional, populated by document-analyzer service)
|
||||||
|
textPreview String? @db.Text // First ~2000 chars of extracted text
|
||||||
|
detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und')
|
||||||
|
langConfidence Float? // 0.0–1.0 confidence
|
||||||
|
analyzedAt DateTime? // When analysis last ran
|
||||||
|
|
||||||
// MinIO location
|
// MinIO location
|
||||||
bucket String
|
bucket String
|
||||||
@@ -754,7 +747,6 @@ model Assignment {
|
|||||||
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||||
evaluation Evaluation?
|
evaluation Evaluation?
|
||||||
conflictOfInterest ConflictOfInterest?
|
conflictOfInterest ConflictOfInterest?
|
||||||
exceptions AssignmentException[]
|
|
||||||
|
|
||||||
@@unique([userId, projectId, roundId])
|
@@unique([userId, projectId, roundId])
|
||||||
@@index([roundId])
|
@@index([roundId])
|
||||||
@@ -763,6 +755,7 @@ model Assignment {
|
|||||||
@@index([isCompleted])
|
@@index([isCompleted])
|
||||||
@@index([projectId, userId])
|
@@index([projectId, userId])
|
||||||
@@index([juryGroupId])
|
@@index([juryGroupId])
|
||||||
|
@@index([roundId, isCompleted])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Evaluation {
|
model Evaluation {
|
||||||
@@ -780,11 +773,6 @@ model Evaluation {
|
|||||||
binaryDecision Boolean? // Yes/No for semi-finalist
|
binaryDecision Boolean? // Yes/No for semi-finalist
|
||||||
feedbackText String? @db.Text
|
feedbackText String? @db.Text
|
||||||
|
|
||||||
// Versioning (currently unused - evaluations are updated in-place.
|
|
||||||
// TODO: Implement proper versioning by creating new rows on re-submission
|
|
||||||
// if version history is needed for audit purposes)
|
|
||||||
version Int @default(1)
|
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -899,7 +887,8 @@ model AIUsageLog {
|
|||||||
entityId String?
|
entityId String?
|
||||||
|
|
||||||
// What was used
|
// What was used
|
||||||
model String // gpt-4o, gpt-4o-mini, o1, etc.
|
model String // gpt-4o, gpt-4o-mini, o1, claude-sonnet-4-5, etc.
|
||||||
|
provider String? // openai, anthropic, litellm
|
||||||
promptTokens Int
|
promptTokens Int
|
||||||
completionTokens Int
|
completionTokens Int
|
||||||
totalTokens Int
|
totalTokens Int
|
||||||
@@ -930,22 +919,35 @@ model AIUsageLog {
|
|||||||
|
|
||||||
model NotificationLog {
|
model NotificationLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String?
|
||||||
channel NotificationChannel
|
channel NotificationChannel @default(EMAIL)
|
||||||
provider String? // META, TWILIO, SMTP
|
provider String? // META, TWILIO, SMTP
|
||||||
type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION
|
type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION, ADVANCEMENT_NOTIFICATION, etc.
|
||||||
status String // PENDING, SENT, DELIVERED, FAILED
|
status String // PENDING, SENT, DELIVERED, FAILED
|
||||||
externalId String? // Message ID from provider
|
externalId String? // Message ID from provider
|
||||||
errorMsg String? @db.Text
|
errorMsg String? @db.Text
|
||||||
|
|
||||||
|
// Bulk notification tracking
|
||||||
|
email String? // Recipient email address
|
||||||
|
roundId String?
|
||||||
|
projectId String?
|
||||||
|
batchId String? // Groups emails from same send operation
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull)
|
||||||
|
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
@@index([roundId, type])
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([batchId])
|
||||||
|
@@index([email])
|
||||||
|
@@index([type, status])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -1002,13 +1004,12 @@ model NotificationEmailSetting {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
model LearningResource {
|
model LearningResource {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
programId String? // null = global resource
|
programId String? // null = global resource
|
||||||
title String
|
title String
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
contentJson Json? @db.JsonB // BlockNote document structure
|
contentJson Json? @db.JsonB // BlockNote document structure
|
||||||
resourceType ResourceType
|
accessJson Json? @db.JsonB // Fine-grained access rules
|
||||||
cohortLevel CohortLevel @default(ALL)
|
|
||||||
|
|
||||||
// File storage (for uploaded resources)
|
// File storage (for uploaded resources)
|
||||||
fileName String?
|
fileName String?
|
||||||
@@ -1017,6 +1018,9 @@ model LearningResource {
|
|||||||
bucket String?
|
bucket String?
|
||||||
objectKey String?
|
objectKey String?
|
||||||
|
|
||||||
|
// Cover image (stored in MinIO)
|
||||||
|
coverImageKey String?
|
||||||
|
|
||||||
// External link
|
// External link
|
||||||
externalUrl String?
|
externalUrl String?
|
||||||
|
|
||||||
@@ -1033,7 +1037,6 @@ model LearningResource {
|
|||||||
accessLogs ResourceAccess[]
|
accessLogs ResourceAccess[]
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([cohortLevel])
|
|
||||||
@@index([isPublished])
|
@@index([isPublished])
|
||||||
@@index([sortOrder])
|
@@index([sortOrder])
|
||||||
}
|
}
|
||||||
@@ -1408,6 +1411,76 @@ enum AssignmentJobStatus {
|
|||||||
FAILED
|
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
|
||||||
|
FORMULA // Formula-only: no LLM, pure math ranking
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
@@index([roundId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
// Tracks progress of long-running AI tagging jobs
|
// Tracks progress of long-running AI tagging jobs
|
||||||
model TaggingJob {
|
model TaggingJob {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -1500,6 +1573,7 @@ model SpecialAward {
|
|||||||
juryGroupId String?
|
juryGroupId String?
|
||||||
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
|
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
|
||||||
decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION"
|
decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION"
|
||||||
|
shortlistSize Int @default(10)
|
||||||
|
|
||||||
// Eligibility job tracking
|
// Eligibility job tracking
|
||||||
eligibilityJobStatus String? // PENDING, PROCESSING, COMPLETED, FAILED
|
eligibilityJobStatus String? // PENDING, PROCESSING, COMPLETED, FAILED
|
||||||
@@ -1523,6 +1597,7 @@ model SpecialAward {
|
|||||||
competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
|
competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
|
||||||
evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
|
evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
|
||||||
awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||||
|
rounds Round[] @relation("AwardRounds")
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -1538,11 +1613,20 @@ model AwardEligibility {
|
|||||||
method EligibilityMethod @default(AUTO)
|
method EligibilityMethod @default(AUTO)
|
||||||
eligible Boolean @default(false)
|
eligible Boolean @default(false)
|
||||||
aiReasoningJson Json? @db.JsonB
|
aiReasoningJson Json? @db.JsonB
|
||||||
|
qualityScore Float?
|
||||||
|
shortlisted Boolean @default(false)
|
||||||
|
|
||||||
// Admin override
|
// Admin override
|
||||||
overriddenBy String?
|
overriddenBy String?
|
||||||
overriddenAt DateTime?
|
overriddenAt DateTime?
|
||||||
|
|
||||||
|
// Shortlist confirmation
|
||||||
|
confirmedAt DateTime?
|
||||||
|
confirmedBy String?
|
||||||
|
|
||||||
|
// Pool notification tracking
|
||||||
|
notifiedAt DateTime?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1550,6 +1634,7 @@ model AwardEligibility {
|
|||||||
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
|
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
overriddenByUser User? @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull)
|
overriddenByUser User? @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull)
|
||||||
|
confirmer User? @relation("AwardEligibilityConfirmer", fields: [confirmedBy], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@unique([awardId, projectId])
|
@@unique([awardId, projectId])
|
||||||
@@index([awardId])
|
@@index([awardId])
|
||||||
@@ -1622,7 +1707,6 @@ model ConflictOfInterest {
|
|||||||
assignmentId String @unique
|
assignmentId String @unique
|
||||||
userId String
|
userId String
|
||||||
projectId String
|
projectId String
|
||||||
roundId String? // Legacy — kept for historical data
|
|
||||||
hasConflict Boolean @default(false)
|
hasConflict Boolean @default(false)
|
||||||
conflictType String? // "financial", "personal", "organizational", "other"
|
conflictType String? // "financial", "personal", "organizational", "other"
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
@@ -1640,6 +1724,8 @@ model ConflictOfInterest {
|
|||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([hasConflict])
|
@@index([hasConflict])
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([userId, hasConflict])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -2002,24 +2088,6 @@ model LiveProgressCursor {
|
|||||||
@@index([sessionId])
|
@@index([sessionId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model OverrideAction {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
entityType String // ProjectRoundState, FilteringResult, AwardEligibility, etc.
|
|
||||||
entityId String
|
|
||||||
previousValue Json? @db.JsonB
|
|
||||||
newValueJson Json @db.JsonB
|
|
||||||
reasonCode OverrideReasonCode
|
|
||||||
reasonText String? @db.Text
|
|
||||||
actorId String
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@index([entityType, entityId])
|
|
||||||
@@index([actorId])
|
|
||||||
@@index([reasonCode])
|
|
||||||
@@index([createdAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model DecisionAuditLog {
|
model DecisionAuditLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
eventType String // stage.transitioned, routing.executed, filtering.completed, etc.
|
eventType String // stage.transitioned, routing.executed, filtering.completed, etc.
|
||||||
@@ -2037,21 +2105,6 @@ model DecisionAuditLog {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model NotificationPolicy {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
eventType String @unique // stage.transitioned, filtering.completed, etc.
|
|
||||||
channel String @default("EMAIL") // EMAIL, IN_APP, BOTH, NONE
|
|
||||||
templateId String? // Optional reference to MessageTemplate
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
configJson Json? @db.JsonB // Additional config (delay, batch, etc.)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@index([eventType])
|
|
||||||
@@index([isActive])
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// COMPETITION / ROUND ENGINE MODELS (NEW — coexists with Pipeline/Track/Stage)
|
// COMPETITION / ROUND ENGINE MODELS (NEW — coexists with Pipeline/Track/Stage)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -2111,16 +2164,22 @@ model Round {
|
|||||||
// Links to other entities
|
// Links to other entities
|
||||||
juryGroupId String?
|
juryGroupId String?
|
||||||
submissionWindowId String?
|
submissionWindowId String?
|
||||||
|
specialAwardId String?
|
||||||
|
|
||||||
|
// Finalization
|
||||||
|
gracePeriodEndsAt DateTime?
|
||||||
|
finalizedAt DateTime?
|
||||||
|
finalizedBy String?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
|
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
|
||||||
|
specialAward SpecialAward? @relation("AwardRounds", fields: [specialAwardId], references: [id], onDelete: SetNull)
|
||||||
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||||
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
|
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
|
||||||
projectRoundStates ProjectRoundState[]
|
projectRoundStates ProjectRoundState[]
|
||||||
advancementRules AdvancementRule[]
|
|
||||||
visibleSubmissionWindows RoundSubmissionVisibility[]
|
visibleSubmissionWindows RoundSubmissionVisibility[]
|
||||||
assignmentIntents AssignmentIntent[]
|
assignmentIntents AssignmentIntent[]
|
||||||
deliberationSessions DeliberationSession[]
|
deliberationSessions DeliberationSession[]
|
||||||
@@ -2138,10 +2197,12 @@ model Round {
|
|||||||
filteringResults FilteringResult[]
|
filteringResults FilteringResult[]
|
||||||
filteringJobs FilteringJob[]
|
filteringJobs FilteringJob[]
|
||||||
assignmentJobs AssignmentJob[]
|
assignmentJobs AssignmentJob[]
|
||||||
|
rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots")
|
||||||
reminderLogs ReminderLog[]
|
reminderLogs ReminderLog[]
|
||||||
evaluationSummaries EvaluationSummary[]
|
evaluationSummaries EvaluationSummary[]
|
||||||
evaluationDiscussions EvaluationDiscussion[]
|
evaluationDiscussions EvaluationDiscussion[]
|
||||||
messages Message[]
|
messages Message[]
|
||||||
|
notificationLogs NotificationLog[]
|
||||||
cohorts Cohort[]
|
cohorts Cohort[]
|
||||||
liveCursor LiveProgressCursor?
|
liveCursor LiveProgressCursor?
|
||||||
|
|
||||||
@@ -2150,16 +2211,18 @@ model Round {
|
|||||||
@@index([competitionId])
|
@@index([competitionId])
|
||||||
@@index([roundType])
|
@@index([roundType])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([specialAwardId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ProjectRoundState {
|
model ProjectRoundState {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
projectId String
|
projectId String
|
||||||
roundId String
|
roundId String
|
||||||
state ProjectRoundStateValue @default(PENDING)
|
state ProjectRoundStateValue @default(PENDING)
|
||||||
enteredAt DateTime @default(now())
|
proposedOutcome ProjectRoundStateValue?
|
||||||
exitedAt DateTime?
|
enteredAt DateTime @default(now())
|
||||||
metadataJson Json? @db.JsonB
|
exitedAt DateTime?
|
||||||
|
metadataJson Json? @db.JsonB
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -2172,24 +2235,7 @@ model ProjectRoundState {
|
|||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@index([roundId])
|
@@index([roundId])
|
||||||
@@index([state])
|
@@index([state])
|
||||||
}
|
@@index([roundId, state])
|
||||||
|
|
||||||
model AdvancementRule {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
roundId String
|
|
||||||
targetRoundId String?
|
|
||||||
ruleType AdvancementRuleType
|
|
||||||
configJson Json @db.JsonB
|
|
||||||
isDefault Boolean @default(true)
|
|
||||||
sortOrder Int @default(0)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
// Relations
|
|
||||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([roundId, sortOrder])
|
|
||||||
@@index([roundId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -2368,22 +2414,6 @@ model AssignmentIntent {
|
|||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
model AssignmentException {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
assignmentId String
|
|
||||||
reason String @db.Text
|
|
||||||
overCapBy Int
|
|
||||||
approvedById String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
// Relations
|
|
||||||
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
|
||||||
approvedBy User @relation("AssignmentExceptionApprover", fields: [approvedById], references: [id])
|
|
||||||
|
|
||||||
@@index([assignmentId])
|
|
||||||
@@index([approvedById])
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MENTORING WORKSPACE MODELS (NEW)
|
// MENTORING WORKSPACE MODELS (NEW)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ async function main() {
|
|||||||
const existingTags = await prisma.expertiseTag.findMany({
|
const existingTags = await prisma.expertiseTag.findMany({
|
||||||
select: { name: true },
|
select: { name: true },
|
||||||
})
|
})
|
||||||
const existingNames = new Set(existingTags.map((t) => t.name))
|
const existingNames = new Set(existingTags.map((t: { name: string }) => t.name))
|
||||||
|
|
||||||
// Filter out tags that already exist
|
// Filter out tags that already exist
|
||||||
const newTags = EXPERTISE_TAGS.filter((t) => !existingNames.has(t.name))
|
const newTags = EXPERTISE_TAGS.filter((t) => !existingNames.has(t.name))
|
||||||
|
|||||||
@@ -90,6 +90,27 @@ const NOTIFICATION_EMAIL_SETTINGS = [
|
|||||||
description: 'When multiple projects are assigned at once',
|
description: 'When multiple projects are assigned at once',
|
||||||
sendEmail: true,
|
sendEmail: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
notificationType: 'COI_REASSIGNED',
|
||||||
|
category: 'jury',
|
||||||
|
label: 'COI Reassignment',
|
||||||
|
description: 'When a project is reassigned to you due to another juror\'s conflict of interest',
|
||||||
|
sendEmail: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notificationType: 'MANUAL_REASSIGNED',
|
||||||
|
category: 'jury',
|
||||||
|
label: 'Manual Reassignment',
|
||||||
|
description: 'When an admin manually reassigns a project to you',
|
||||||
|
sendEmail: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notificationType: 'DROPOUT_REASSIGNED',
|
||||||
|
category: 'jury',
|
||||||
|
label: 'Juror Dropout Reassignment',
|
||||||
|
description: 'When projects are reassigned to you because a juror dropped out or became unavailable',
|
||||||
|
sendEmail: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
notificationType: 'ROUND_NOW_OPEN',
|
notificationType: 'ROUND_NOW_OPEN',
|
||||||
category: 'jury',
|
category: 'jury',
|
||||||
|
|||||||
52
prisma/seed-team-leads.ts
Normal file
52
prisma/seed-team-leads.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Idempotent sync: ensure every project with a submittedByUserId has a
|
||||||
|
* corresponding TeamMember(LEAD) record. Safe to run on every deploy.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { submittedByUserId: { not: null } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
submittedByUserId: true,
|
||||||
|
teamMembers: { select: { userId: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const toCreate: Array<{ projectId: string; userId: string; role: 'LEAD' }> = []
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
if (!project.submittedByUserId) continue
|
||||||
|
const alreadyLinked = project.teamMembers.some(
|
||||||
|
(tm) => tm.userId === project.submittedByUserId
|
||||||
|
)
|
||||||
|
if (!alreadyLinked) {
|
||||||
|
toCreate.push({
|
||||||
|
projectId: project.id,
|
||||||
|
userId: project.submittedByUserId,
|
||||||
|
role: 'LEAD',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toCreate.length > 0) {
|
||||||
|
await prisma.teamMember.createMany({
|
||||||
|
data: toCreate,
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
console.log(`✓ Linked ${toCreate.length} project submitters as TeamMember(LEAD)`)
|
||||||
|
} else {
|
||||||
|
console.log('✓ All project submitters already linked — nothing to do')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Team lead sync failed:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
RoundStatus,
|
RoundStatus,
|
||||||
CapMode,
|
CapMode,
|
||||||
JuryGroupMemberRole,
|
JuryGroupMemberRole,
|
||||||
AdvancementRuleType,
|
|
||||||
} from '@prisma/client'
|
} from '@prisma/client'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
// Inline default configs so seed has ZERO dependency on src/ (not available in Docker prod image)
|
// Inline default configs so seed has ZERO dependency on src/ (not available in Docker prod image)
|
||||||
@@ -316,6 +315,7 @@ async function main() {
|
|||||||
|
|
||||||
const staffAccounts = [
|
const staffAccounts = [
|
||||||
{ email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' },
|
{ email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' },
|
||||||
|
{ email: 'matt@letsbe.solutions', name: 'Matt (Applicant)', role: UserRole.APPLICANT, password: '195260Mp!' },
|
||||||
{ email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' },
|
{ email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' },
|
||||||
{ email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' },
|
{ email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' },
|
||||||
]
|
]
|
||||||
@@ -323,10 +323,10 @@ async function main() {
|
|||||||
const staffUsers: Record<string, string> = {}
|
const staffUsers: Record<string, string> = {}
|
||||||
for (const account of staffAccounts) {
|
for (const account of staffAccounts) {
|
||||||
const passwordHash = await bcrypt.hash(account.password, 12)
|
const passwordHash = await bcrypt.hash(account.password, 12)
|
||||||
const isSuperAdmin = account.role === UserRole.SUPER_ADMIN
|
const needsPassword = account.role === UserRole.SUPER_ADMIN || account.role === UserRole.APPLICANT
|
||||||
const user = await prisma.user.upsert({
|
const user = await prisma.user.upsert({
|
||||||
where: { email: account.email },
|
where: { email: account.email },
|
||||||
update: isSuperAdmin
|
update: needsPassword
|
||||||
? {
|
? {
|
||||||
status: UserStatus.ACTIVE,
|
status: UserStatus.ACTIVE,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
@@ -347,11 +347,12 @@ async function main() {
|
|||||||
email: account.email,
|
email: account.email,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
role: account.role,
|
role: account.role,
|
||||||
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE,
|
roles: [account.role],
|
||||||
passwordHash: isSuperAdmin ? passwordHash : null,
|
status: needsPassword ? UserStatus.ACTIVE : UserStatus.NONE,
|
||||||
mustSetPassword: !isSuperAdmin,
|
passwordHash: needsPassword ? passwordHash : null,
|
||||||
passwordSetAt: isSuperAdmin ? new Date() : null,
|
mustSetPassword: !needsPassword,
|
||||||
onboardingCompletedAt: isSuperAdmin ? new Date() : null,
|
passwordSetAt: needsPassword ? new Date() : null,
|
||||||
|
onboardingCompletedAt: needsPassword ? new Date() : null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
staffUsers[account.email] = user.id
|
staffUsers[account.email] = user.id
|
||||||
@@ -385,6 +386,7 @@ async function main() {
|
|||||||
email: j.email,
|
email: j.email,
|
||||||
name: j.name,
|
name: j.name,
|
||||||
role: UserRole.JURY_MEMBER,
|
role: UserRole.JURY_MEMBER,
|
||||||
|
roles: [UserRole.JURY_MEMBER],
|
||||||
status: UserStatus.NONE,
|
status: UserStatus.NONE,
|
||||||
country: j.country,
|
country: j.country,
|
||||||
expertiseTags: j.tags,
|
expertiseTags: j.tags,
|
||||||
@@ -416,6 +418,7 @@ async function main() {
|
|||||||
email: m.email,
|
email: m.email,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
role: UserRole.MENTOR,
|
role: UserRole.MENTOR,
|
||||||
|
roles: [UserRole.MENTOR],
|
||||||
status: UserStatus.NONE,
|
status: UserStatus.NONE,
|
||||||
country: m.country,
|
country: m.country,
|
||||||
expertiseTags: m.tags,
|
expertiseTags: m.tags,
|
||||||
@@ -444,6 +447,7 @@ async function main() {
|
|||||||
email: o.email,
|
email: o.email,
|
||||||
name: o.name,
|
name: o.name,
|
||||||
role: UserRole.OBSERVER,
|
role: UserRole.OBSERVER,
|
||||||
|
roles: [UserRole.OBSERVER],
|
||||||
status: UserStatus.NONE,
|
status: UserStatus.NONE,
|
||||||
country: o.country,
|
country: o.country,
|
||||||
},
|
},
|
||||||
@@ -545,6 +549,7 @@ async function main() {
|
|||||||
email,
|
email,
|
||||||
name: name || `Applicant ${rowIdx + 1}`,
|
name: name || `Applicant ${rowIdx + 1}`,
|
||||||
role: UserRole.APPLICANT,
|
role: UserRole.APPLICANT,
|
||||||
|
roles: [UserRole.APPLICANT],
|
||||||
status: UserStatus.NONE,
|
status: UserStatus.NONE,
|
||||||
phoneNumber: phone,
|
phoneNumber: phone,
|
||||||
country,
|
country,
|
||||||
@@ -554,7 +559,7 @@ async function main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Create project
|
// Create project
|
||||||
await prisma.project.create({
|
const createdProject = await prisma.project.create({
|
||||||
data: {
|
data: {
|
||||||
programId: program.id,
|
programId: program.id,
|
||||||
title: projectName || `Project by ${name}`,
|
title: projectName || `Project by ${name}`,
|
||||||
@@ -579,13 +584,24 @@ async function main() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Link submitter as team lead
|
||||||
|
await prisma.teamMember.upsert({
|
||||||
|
where: { projectId_userId: { projectId: createdProject.id, userId: user.id } },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
projectId: createdProject.id,
|
||||||
|
userId: user.id,
|
||||||
|
role: 'LEAD',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
projectCount++
|
projectCount++
|
||||||
if (projectCount % 50 === 0) {
|
if (projectCount % 50 === 0) {
|
||||||
console.log(` ... ${projectCount} projects created`)
|
console.log(` ... ${projectCount} projects created`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(` ✓ Created ${projectCount} projects`)
|
console.log(` ✓ Created ${projectCount} projects (with team lead links)`)
|
||||||
if (skippedNoEmail > 0) {
|
if (skippedNoEmail > 0) {
|
||||||
console.log(` ⚠ Skipped ${skippedNoEmail} rows with no valid email`)
|
console.log(` ⚠ Skipped ${skippedNoEmail} rows with no valid email`)
|
||||||
}
|
}
|
||||||
@@ -841,23 +857,23 @@ async function main() {
|
|||||||
}
|
}
|
||||||
console.log(` ✓ ${rounds.length} rounds created (R1-R8)`)
|
console.log(` ✓ ${rounds.length} rounds created (R1-R8)`)
|
||||||
|
|
||||||
// --- Advancement Rules (auto-advance between rounds) ---
|
// --- Assign all projects to intake round (COMPLETED, since intake is closed) ---
|
||||||
for (let i = 0; i < rounds.length - 1; i++) {
|
const intakeRound = rounds[0]
|
||||||
await prisma.advancementRule.upsert({
|
const allProjects = await prisma.project.findMany({
|
||||||
where: {
|
where: { programId: program.id },
|
||||||
roundId_sortOrder: { roundId: rounds[i].id, sortOrder: 0 },
|
select: { id: true },
|
||||||
},
|
})
|
||||||
update: {},
|
if (allProjects.length > 0) {
|
||||||
create: {
|
await prisma.projectRoundState.createMany({
|
||||||
roundId: rounds[i].id,
|
data: allProjects.map((p) => ({
|
||||||
ruleType: AdvancementRuleType.AUTO_ADVANCE,
|
projectId: p.id,
|
||||||
sortOrder: 0,
|
roundId: intakeRound.id,
|
||||||
targetRoundId: rounds[i + 1].id,
|
state: 'COMPLETED' as const,
|
||||||
configJson: {},
|
})),
|
||||||
},
|
skipDuplicates: true,
|
||||||
})
|
})
|
||||||
|
console.log(` ✓ ${allProjects.length} projects assigned to intake round (COMPLETED)`)
|
||||||
}
|
}
|
||||||
console.log(` ✓ ${rounds.length - 1} advancement rules created`)
|
|
||||||
|
|
||||||
// --- Round-Submission Visibility (which rounds can see which submission windows) ---
|
// --- Round-Submission Visibility (which rounds can see which submission windows) ---
|
||||||
// R2 and R3 can see R1 docs, R5 can see R4 docs
|
// R2 and R3 can see R1 docs, R5 can see R4 docs
|
||||||
@@ -882,6 +898,28 @@ async function main() {
|
|||||||
}
|
}
|
||||||
console.log(` ✓ ${visibilityLinks.length} submission visibility links created`)
|
console.log(` ✓ ${visibilityLinks.length} submission visibility links created`)
|
||||||
|
|
||||||
|
// --- Applicant/Observer visibility settings ---
|
||||||
|
const visibilitySettings = [
|
||||||
|
{ key: 'observer_show_team_tab', value: 'true', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show Team tab on observer project detail page' },
|
||||||
|
{ key: 'applicant_show_evaluation_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show anonymous jury evaluation feedback to applicants' },
|
||||||
|
{ key: 'applicant_show_evaluation_scores', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show global scores in evaluation feedback' },
|
||||||
|
{ key: 'applicant_show_evaluation_criteria', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show per-criterion scores in evaluation feedback' },
|
||||||
|
{ key: 'applicant_show_evaluation_text', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show written feedback text in evaluation feedback' },
|
||||||
|
{ key: 'applicant_show_livefinal_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show live final scores to applicants' },
|
||||||
|
{ key: 'applicant_show_livefinal_scores', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show individual jury scores from live finals' },
|
||||||
|
{ key: 'applicant_show_deliberation_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show deliberation results to applicants' },
|
||||||
|
{ key: 'applicant_hide_feedback_from_rejected', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Hide feedback from rejected projects' },
|
||||||
|
{ key: 'applicant_allow_description_edit', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Allow applicants to edit their project description' },
|
||||||
|
]
|
||||||
|
for (const s of visibilitySettings) {
|
||||||
|
await prisma.systemSettings.upsert({
|
||||||
|
where: { key: s.key },
|
||||||
|
update: {},
|
||||||
|
create: s,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
console.log(` ✓ Created ${visibilitySettings.length} applicant/observer visibility settings`)
|
||||||
|
|
||||||
// --- Feature flag: enable competition model ---
|
// --- Feature flag: enable competition model ---
|
||||||
await prisma.systemSettings.upsert({
|
await prisma.systemSettings.upsert({
|
||||||
where: { key: 'feature.useCompetitionModel' },
|
where: { key: 'feature.useCompetitionModel' },
|
||||||
@@ -920,6 +958,8 @@ async function main() {
|
|||||||
{ notificationType: 'REMINDER_1H', category: 'jury', label: 'Reminder (1h)', description: 'Urgent reminder 1 hour before deadline', sendEmail: true },
|
{ notificationType: 'REMINDER_1H', category: 'jury', label: 'Reminder (1h)', description: 'Urgent reminder 1 hour before deadline', sendEmail: true },
|
||||||
{ notificationType: 'ROUND_CLOSED', category: 'jury', label: 'Round Closed', description: 'When a round closes', sendEmail: false },
|
{ notificationType: 'ROUND_CLOSED', category: 'jury', label: 'Round Closed', description: 'When a round closes', sendEmail: false },
|
||||||
{ notificationType: 'AWARD_VOTING_OPEN', category: 'jury', label: 'Award Voting Open', description: 'When special award voting opens', sendEmail: true },
|
{ notificationType: 'AWARD_VOTING_OPEN', category: 'jury', label: 'Award Voting Open', description: 'When special award voting opens', sendEmail: true },
|
||||||
|
{ notificationType: 'COI_REASSIGNED', category: 'jury', label: 'COI Reassignment', description: 'When a project is reassigned to you due to another juror\'s conflict of interest', sendEmail: true },
|
||||||
|
{ notificationType: 'MANUAL_REASSIGNED', category: 'jury', label: 'Manual Reassignment', description: 'When an admin manually reassigns a project to you', sendEmail: true },
|
||||||
// Mentor notifications
|
// Mentor notifications
|
||||||
{ notificationType: 'MENTEE_ASSIGNED', category: 'mentor', label: 'Mentee Assigned', description: 'When assigned as mentor to a project', sendEmail: true },
|
{ notificationType: 'MENTEE_ASSIGNED', category: 'mentor', label: 'Mentee Assigned', description: 'When assigned as mentor to a project', sendEmail: true },
|
||||||
{ notificationType: 'MENTEE_UPLOADED_DOCS', category: 'mentor', label: 'Mentee Documents Updated', description: 'When a mentee uploads new documents', sendEmail: false },
|
{ notificationType: 'MENTEE_UPLOADED_DOCS', category: 'mentor', label: 'Mentee Documents Updated', description: 'When a mentee uploads new documents', sendEmail: false },
|
||||||
|
|||||||
106
scripts/backfill-binary-decision.ts
Normal file
106
scripts/backfill-binary-decision.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* One-off script: backfill binaryDecision from custom boolean criterion
|
||||||
|
* "Move to the Next Stage?" for evaluations where binaryDecision is null.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/backfill-binary-decision.ts
|
||||||
|
*
|
||||||
|
* What it does:
|
||||||
|
* 1. Finds all rounds with a boolean criterion labeled "Move to the Next Stage?"
|
||||||
|
* 2. For evaluations in those rounds where binaryDecision IS NULL,
|
||||||
|
* copies the boolean value from criterionScoresJson into binaryDecision
|
||||||
|
*
|
||||||
|
* Safe to re-run: only updates evaluations where binaryDecision is still null.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient, Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
type CriterionConfig = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Find all rounds that have evaluation config with criteria
|
||||||
|
const rounds = await prisma.round.findMany({
|
||||||
|
where: { roundType: 'EVALUATION' },
|
||||||
|
select: { id: true, name: true, configJson: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
let totalUpdated = 0
|
||||||
|
let totalSkipped = 0
|
||||||
|
|
||||||
|
for (const round of rounds) {
|
||||||
|
const config = round.configJson as Record<string, unknown> | null
|
||||||
|
if (!config) continue
|
||||||
|
|
||||||
|
const criteria = (config.criteria ?? config.evaluationCriteria ?? []) as CriterionConfig[]
|
||||||
|
|
||||||
|
// Find the boolean criterion for "Move to the Next Stage?"
|
||||||
|
const boolCriterion = criteria.find(
|
||||||
|
(c) =>
|
||||||
|
(c.type === 'boolean') &&
|
||||||
|
c.label?.toLowerCase().includes('move to the next stage'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!boolCriterion) continue
|
||||||
|
|
||||||
|
console.log(`Round "${round.name}" (${round.id}): found criterion "${boolCriterion.label}" (${boolCriterion.id})`)
|
||||||
|
|
||||||
|
// Find evaluations in this round where binaryDecision is null
|
||||||
|
// Use Prisma.JsonNull for proper null filtering
|
||||||
|
const evaluations = await prisma.evaluation.findMany({
|
||||||
|
where: {
|
||||||
|
assignment: { roundId: round.id },
|
||||||
|
binaryDecision: null,
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
},
|
||||||
|
select: { id: true, criterionScoresJson: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
let updated = 0
|
||||||
|
let skipped = 0
|
||||||
|
for (const ev of evaluations) {
|
||||||
|
const scores = ev.criterionScoresJson as Record<string, unknown> | null
|
||||||
|
if (!scores) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = scores[boolCriterion.id]
|
||||||
|
let resolved: boolean | null = null
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
resolved = value
|
||||||
|
} else if (value === 'true' || value === 1) {
|
||||||
|
resolved = true
|
||||||
|
} else if (value === 'false' || value === 0) {
|
||||||
|
resolved = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved === null) {
|
||||||
|
console.log(` Skipping eval ${ev.id}: criterion value is ${JSON.stringify(value)}`)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.evaluation.update({
|
||||||
|
where: { id: ev.id },
|
||||||
|
data: { binaryDecision: resolved },
|
||||||
|
})
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Updated ${updated}/${evaluations.length} evaluations (skipped ${skipped})`)
|
||||||
|
totalUpdated += updated
|
||||||
|
totalSkipped += skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDone. Total updated: ${totalUpdated}, Total skipped: ${totalSkipped}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
112
scripts/backfill-intake-round.ts
Normal file
112
scripts/backfill-intake-round.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Backfill all projects into the intake round (and any intermediate rounds
|
||||||
|
* between intake and their earliest assigned round) with COMPLETED state.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/backfill-intake-round.ts
|
||||||
|
* Add --dry-run to preview without making changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
const dryRun = process.argv.includes('--dry-run')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(dryRun ? '🔍 DRY RUN — no changes will be made\n' : '🚀 Backfilling intake round states...\n')
|
||||||
|
|
||||||
|
// Find the intake round
|
||||||
|
const intakeRound = await prisma.round.findFirst({
|
||||||
|
where: { roundType: 'INTAKE' },
|
||||||
|
select: { id: true, name: true, sortOrder: true, competitionId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!intakeRound) {
|
||||||
|
console.log('❌ No INTAKE round found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Intake round: "${intakeRound.name}" (sortOrder: ${intakeRound.sortOrder})`)
|
||||||
|
|
||||||
|
// Get all rounds in the competition ordered by sortOrder
|
||||||
|
const allRounds = await prisma.round.findMany({
|
||||||
|
where: { competitionId: intakeRound.competitionId },
|
||||||
|
select: { id: true, name: true, sortOrder: true },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find all projects NOT in the intake round
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
projectRoundStates: {
|
||||||
|
none: { roundId: intakeRound.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
projectRoundStates: {
|
||||||
|
select: { roundId: true, round: { select: { sortOrder: true } } },
|
||||||
|
orderBy: { round: { sortOrder: 'asc' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`${projects.length} projects not in intake round\n`)
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
console.log('✅ All projects already in intake round')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each project, create COMPLETED states for intake + any intermediate rounds
|
||||||
|
const toCreate: Array<{ projectId: string; roundId: string; state: 'COMPLETED' }> = []
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
// Find the earliest round this project is already in
|
||||||
|
const earliestSortOrder = project.projectRoundStates.length > 0
|
||||||
|
? Math.min(...project.projectRoundStates.map(ps => ps.round.sortOrder))
|
||||||
|
: Infinity
|
||||||
|
|
||||||
|
const existingRoundIds = new Set(project.projectRoundStates.map(ps => ps.roundId))
|
||||||
|
|
||||||
|
// Add COMPLETED for intake + all intermediate rounds before the earliest assigned round
|
||||||
|
for (const round of allRounds) {
|
||||||
|
if (round.sortOrder >= earliestSortOrder) break
|
||||||
|
if (existingRoundIds.has(round.id)) continue
|
||||||
|
|
||||||
|
toCreate.push({
|
||||||
|
projectId: project.id,
|
||||||
|
roundId: round.id,
|
||||||
|
state: 'COMPLETED',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Creating ${toCreate.length} ProjectRoundState records...`)
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
await prisma.projectRoundState.createMany({
|
||||||
|
data: toCreate,
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary by round
|
||||||
|
const byRound = new Map<string, number>()
|
||||||
|
for (const r of toCreate) {
|
||||||
|
const name = allRounds.find(ar => ar.id === r.roundId)?.name ?? r.roundId
|
||||||
|
byRound.set(name, (byRound.get(name) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
for (const [name, count] of byRound) {
|
||||||
|
console.log(` ${name}: ${count} projects`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Done! ${toCreate.length} records ${dryRun ? 'would be' : ''} created`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Error:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
78
scripts/backfill-team-leads.ts
Normal file
78
scripts/backfill-team-leads.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Backfill TeamMember records for all projects that have a submittedByUserId
|
||||||
|
* but no corresponding TeamMember link.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/backfill-team-leads.ts
|
||||||
|
* Add --dry-run to preview without making changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
const dryRun = process.argv.includes('--dry-run')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(dryRun ? '🔍 DRY RUN — no changes will be made\n' : '🚀 Backfilling team leads...\n')
|
||||||
|
|
||||||
|
// Find all projects with a submitter but no TeamMember link for that user
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
submittedByUserId: { not: null },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
submittedByUserId: true,
|
||||||
|
teamMembers: {
|
||||||
|
select: { userId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let created = 0
|
||||||
|
let alreadyLinked = 0
|
||||||
|
let noSubmitter = 0
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
if (!project.submittedByUserId) {
|
||||||
|
noSubmitter++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyHasLink = project.teamMembers.some(
|
||||||
|
(tm) => tm.userId === project.submittedByUserId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (alreadyHasLink) {
|
||||||
|
alreadyLinked++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` + Linking "${project.title}" → user ${project.submittedByUserId}`)
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
await prisma.teamMember.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
userId: project.submittedByUserId,
|
||||||
|
role: 'LEAD',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Done!`)
|
||||||
|
console.log(` ${created} TeamMember records ${dryRun ? 'would be' : ''} created`)
|
||||||
|
console.log(` ${alreadyLinked} projects already had the submitter linked`)
|
||||||
|
console.log(` ${noSubmitter} projects had no submitter`)
|
||||||
|
console.log(` ${projects.length} total projects checked`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Error:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
32
scripts/check-invites.cjs
Normal file
32
scripts/check-invites.cjs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const members = await p.teamMember.findMany({
|
||||||
|
orderBy: { joinedAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true, status: true, inviteToken: true } },
|
||||||
|
project: { select: { title: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const m of members) {
|
||||||
|
console.log(m.role, '|', m.user.name, '|', m.user.email, '|', m.user.status, '|', m.project.title, '|', m.joinedAt.toISOString().slice(0,16), '| token:', m.user.inviteToken ? 'yes' : 'no');
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await p.notificationLog.findMany({
|
||||||
|
where: { type: 'TEAM_INVITATION' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
});
|
||||||
|
if (logs.length) {
|
||||||
|
console.log('\n--- Notification logs:');
|
||||||
|
for (const l of logs) {
|
||||||
|
console.log(l.status, '|', l.channel, '|', l.errorMsg, '|', l.createdAt.toISOString().slice(0,16));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('\n--- No TEAM_INVITATION notification logs found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await p.$disconnect();
|
||||||
|
})();
|
||||||
20
scripts/check-rounds.cjs
Normal file
20
scripts/check-rounds.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const rounds = await p.round.findMany({
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
select: { id: true, name: true, roundType: true, status: true, sortOrder: true, competitionId: true },
|
||||||
|
});
|
||||||
|
for (const r of rounds) console.log(r.sortOrder, '|', r.name, '|', r.roundType, '|', r.status, '|', r.id);
|
||||||
|
|
||||||
|
console.log('\n--- File Requirements:');
|
||||||
|
const reqs = await p.fileRequirement.findMany({ include: { round: { select: { name: true } } } });
|
||||||
|
for (const r of reqs) console.log(r.round.name, '|', r.name, '|', r.isRequired, '|', r.id);
|
||||||
|
|
||||||
|
console.log('\n--- Submission Windows:');
|
||||||
|
const wins = await p.submissionWindow.findMany({ select: { id: true, name: true, roundNumber: true, windowOpenAt: true, windowCloseAt: true, competitionId: true } });
|
||||||
|
for (const w of wins) console.log(w.name, '| round#', w.roundNumber, '| open:', w.windowOpenAt?.toISOString().slice(0,16), '| close:', w.windowCloseAt?.toISOString().slice(0,16));
|
||||||
|
|
||||||
|
await p.$disconnect();
|
||||||
|
})();
|
||||||
71
scripts/create-requirements.cjs
Normal file
71
scripts/create-requirements.cjs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// R2 - AI Screening round ID
|
||||||
|
const roundId = 'cmmafe7et00ldy53kxpdhhvf0';
|
||||||
|
|
||||||
|
// Check existing
|
||||||
|
const existing = await p.fileRequirement.count({ where: { roundId } });
|
||||||
|
if (existing > 0) {
|
||||||
|
console.log(`Round already has ${existing} file requirements, skipping.`);
|
||||||
|
await p.$disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requirements = [
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Executive Summary',
|
||||||
|
description: 'A 2-page executive summary of your project (PDF format, max 10MB)',
|
||||||
|
acceptedMimeTypes: ['application/pdf'],
|
||||||
|
maxSizeMB: 10,
|
||||||
|
isRequired: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Business Plan',
|
||||||
|
description: 'Full business plan or project proposal (PDF format, max 25MB)',
|
||||||
|
acceptedMimeTypes: ['application/pdf'],
|
||||||
|
maxSizeMB: 25,
|
||||||
|
isRequired: true,
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Pitch Presentation',
|
||||||
|
description: 'Slide deck presenting your project (PDF or PowerPoint, max 50MB)',
|
||||||
|
acceptedMimeTypes: ['application/pdf', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
||||||
|
maxSizeMB: 50,
|
||||||
|
isRequired: true,
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Video Pitch',
|
||||||
|
description: 'A short video (max 3 minutes) explaining your project (MP4, max 200MB). Optional but recommended.',
|
||||||
|
acceptedMimeTypes: ['video/mp4', 'video/quicktime', 'video/webm'],
|
||||||
|
maxSizeMB: 200,
|
||||||
|
isRequired: false,
|
||||||
|
sortOrder: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Supporting Documents',
|
||||||
|
description: 'Any additional supporting documents such as research papers, letters of support, etc. (PDF, max 20MB)',
|
||||||
|
acceptedMimeTypes: ['application/pdf'],
|
||||||
|
maxSizeMB: 20,
|
||||||
|
isRequired: false,
|
||||||
|
sortOrder: 4,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const req of requirements) {
|
||||||
|
const created = await p.fileRequirement.create({ data: req });
|
||||||
|
console.log('Created:', created.name, '| required:', created.isRequired, '| id:', created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nDone! Created', requirements.length, 'file requirements for R2.');
|
||||||
|
await p.$disconnect();
|
||||||
|
})();
|
||||||
68
scripts/create-test-applicant.ts
Normal file
68
scripts/create-test-applicant.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { sendInvitationEmail } from '../src/lib/email'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Find a program to attach the project to
|
||||||
|
const program = await prisma.program.findFirst()
|
||||||
|
if (!program) throw new Error('No program found - run seed first')
|
||||||
|
|
||||||
|
// Create applicant user
|
||||||
|
const inviteToken = crypto.randomBytes(32).toString('hex')
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: 'test_applicant_matt_ciaccio',
|
||||||
|
name: 'Matt Ciaccio',
|
||||||
|
email: 'matt.ciaccio@gmail.com',
|
||||||
|
role: 'APPLICANT',
|
||||||
|
roles: ['APPLICANT'],
|
||||||
|
status: 'INVITED',
|
||||||
|
mustSetPassword: true,
|
||||||
|
inviteToken,
|
||||||
|
inviteTokenExpiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log('Created user:', user.id)
|
||||||
|
|
||||||
|
// Create test project
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
id: 'test_project_qa',
|
||||||
|
title: 'OceanWatch AI',
|
||||||
|
description: 'AI-powered ocean monitoring platform for marine conservation',
|
||||||
|
programId: program.id,
|
||||||
|
submittedByUserId: user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log('Created project:', project.id)
|
||||||
|
|
||||||
|
// Create team member (LEAD)
|
||||||
|
await prisma.teamMember.create({
|
||||||
|
data: {
|
||||||
|
id: 'test_tm_lead',
|
||||||
|
projectId: project.id,
|
||||||
|
userId: user.id,
|
||||||
|
role: 'LEAD',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log('Created team member (LEAD)')
|
||||||
|
|
||||||
|
// Send styled invitation email
|
||||||
|
const url = `http://localhost:3000/accept-invite?token=${inviteToken}`
|
||||||
|
console.log('Invite URL:', url)
|
||||||
|
|
||||||
|
await sendInvitationEmail(
|
||||||
|
'matt.ciaccio@gmail.com',
|
||||||
|
'Matt Ciaccio',
|
||||||
|
url,
|
||||||
|
'APPLICANT',
|
||||||
|
72
|
||||||
|
)
|
||||||
|
console.log('Styled invitation email sent!')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => prisma.$disconnect().then(() => process.exit(0)))
|
||||||
165
scripts/seed-notification-log.ts
Normal file
165
scripts/seed-notification-log.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Seed NotificationLog with confirmed SMTP delivery data.
|
||||||
|
*
|
||||||
|
* Sources:
|
||||||
|
* 1. 33 emails confirmed delivered in Poste.io SMTP logs (2026-03-04)
|
||||||
|
* 2. Users with status ACTIVE who are LEADs on PASSED projects
|
||||||
|
* (they clearly received and used their invite link)
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/seed-notification-log.ts
|
||||||
|
* Add --dry-run to preview without making changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
const dryRun = process.argv.includes('--dry-run')
|
||||||
|
|
||||||
|
// Emails confirmed delivered via SMTP logs on 2026-03-04
|
||||||
|
const CONFIRMED_SMTP_EMAILS = new Set([
|
||||||
|
'fbayong@balazstudio.com',
|
||||||
|
'gnoel@kilimora.africa',
|
||||||
|
'amal.chebbi@pigmentoco.com',
|
||||||
|
'nairita@yarsi.net',
|
||||||
|
'martin.itamalo@greenbrinetechnologies.com',
|
||||||
|
'petervegan1223@gmail.com',
|
||||||
|
'dmarinov@redget.io',
|
||||||
|
'adrien@seavium.com',
|
||||||
|
'l.buob@whisper-ef.com',
|
||||||
|
'silvia@omnivorus.com',
|
||||||
|
'marzettisebastian@gmail.com',
|
||||||
|
'fiona.mcomish@algae-scope.com',
|
||||||
|
'karimeguillen@rearvora.com',
|
||||||
|
'info@skywatt.tech',
|
||||||
|
'julia@nereia-coatings.com',
|
||||||
|
'info@janmaisenbacher.com',
|
||||||
|
'xbm_0201@qq.com',
|
||||||
|
'irinakharitonova0201@gmail.com',
|
||||||
|
'seablocksrecif@gmail.com',
|
||||||
|
'oscar@seafuser.com',
|
||||||
|
'charles.maher@blueshadow.dk',
|
||||||
|
'sabirabokhari@gmail.com',
|
||||||
|
'munayimbabura@gmail.com',
|
||||||
|
'amritha.ramadevu@edu.escp.eu',
|
||||||
|
'nele.jordan@myhsba.de',
|
||||||
|
'karl.mihhels@aalto.fi',
|
||||||
|
'christine.a.kurz@gmail.com',
|
||||||
|
'aki@corall.eco',
|
||||||
|
'topias.kilpinen@hotmail.fi',
|
||||||
|
'nina.riutta.camilla@gmail.com',
|
||||||
|
'sofie.boggiosella@my.jcu.edu.au',
|
||||||
|
'giambattistafigari@gmail.com',
|
||||||
|
'mussinig0@gmail.com',
|
||||||
|
])
|
||||||
|
|
||||||
|
const SENT_AT = new Date('2026-03-04T01:00:00Z')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(dryRun ? '--- DRY RUN ---\n' : 'Seeding NotificationLog...\n')
|
||||||
|
|
||||||
|
// Find LEAD team members on PASSED projects
|
||||||
|
const passedLeads = await prisma.teamMember.findMany({
|
||||||
|
where: {
|
||||||
|
role: 'LEAD',
|
||||||
|
project: {
|
||||||
|
projectRoundStates: {
|
||||||
|
some: { state: 'PASSED' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
projectId: true,
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
projectRoundStates: {
|
||||||
|
where: { state: 'PASSED' },
|
||||||
|
select: { roundId: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
status: true,
|
||||||
|
inviteToken: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Found ${passedLeads.length} LEAD team members on PASSED projects\n`)
|
||||||
|
|
||||||
|
let created = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
for (const lead of passedLeads) {
|
||||||
|
const email = lead.user.email?.toLowerCase()
|
||||||
|
if (!email) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a NotificationLog already exists for this project+email
|
||||||
|
const existing = await prisma.notificationLog.findFirst({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
projectId: lead.projectId,
|
||||||
|
type: 'ADVANCEMENT_NOTIFICATION',
|
||||||
|
status: 'SENT',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine confidence of delivery
|
||||||
|
const isConfirmedSMTP = CONFIRMED_SMTP_EMAILS.has(email)
|
||||||
|
const isActive = lead.user.status === 'ACTIVE'
|
||||||
|
const isInvited = lead.user.status === 'INVITED' && !!lead.user.inviteToken
|
||||||
|
|
||||||
|
// Only seed for confirmed deliveries or active users
|
||||||
|
if (!isConfirmedSMTP && !isActive && !isInvited) {
|
||||||
|
console.log(` SKIP ${email} (status=${lead.user.status}, not in SMTP logs)`)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundId = lead.project.projectRoundStates[0]?.roundId ?? null
|
||||||
|
const label = isConfirmedSMTP ? 'SMTP-confirmed' : isActive ? 'user-active' : 'invite-sent'
|
||||||
|
|
||||||
|
console.log(` ${dryRun ? 'WOULD CREATE' : 'CREATE'} ${email} [${label}] project=${lead.projectId}`)
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
await prisma.notificationLog.create({
|
||||||
|
data: {
|
||||||
|
userId: lead.user.id,
|
||||||
|
channel: 'EMAIL',
|
||||||
|
type: 'ADVANCEMENT_NOTIFICATION',
|
||||||
|
status: 'SENT',
|
||||||
|
email,
|
||||||
|
projectId: lead.projectId,
|
||||||
|
roundId,
|
||||||
|
batchId: 'seed-2026-03-04',
|
||||||
|
createdAt: SENT_AT,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
created++
|
||||||
|
} else {
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDone. Created: ${created}, Skipped: ${skipped}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Error:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
120
scripts/send-invite-direct.ts
Normal file
120
scripts/send-invite-direct.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
|
// Import just the template helper without hitting DB
|
||||||
|
// We'll construct the email manually since the DB connection fails
|
||||||
|
|
||||||
|
const BRAND = {
|
||||||
|
red: '#de0f1e',
|
||||||
|
darkBlue: '#053d57',
|
||||||
|
white: '#fefefe',
|
||||||
|
teal: '#557f8c',
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = '6f974b1da9fae95f74bbcd2419df589730979ac945aeaa5413021c00311b5165';
|
||||||
|
const url = 'http://localhost:3000/accept-invite?token=' + token;
|
||||||
|
|
||||||
|
// Replicate the styled email template from email.ts
|
||||||
|
function getStyledHtml(name: string, inviteUrl: string) {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>You're invited to join the MOPC Portal</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f8fafc;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; width: 100%;">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background: linear-gradient(135deg, ${BRAND.darkBlue} 0%, ${BRAND.teal} 100%); border-radius: 16px 16px 0 0; padding: 32px 40px; text-align: center;">
|
||||||
|
<h1 style="color: ${BRAND.white}; font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em;">
|
||||||
|
Monaco Ocean Protection Challenge
|
||||||
|
</h1>
|
||||||
|
<p style="color: rgba(255,255,255,0.8); font-size: 13px; font-weight: 300; margin: 8px 0 0 0; letter-spacing: 0.05em; text-transform: uppercase;">
|
||||||
|
Together for a healthier ocean
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Body -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #ffffff; padding: 40px; border-radius: 0 0 16px 16px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);">
|
||||||
|
<h2 style="color: ${BRAND.darkBlue}; font-size: 20px; font-weight: 600; margin: 0 0 24px 0;">
|
||||||
|
Hello ${name},
|
||||||
|
</h2>
|
||||||
|
<p style="color: #475569; font-size: 15px; line-height: 1.7; margin: 0 0 16px 0; font-weight: 400;">
|
||||||
|
You've been invited to join the Monaco Ocean Protection Challenge platform as an <strong>applicant</strong>.
|
||||||
|
</p>
|
||||||
|
<p style="color: #475569; font-size: 15px; line-height: 1.7; margin: 0 0 24px 0; font-weight: 400;">
|
||||||
|
Click the button below to set up your account and get started.
|
||||||
|
</p>
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 28px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="${inviteUrl}" style="display: inline-block; background: linear-gradient(135deg, ${BRAND.red} 0%, #c40d19 100%); color: #ffffff; text-decoration: none; padding: 14px 36px; border-radius: 10px; font-size: 15px; font-weight: 600; letter-spacing: 0.02em; box-shadow: 0 4px 14px rgba(222, 15, 30, 0.3);">
|
||||||
|
Accept Invitation
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!-- Info Box -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #eff6ff; border-left: 4px solid ${BRAND.darkBlue}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||||||
|
<p style="color: #1e40af; margin: 0; font-size: 13px; line-height: 1.6;">
|
||||||
|
This link will expire in 3 days.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 24px 40px; text-align: center;">
|
||||||
|
<p style="color: #94a3b8; font-size: 12px; line-height: 1.6; margin: 0;">
|
||||||
|
Monaco Ocean Protection Challenge<br>
|
||||||
|
<span style="color: #cbd5e1;">Together for a healthier ocean.</span>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Creating transporter...');
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: 'mail.monaco-opc.com',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: 'noreply@monaco-opc.com',
|
||||||
|
pass: '9EythPDcz1Fya4M88iigkB1wojNf8QEVPuRRnD9dJMBpT3pk2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Sending styled invitation email...');
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: 'MOPC Portal <noreply@monaco-opc.com>',
|
||||||
|
to: 'matt.ciaccio@gmail.com',
|
||||||
|
subject: "You're invited to join the MOPC Portal",
|
||||||
|
text: `Hello Matt Ciaccio,\n\nYou've been invited to join the Monaco Ocean Protection Challenge platform as an applicant.\n\nClick the link below to set up your account:\n\n${url}\n\nThis link will expire in 3 days.\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||||||
|
html: getStyledHtml('Matt Ciaccio', url),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('SUCCESS! Message ID:', info.messageId);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('FAILED:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
26
scripts/send-invite.ts
Normal file
26
scripts/send-invite.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { sendInvitationEmail } from '../src/lib/email';
|
||||||
|
|
||||||
|
const token = '6f974b1da9fae95f74bbcd2419df589730979ac945aeaa5413021c00311b5165';
|
||||||
|
const url = 'http://localhost:3000/accept-invite?token=' + token;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Sending styled invitation email...');
|
||||||
|
console.log('To: matt.ciaccio@gmail.com');
|
||||||
|
console.log('URL:', url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendInvitationEmail(
|
||||||
|
'matt.ciaccio@gmail.com',
|
||||||
|
'Matt Ciaccio',
|
||||||
|
url,
|
||||||
|
'APPLICANT',
|
||||||
|
72
|
||||||
|
);
|
||||||
|
console.log('SUCCESS: Styled invitation email sent!');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('FAILED:', err.message || err);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
20
scripts/test-db.cjs
Normal file
20
scripts/test-db.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('DATABASE_URL:', process.env.DATABASE_URL);
|
||||||
|
const p = new PrismaClient({ log: ['query', 'info', 'warn', 'error'] });
|
||||||
|
try {
|
||||||
|
const result = await p.$queryRawUnsafe('SELECT 1 as ok');
|
||||||
|
console.log('Connected!', result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error code:', e.code);
|
||||||
|
console.error('Error meta:', JSON.stringify(e.meta, null, 2));
|
||||||
|
console.error('Message:', e.message);
|
||||||
|
} finally {
|
||||||
|
await p.$disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -56,9 +56,11 @@ import { Switch } from '@/components/ui/switch'
|
|||||||
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||||
import { formatDate } from '@/lib/utils'
|
import { formatDate } from '@/lib/utils'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
// Action type options
|
// Action type options (manual audit actions + auto-generated mutation audit actions)
|
||||||
const ACTION_TYPES = [
|
const ACTION_TYPES = [
|
||||||
|
// Manual audit actions
|
||||||
'CREATE',
|
'CREATE',
|
||||||
'UPDATE',
|
'UPDATE',
|
||||||
'DELETE',
|
'DELETE',
|
||||||
@@ -76,6 +78,8 @@ const ACTION_TYPES = [
|
|||||||
'ROUND_ARCHIVED',
|
'ROUND_ARCHIVED',
|
||||||
'UPLOAD_FILE',
|
'UPLOAD_FILE',
|
||||||
'DELETE_FILE',
|
'DELETE_FILE',
|
||||||
|
'FILE_VIEWED',
|
||||||
|
'FILE_OPENED',
|
||||||
'FILE_DOWNLOADED',
|
'FILE_DOWNLOADED',
|
||||||
'BULK_CREATE',
|
'BULK_CREATE',
|
||||||
'BULK_UPDATE_STATUS',
|
'BULK_UPDATE_STATUS',
|
||||||
@@ -83,12 +87,58 @@ const ACTION_TYPES = [
|
|||||||
'ROLE_CHANGED',
|
'ROLE_CHANGED',
|
||||||
'PASSWORD_SET',
|
'PASSWORD_SET',
|
||||||
'PASSWORD_CHANGED',
|
'PASSWORD_CHANGED',
|
||||||
|
'JUROR_DROPOUT_RESHUFFLE',
|
||||||
|
'COI_REASSIGNMENT',
|
||||||
|
'APPLY_AI_SUGGESTIONS',
|
||||||
|
'APPLY_SUGGESTIONS',
|
||||||
|
'NOTIFY_JURORS_OF_ASSIGNMENTS',
|
||||||
|
'IMPERSONATION_START',
|
||||||
|
'IMPERSONATION_END',
|
||||||
|
// Auto-generated mutation audit actions (non-super-admin)
|
||||||
|
'EVALUATION_START',
|
||||||
|
'EVALUATION_SUBMIT',
|
||||||
|
'EVALUATION_AUTOSAVE',
|
||||||
|
'EVALUATION_DECLARE_COI',
|
||||||
|
'EVALUATION_ADD_COMMENT',
|
||||||
|
'APPLICANT_SAVE_SUBMISSION',
|
||||||
|
'APPLICANT_SAVE_FILE_METADATA',
|
||||||
|
'APPLICANT_DELETE_FILE',
|
||||||
|
'APPLICANT_REQUEST_MENTORING',
|
||||||
|
'APPLICANT_WITHDRAW_FROM_COMPETITION',
|
||||||
|
'APPLICANT_INVITE_TEAM_MEMBER',
|
||||||
|
'APPLICANT_REMOVE_TEAM_MEMBER',
|
||||||
|
'APPLICANT_SEND_MENTOR_MESSAGE',
|
||||||
|
'APPLICATION_SUBMIT',
|
||||||
|
'APPLICATION_SAVE_DRAFT',
|
||||||
|
'APPLICATION_SUBMIT_DRAFT',
|
||||||
|
'MENTOR_SEND_MESSAGE',
|
||||||
|
'MENTOR_CREATE_NOTE',
|
||||||
|
'MENTOR_DELETE_NOTE',
|
||||||
|
'MENTOR_COMPLETE_MILESTONE',
|
||||||
|
'LIVE_CAST_VOTE',
|
||||||
|
'LIVE_CAST_STAGE_VOTE',
|
||||||
|
'LIVE_VOTING_VOTE',
|
||||||
|
'LIVE_VOTING_CAST_AUDIENCE_VOTE',
|
||||||
|
'DELIBERATION_SUBMIT_VOTE',
|
||||||
|
'NOTIFICATION_MARK_AS_READ',
|
||||||
|
'NOTIFICATION_MARK_ALL_AS_READ',
|
||||||
|
'USER_UPDATE_PROFILE',
|
||||||
|
'USER_SET_PASSWORD',
|
||||||
|
'USER_CHANGE_PASSWORD',
|
||||||
|
'USER_COMPLETE_ONBOARDING',
|
||||||
|
'SPECIAL_AWARD_SUBMIT_VOTE',
|
||||||
|
// Security events
|
||||||
|
'ACCOUNT_LOCKED',
|
||||||
|
'ACCESS_DENIED_FORBIDDEN',
|
||||||
|
'ACCESS_DENIED_UNAUTHORIZED',
|
||||||
|
'ACCESS_DENIED_NOT_FOUND',
|
||||||
]
|
]
|
||||||
|
|
||||||
// Entity type options
|
// Entity type options
|
||||||
const ENTITY_TYPES = [
|
const ENTITY_TYPES = [
|
||||||
'User',
|
'User',
|
||||||
'Program',
|
'Program',
|
||||||
|
'Competition',
|
||||||
'Round',
|
'Round',
|
||||||
'Project',
|
'Project',
|
||||||
'Assignment',
|
'Assignment',
|
||||||
@@ -96,6 +146,21 @@ const ENTITY_TYPES = [
|
|||||||
'EvaluationForm',
|
'EvaluationForm',
|
||||||
'ProjectFile',
|
'ProjectFile',
|
||||||
'GracePeriod',
|
'GracePeriod',
|
||||||
|
'Applicant',
|
||||||
|
'Application',
|
||||||
|
'Mentor',
|
||||||
|
'Live',
|
||||||
|
'LiveVoting',
|
||||||
|
'Deliberation',
|
||||||
|
'Notification',
|
||||||
|
'SpecialAward',
|
||||||
|
'File',
|
||||||
|
'Tag',
|
||||||
|
'Message',
|
||||||
|
'Settings',
|
||||||
|
'Ranking',
|
||||||
|
'Filtering',
|
||||||
|
'RoundEngine',
|
||||||
]
|
]
|
||||||
|
|
||||||
// Color map for action types
|
// Color map for action types
|
||||||
@@ -114,10 +179,69 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
|
|||||||
ROUND_ACTIVATED: 'default',
|
ROUND_ACTIVATED: 'default',
|
||||||
ROUND_CLOSED: 'secondary',
|
ROUND_CLOSED: 'secondary',
|
||||||
ROUND_ARCHIVED: 'secondary',
|
ROUND_ARCHIVED: 'secondary',
|
||||||
|
FILE_VIEWED: 'outline',
|
||||||
|
FILE_OPENED: 'outline',
|
||||||
FILE_DOWNLOADED: 'outline',
|
FILE_DOWNLOADED: 'outline',
|
||||||
ROLE_CHANGED: 'secondary',
|
ROLE_CHANGED: 'secondary',
|
||||||
PASSWORD_SET: 'outline',
|
PASSWORD_SET: 'outline',
|
||||||
PASSWORD_CHANGED: 'outline',
|
PASSWORD_CHANGED: 'outline',
|
||||||
|
JUROR_DROPOUT_RESHUFFLE: 'destructive',
|
||||||
|
COI_REASSIGNMENT: 'secondary',
|
||||||
|
APPLY_AI_SUGGESTIONS: 'default',
|
||||||
|
APPLY_SUGGESTIONS: 'default',
|
||||||
|
NOTIFY_JURORS_OF_ASSIGNMENTS: 'outline',
|
||||||
|
IMPERSONATION_START: 'destructive',
|
||||||
|
IMPERSONATION_END: 'secondary',
|
||||||
|
// Auto-generated mutation audit actions
|
||||||
|
EVALUATION_START: 'default',
|
||||||
|
EVALUATION_SUBMIT: 'default',
|
||||||
|
EVALUATION_AUTOSAVE: 'outline',
|
||||||
|
EVALUATION_DECLARE_COI: 'secondary',
|
||||||
|
EVALUATION_ADD_COMMENT: 'outline',
|
||||||
|
APPLICANT_SAVE_SUBMISSION: 'default',
|
||||||
|
APPLICANT_DELETE_FILE: 'destructive',
|
||||||
|
APPLICANT_WITHDRAW_FROM_COMPETITION: 'destructive',
|
||||||
|
APPLICANT_INVITE_TEAM_MEMBER: 'default',
|
||||||
|
APPLICANT_REMOVE_TEAM_MEMBER: 'destructive',
|
||||||
|
APPLICATION_SUBMIT: 'default',
|
||||||
|
MENTOR_SEND_MESSAGE: 'outline',
|
||||||
|
MENTOR_CREATE_NOTE: 'default',
|
||||||
|
MENTOR_DELETE_NOTE: 'destructive',
|
||||||
|
LIVE_CAST_VOTE: 'default',
|
||||||
|
LIVE_CAST_STAGE_VOTE: 'default',
|
||||||
|
LIVE_VOTING_CAST_AUDIENCE_VOTE: 'default',
|
||||||
|
DELIBERATION_SUBMIT_VOTE: 'default',
|
||||||
|
SPECIAL_AWARD_SUBMIT_VOTE: 'default',
|
||||||
|
USER_UPDATE_PROFILE: 'secondary',
|
||||||
|
USER_SET_PASSWORD: 'outline',
|
||||||
|
USER_CHANGE_PASSWORD: 'outline',
|
||||||
|
USER_COMPLETE_ONBOARDING: 'default',
|
||||||
|
// Security events
|
||||||
|
ACCOUNT_LOCKED: 'destructive',
|
||||||
|
ACCESS_DENIED_FORBIDDEN: 'destructive',
|
||||||
|
ACCESS_DENIED_UNAUTHORIZED: 'destructive',
|
||||||
|
ACCESS_DENIED_NOT_FOUND: 'secondary',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getEntityLink(entityType: string, entityId: string): string | null {
|
||||||
|
switch (entityType) {
|
||||||
|
case 'User':
|
||||||
|
return `/admin/members/${entityId}`
|
||||||
|
case 'Project':
|
||||||
|
return `/admin/projects/${entityId}`
|
||||||
|
case 'Round':
|
||||||
|
return `/admin/rounds/${entityId}`
|
||||||
|
case 'Competition':
|
||||||
|
return `/admin/competitions`
|
||||||
|
case 'Evaluation':
|
||||||
|
case 'EvaluationForm':
|
||||||
|
return null // no dedicated page
|
||||||
|
case 'SpecialAward':
|
||||||
|
return `/admin/awards/${entityId}`
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuditLogPage() {
|
export default function AuditLogPage() {
|
||||||
@@ -151,7 +275,7 @@ export default function AuditLogPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Fetch audit logs
|
// Fetch audit logs
|
||||||
const { data, isLoading, refetch } = trpc.audit.list.useQuery(queryInput)
|
const { data, isLoading, refetch } = trpc.audit.list.useQuery(queryInput, { refetchInterval: 30_000 })
|
||||||
|
|
||||||
// Fetch users for filter dropdown
|
// Fetch users for filter dropdown
|
||||||
const { data: usersData } = trpc.user.list.useQuery({
|
const { data: usersData } = trpc.user.list.useQuery({
|
||||||
@@ -452,14 +576,24 @@ export default function AuditLogPage() {
|
|||||||
{formatDate(log.timestamp)}
|
{formatDate(log.timestamp)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
{log.userId ? (
|
||||||
<p className="font-medium text-sm">
|
<Link
|
||||||
{log.user?.name || 'System'}
|
href={`/admin/members/${log.userId}`}
|
||||||
</p>
|
className="group block"
|
||||||
<p className="text-xs text-muted-foreground">
|
onClick={(e) => e.stopPropagation()}
|
||||||
{log.user?.email}
|
>
|
||||||
</p>
|
<p className="font-medium text-sm group-hover:text-primary group-hover:underline">
|
||||||
</div>
|
{log.user?.name || 'System'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{log.user?.email}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">System</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -471,11 +605,22 @@ export default function AuditLogPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm">{log.entityType}</p>
|
<p className="text-sm">{log.entityType}</p>
|
||||||
{log.entityId && (
|
{log.entityId && (() => {
|
||||||
<p className="text-xs text-muted-foreground font-mono">
|
const link = getEntityLink(log.entityType, log.entityId)
|
||||||
{log.entityId.slice(0, 8)}...
|
return link ? (
|
||||||
</p>
|
<Link
|
||||||
)}
|
href={link}
|
||||||
|
className="text-xs text-primary font-mono hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{log.entityId.slice(0, 8)}...
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground font-mono">
|
||||||
|
{log.entityId.slice(0, 8)}...
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-xs">
|
<TableCell className="font-mono text-xs">
|
||||||
@@ -498,9 +643,18 @@ export default function AuditLogPage() {
|
|||||||
<p className="text-xs font-medium text-muted-foreground">
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
Entity ID
|
Entity ID
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-sm">
|
{log.entityId ? (() => {
|
||||||
{log.entityId || 'N/A'}
|
const link = getEntityLink(log.entityType, log.entityId)
|
||||||
</p>
|
return link ? (
|
||||||
|
<Link href={link} className="font-mono text-sm text-primary hover:underline" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{log.entityId}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<p className="font-mono text-sm">{log.entityId}</p>
|
||||||
|
)
|
||||||
|
})() : (
|
||||||
|
<p className="font-mono text-sm">N/A</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
@@ -516,9 +670,15 @@ export default function AuditLogPage() {
|
|||||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
Details
|
Details
|
||||||
</p>
|
</p>
|
||||||
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
{log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
|
||||||
{JSON.stringify(log.detailsJson, null, 2)}
|
<ReshuffleDetailView details={log.detailsJson as Record<string, unknown>} />
|
||||||
</pre>
|
) : log.action === 'COI_REASSIGNMENT' ? (
|
||||||
|
<COIReassignmentDetailView details={log.detailsJson as Record<string, unknown>} />
|
||||||
|
) : (
|
||||||
|
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
||||||
|
{JSON.stringify(log.detailsJson, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!!(log as Record<string, unknown>).previousDataJson && (
|
{!!(log as Record<string, unknown>).previousDataJson && (
|
||||||
@@ -591,12 +751,23 @@ export default function AuditLogPage() {
|
|||||||
{formatDate(log.timestamp)}
|
{formatDate(log.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-muted-foreground">
|
{log.userId ? (
|
||||||
<User className="h-3 w-3" />
|
<Link
|
||||||
<span className="text-xs">
|
href={`/admin/members/${log.userId}`}
|
||||||
{log.user?.name || 'System'}
|
className="flex items-center gap-1 text-muted-foreground hover:text-primary"
|
||||||
</span>
|
onClick={(e) => e.stopPropagation()}
|
||||||
</div>
|
>
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
<span className="text-xs hover:underline">
|
||||||
|
{log.user?.name || 'System'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
<span className="text-xs">System</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
@@ -622,9 +793,15 @@ export default function AuditLogPage() {
|
|||||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
Details
|
Details
|
||||||
</p>
|
</p>
|
||||||
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
{log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
|
||||||
{JSON.stringify(log.detailsJson, null, 2)}
|
<ReshuffleDetailView details={log.detailsJson as Record<string, unknown>} />
|
||||||
</pre>
|
) : log.action === 'COI_REASSIGNMENT' ? (
|
||||||
|
<COIReassignmentDetailView details={log.detailsJson as Record<string, unknown>} />
|
||||||
|
) : (
|
||||||
|
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
|
||||||
|
{JSON.stringify(log.detailsJson, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -693,6 +870,129 @@ export default function AuditLogPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ReshuffleDetailView({ details }: { details: Record<string, unknown> }) {
|
||||||
|
const reassignedTo = (details.reassignedTo ?? {}) as Record<string, number>
|
||||||
|
const jurorIds = Object.keys(reassignedTo)
|
||||||
|
const moves = (details.moves ?? []) as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]
|
||||||
|
|
||||||
|
// Resolve juror IDs to names
|
||||||
|
const { data: nameMap } = trpc.user.resolveNames.useQuery(
|
||||||
|
{ ids: [...jurorIds, details.droppedJurorId as string].filter(Boolean) },
|
||||||
|
{ enabled: jurorIds.length > 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const droppedName = (details.droppedJurorName as string) || (nameMap && details.droppedJurorId ? nameMap[details.droppedJurorId as string] : null) || (details.droppedJurorId as string)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-white overflow-hidden text-sm">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="p-3 bg-muted/50 border-b space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="destructive">Juror Dropout</Badge>
|
||||||
|
<span className="font-semibold">{droppedName}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{String(details.movedCount)} project(s) reassigned, {String(details.failedCount)} failed
|
||||||
|
{details.removedFromGroup ? ' — removed from jury group' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-project moves (new format) */}
|
||||||
|
{moves.length > 0 && (
|
||||||
|
<div className="p-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2">Project → New Juror</p>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-muted-foreground border-b">
|
||||||
|
<th className="text-left py-1 font-medium">Project</th>
|
||||||
|
<th className="text-left py-1 font-medium">Reassigned To</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{moves.map((move, i) => (
|
||||||
|
<tr key={i} className="border-b last:border-0">
|
||||||
|
<td className="py-1.5 pr-2">{move.projectTitle}</td>
|
||||||
|
<td className="py-1.5 font-medium">{move.newJurorName}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fallback: count-based view (old format, no per-project detail) */}
|
||||||
|
{moves.length === 0 && jurorIds.length > 0 && (
|
||||||
|
<div className="p-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2">Reassignment Summary (project detail not available)</p>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-muted-foreground border-b">
|
||||||
|
<th className="text-left py-1 font-medium">Juror</th>
|
||||||
|
<th className="text-right py-1 font-medium">Projects Received</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jurorIds.map((id) => (
|
||||||
|
<tr key={id} className="border-b last:border-0">
|
||||||
|
<td className="py-1.5">{nameMap?.[id] || id}</td>
|
||||||
|
<td className="py-1.5 text-right font-medium">{reassignedTo[id]}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Failed projects */}
|
||||||
|
{Array.isArray(details.failedProjects) && (details.failedProjects as string[]).length > 0 && (
|
||||||
|
<div className="p-3 border-t bg-red-50/50">
|
||||||
|
<p className="text-xs font-medium text-red-700 mb-1">Could not reassign:</p>
|
||||||
|
<ul className="text-xs text-muted-foreground list-disc list-inside">
|
||||||
|
{(details.failedProjects as string[]).map((p, i) => (
|
||||||
|
<li key={i}>{p}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function COIReassignmentDetailView({ details }: { details: Record<string, unknown> }) {
|
||||||
|
const ids = [details.oldJurorId, details.newJurorId].filter(Boolean) as string[]
|
||||||
|
const { data: nameMap } = trpc.user.resolveNames.useQuery(
|
||||||
|
{ ids },
|
||||||
|
{ enabled: ids.length > 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const oldName = nameMap?.[details.oldJurorId as string] || (details.oldJurorId as string)
|
||||||
|
const newName = nameMap?.[details.newJurorId as string] || (details.newJurorId as string)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-white overflow-hidden text-sm">
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary">COI Reassignment</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">From</p>
|
||||||
|
<p className="font-medium">{oldName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">To</p>
|
||||||
|
<p className="font-medium">{newName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Project: <span className="font-mono">{(details.projectId as string)?.slice(0, 12)}...</span>
|
||||||
|
{' | '}Round: <span className="font-mono">{(details.roundId as string)?.slice(0, 12)}...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function DiffViewer({ before, after }: { before: unknown; after: unknown }) {
|
function DiffViewer({ before, after }: { before: unknown; after: unknown }) {
|
||||||
const beforeObj = typeof before === 'object' && before !== null ? before as Record<string, unknown> : {}
|
const beforeObj = typeof before === 'object' && before !== null ? before as Record<string, unknown> : {}
|
||||||
const afterObj = typeof after === 'object' && after !== null ? after as Record<string, unknown> : {}
|
const afterObj = typeof after === 'object' && after !== null ? after as Record<string, unknown> : {}
|
||||||
|
|||||||
@@ -25,15 +25,7 @@ import {
|
|||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ArrowLeft, Save, Loader2, Plus, X, Info } from 'lucide-react'
|
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
|
|
||||||
type AutoTagRule = {
|
|
||||||
id: string
|
|
||||||
field: 'competitionCategory' | 'country' | 'geographicZone' | 'tags' | 'oceanIssue'
|
|
||||||
operator: 'equals' | 'contains' | 'in'
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EditAwardPage({
|
export default function EditAwardPage({
|
||||||
params,
|
params,
|
||||||
@@ -46,12 +38,8 @@ export default function EditAwardPage({
|
|||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId })
|
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId })
|
||||||
|
|
||||||
// Fetch competition rounds for source round selector
|
// Rounds come from the award's included competition relation
|
||||||
const competitionId = award?.competitionId
|
const competitionRounds = award?.competition?.rounds ?? []
|
||||||
const { data: competition } = trpc.competition.getById.useQuery(
|
|
||||||
{ id: competitionId! },
|
|
||||||
{ enabled: !!competitionId }
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateAward = trpc.specialAward.update.useMutation({
|
const updateAward = trpc.specialAward.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -70,7 +58,6 @@ export default function EditAwardPage({
|
|||||||
const [votingEndAt, setVotingEndAt] = useState('')
|
const [votingEndAt, setVotingEndAt] = useState('')
|
||||||
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
||||||
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
|
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
|
||||||
const [autoTagRules, setAutoTagRules] = useState<AutoTagRule[]>([])
|
|
||||||
|
|
||||||
// Helper to format date for datetime-local input
|
// Helper to format date for datetime-local input
|
||||||
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||||
@@ -93,14 +80,6 @@ export default function EditAwardPage({
|
|||||||
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
||||||
setEvaluationRoundId(award.evaluationRoundId || '')
|
setEvaluationRoundId(award.evaluationRoundId || '')
|
||||||
setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL')
|
setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL')
|
||||||
|
|
||||||
// Parse autoTagRulesJson
|
|
||||||
if (award.autoTagRulesJson && typeof award.autoTagRulesJson === 'object') {
|
|
||||||
const rules = award.autoTagRulesJson as { rules?: AutoTagRule[] }
|
|
||||||
setAutoTagRules(rules.rules || [])
|
|
||||||
} else {
|
|
||||||
setAutoTagRules([])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [award])
|
}, [award])
|
||||||
|
|
||||||
@@ -119,7 +98,6 @@ export default function EditAwardPage({
|
|||||||
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
||||||
evaluationRoundId: evaluationRoundId || undefined,
|
evaluationRoundId: evaluationRoundId || undefined,
|
||||||
eligibilityMode,
|
eligibilityMode,
|
||||||
autoTagRulesJson: autoTagRules.length > 0 ? { rules: autoTagRules } : undefined,
|
|
||||||
})
|
})
|
||||||
toast.success('Award updated')
|
toast.success('Award updated')
|
||||||
router.push(`/admin/awards/${awardId}`)
|
router.push(`/admin/awards/${awardId}`)
|
||||||
@@ -130,28 +108,6 @@ export default function EditAwardPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addRule = () => {
|
|
||||||
setAutoTagRules([
|
|
||||||
...autoTagRules,
|
|
||||||
{
|
|
||||||
id: `rule-${Date.now()}`,
|
|
||||||
field: 'competitionCategory',
|
|
||||||
operator: 'equals',
|
|
||||||
value: '',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeRule = (id: string) => {
|
|
||||||
setAutoTagRules(autoTagRules.filter((r) => r.id !== id))
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateRule = (id: string, updates: Partial<AutoTagRule>) => {
|
|
||||||
setAutoTagRules(
|
|
||||||
autoTagRules.map((r) => (r.id === id ? { ...r, ...updates } : r))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -166,11 +122,9 @@ export default function EditAwardPage({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href={`/admin/awards/${awardId}`}>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Award
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -306,13 +260,11 @@ export default function EditAwardPage({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">No source round</SelectItem>
|
<SelectItem value="none">No source round</SelectItem>
|
||||||
{competition?.rounds
|
{competitionRounds.map((round) => (
|
||||||
?.sort((a, b) => a.sortOrder - b.sortOrder)
|
<SelectItem key={round.id} value={round.id}>
|
||||||
.map((round) => (
|
{round.name} ({round.roundType})
|
||||||
<SelectItem key={round.id} value={round.id}>
|
</SelectItem>
|
||||||
{round.name} ({round.roundType})
|
))}
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -348,135 +300,6 @@ export default function EditAwardPage({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Auto-Tag Rules */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Auto-Tag Rules</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Deterministic eligibility rules based on project metadata
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" onClick={addRule}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Add Rule
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{autoTagRules.length === 0 ? (
|
|
||||||
<div className="flex items-start gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
|
||||||
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
|
||||||
<p>
|
|
||||||
No rules defined. Add rules to automatically filter projects based on category, location, tags, or ocean issues.
|
|
||||||
Rules work together with the source round setting.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{autoTagRules.map((rule, index) => (
|
|
||||||
<div
|
|
||||||
key={rule.id}
|
|
||||||
className="flex items-start gap-3 rounded-lg border p-3"
|
|
||||||
>
|
|
||||||
<div className="flex-1 grid gap-3 sm:grid-cols-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Field</Label>
|
|
||||||
<Select
|
|
||||||
value={rule.field}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
updateRule(rule.id, {
|
|
||||||
field: v as AutoTagRule['field'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="competitionCategory">
|
|
||||||
Competition Category
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="country">Country</SelectItem>
|
|
||||||
<SelectItem value="geographicZone">
|
|
||||||
Geographic Zone
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="tags">Tags</SelectItem>
|
|
||||||
<SelectItem value="oceanIssue">Ocean Issue</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Operator</Label>
|
|
||||||
<Select
|
|
||||||
value={rule.operator}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
updateRule(rule.id, {
|
|
||||||
operator: v as AutoTagRule['operator'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="equals">Equals</SelectItem>
|
|
||||||
<SelectItem value="contains">Contains</SelectItem>
|
|
||||||
<SelectItem value="in">In (comma-separated)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Value</Label>
|
|
||||||
<Input
|
|
||||||
className="h-9"
|
|
||||||
value={rule.value}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateRule(rule.id, { value: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
rule.operator === 'in'
|
|
||||||
? 'value1,value2,value3'
|
|
||||||
: 'Enter value...'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-9 w-9 shrink-0"
|
|
||||||
onClick={() => removeRule(rule.id)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{autoTagRules.length > 0 && (
|
|
||||||
<div className="flex items-start gap-2 rounded-lg bg-muted p-3 text-xs text-muted-foreground">
|
|
||||||
<Info className="h-3 w-3 mt-0.5 shrink-0" />
|
|
||||||
<p>
|
|
||||||
<strong>How it works:</strong> Filter from{' '}
|
|
||||||
<Badge variant="outline" className="mx-1">
|
|
||||||
{evaluationRoundId
|
|
||||||
? competition?.rounds?.find((r) => r.id === evaluationRoundId)
|
|
||||||
?.name || 'Selected Round'
|
|
||||||
: 'All Projects'}
|
|
||||||
</Badge>
|
|
||||||
, where ALL rules match (AND logic). Projects matching these deterministic rules will be marked eligible.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Voting Window Card */}
|
{/* Voting Window Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import { Progress } from '@/components/ui/progress'
|
|||||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { Pagination } from '@/components/shared/pagination'
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
|
import { EmailPreviewDialog } from '@/components/admin/round/email-preview-dialog'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -89,7 +90,30 @@ import {
|
|||||||
Vote,
|
Vote,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Layers,
|
||||||
|
Info,
|
||||||
|
Mail,
|
||||||
|
GripVertical,
|
||||||
|
ArrowRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
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 { CountryDisplay } from '@/components/shared/country-display'
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
DRAFT: 'secondary',
|
DRAFT: 'secondary',
|
||||||
@@ -112,6 +136,199 @@ function getStepIndex(status: string): number {
|
|||||||
return idx >= 0 ? idx : (status === 'ARCHIVED' ? 3 : 0)
|
return idx >= 0 ? idx : (status === 'ARCHIVED' ? 3 : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROUND_TYPE_COLORS: Record<string, string> = {
|
||||||
|
EVALUATION: 'bg-violet-100 text-violet-700',
|
||||||
|
FILTERING: 'bg-amber-100 text-amber-700',
|
||||||
|
SUBMISSION: 'bg-blue-100 text-blue-700',
|
||||||
|
MENTORING: 'bg-teal-100 text-teal-700',
|
||||||
|
LIVE_FINAL: 'bg-rose-100 text-rose-700',
|
||||||
|
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
||||||
|
}
|
||||||
|
const ROUND_STATUS_COLORS: Record<string, string> = {
|
||||||
|
DRAFT: 'bg-gray-100 text-gray-600',
|
||||||
|
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||||
|
CLOSED: 'bg-blue-100 text-blue-700',
|
||||||
|
ARCHIVED: 'bg-muted text-muted-foreground',
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableRoundCard({
|
||||||
|
round,
|
||||||
|
index,
|
||||||
|
isFirst,
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
}: {
|
||||||
|
round: any
|
||||||
|
index: number
|
||||||
|
isFirst: boolean
|
||||||
|
onDelete: (roundId: string) => void
|
||||||
|
isDeleting: boolean
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: round.id })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectCount = round._count?.projectRoundStates ?? 0
|
||||||
|
const assignmentCount = round._count?.assignments ?? 0
|
||||||
|
const statusLabel = round.status.replace('ROUND_', '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`hover:shadow-md transition-shadow ${isDragging ? 'opacity-50 shadow-lg z-50' : ''}`}
|
||||||
|
>
|
||||||
|
<CardContent className="pt-4 pb-3 space-y-3">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<button
|
||||||
|
className="cursor-grab touch-none text-muted-foreground hover:text-foreground mt-1 shrink-0"
|
||||||
|
aria-label="Drag to reorder"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
|
||||||
|
{round.name}
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
<Badge variant="secondary" className={`text-[10px] ${ROUND_TYPE_COLORS[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{round.roundType.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className={`text-[10px] ${ROUND_STATUS_COLORS[statusLabel]}`}>
|
||||||
|
{statusLabel}
|
||||||
|
</Badge>
|
||||||
|
{isFirst && (
|
||||||
|
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
|
||||||
|
Entry point
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Layers className="h-3.5 w-3.5" />
|
||||||
|
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
{assignmentCount > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<ListChecks className="h-3.5 w-3.5" />
|
||||||
|
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{round.status === 'ROUND_DRAFT' && (
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete "{round.name}". This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => onDelete(round.id)}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoundsDndGrid({
|
||||||
|
rounds,
|
||||||
|
awardId,
|
||||||
|
onReorder,
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
}: {
|
||||||
|
rounds: any[]
|
||||||
|
awardId: string
|
||||||
|
onReorder: (roundIds: string[]) => void
|
||||||
|
onDelete: (roundId: string) => void
|
||||||
|
isDeleting: boolean
|
||||||
|
}) {
|
||||||
|
const [items, setItems] = useState(rounds.map((r: any) => r.id))
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sync if server data changes
|
||||||
|
useEffect(() => {
|
||||||
|
setItems(rounds.map((r: any) => r.id))
|
||||||
|
}, [rounds])
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id) return
|
||||||
|
|
||||||
|
const oldIndex = items.indexOf(active.id as string)
|
||||||
|
const newIndex = items.indexOf(over.id as string)
|
||||||
|
const newItems = arrayMove(items, oldIndex, newIndex)
|
||||||
|
setItems(newItems)
|
||||||
|
onReorder(newItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundMap = new Map(rounds.map((r: any) => [r.id, r]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((id, index) => {
|
||||||
|
const round = roundMap.get(id)
|
||||||
|
if (!round) return null
|
||||||
|
return (
|
||||||
|
<SortableRoundCard
|
||||||
|
key={id}
|
||||||
|
round={round}
|
||||||
|
index={index}
|
||||||
|
isFirst={index === 0}
|
||||||
|
onDelete={onDelete}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ConfidenceBadge({ confidence }: { confidence: number }) {
|
function ConfidenceBadge({ confidence }: { confidence: number }) {
|
||||||
if (confidence > 0.8) {
|
if (confidence > 0.8) {
|
||||||
return (
|
return (
|
||||||
@@ -151,6 +368,10 @@ export default function AwardDetailPage({
|
|||||||
const [projectSearchQuery, setProjectSearchQuery] = useState('')
|
const [projectSearchQuery, setProjectSearchQuery] = useState('')
|
||||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||||
const [activeTab, setActiveTab] = useState('eligibility')
|
const [activeTab, setActiveTab] = useState('eligibility')
|
||||||
|
const [addRoundOpen, setAddRoundOpen] = useState(false)
|
||||||
|
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
|
||||||
|
const [notifyDialogOpen, setNotifyDialogOpen] = useState(false)
|
||||||
|
const [notifyCustomMessage, setNotifyCustomMessage] = useState<string | undefined>()
|
||||||
|
|
||||||
// Pagination for eligibility list
|
// Pagination for eligibility list
|
||||||
const [eligibilityPage, setEligibilityPage] = useState(1)
|
const [eligibilityPage, setEligibilityPage] = useState(1)
|
||||||
@@ -158,7 +379,7 @@ export default function AwardDetailPage({
|
|||||||
|
|
||||||
// Core queries — lazy-load tab-specific data based on activeTab
|
// Core queries — lazy-load tab-specific data based on activeTab
|
||||||
const { data: award, isLoading, refetch } =
|
const { data: award, isLoading, refetch } =
|
||||||
trpc.specialAward.get.useQuery({ id: awardId })
|
trpc.specialAward.get.useQuery({ id: awardId }, { refetchInterval: 30_000 })
|
||||||
const { data: eligibilityData, refetch: refetchEligibility } =
|
const { data: eligibilityData, refetch: refetchEligibility } =
|
||||||
trpc.specialAward.listEligible.useQuery({
|
trpc.specialAward.listEligible.useQuery({
|
||||||
awardId,
|
awardId,
|
||||||
@@ -175,6 +396,10 @@ export default function AwardDetailPage({
|
|||||||
trpc.specialAward.getVoteResults.useQuery({ awardId }, {
|
trpc.specialAward.getVoteResults.useQuery({ awardId }, {
|
||||||
enabled: activeTab === 'results',
|
enabled: activeTab === 'results',
|
||||||
})
|
})
|
||||||
|
const { data: awardRounds, refetch: refetchRounds } =
|
||||||
|
trpc.specialAward.listRounds.useQuery({ awardId }, {
|
||||||
|
enabled: activeTab === 'rounds',
|
||||||
|
})
|
||||||
|
|
||||||
// Deferred queries - only load when needed
|
// Deferred queries - only load when needed
|
||||||
const { data: allUsers } = trpc.user.list.useQuery(
|
const { data: allUsers } = trpc.user.list.useQuery(
|
||||||
@@ -258,6 +483,47 @@ export default function AwardDetailPage({
|
|||||||
const deleteAward = trpc.specialAward.delete.useMutation({
|
const deleteAward = trpc.specialAward.delete.useMutation({
|
||||||
onSuccess: () => utils.specialAward.list.invalidate(),
|
onSuccess: () => utils.specialAward.list.invalidate(),
|
||||||
})
|
})
|
||||||
|
const createRound = trpc.specialAward.createRound.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchRounds()
|
||||||
|
setAddRoundOpen(false)
|
||||||
|
setRoundForm({ name: '', roundType: 'EVALUATION' })
|
||||||
|
toast.success('Round created')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
const deleteRound = trpc.specialAward.deleteRound.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchRounds()
|
||||||
|
toast.success('Round deleted')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
const reorderRounds = trpc.specialAward.reorderAwardRounds.useMutation({
|
||||||
|
onSuccess: () => refetchRounds(),
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
const assignToFirstRound = trpc.specialAward.assignToFirstRound.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success(`Assigned ${result.totalAssigned} projects to first round (${result.createdCount} new, ${result.movedCount} moved)`)
|
||||||
|
refetchRounds()
|
||||||
|
refetch()
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const notifyPreview = trpc.specialAward.previewAwardSelectionEmail.useQuery(
|
||||||
|
{ awardId, customMessage: notifyCustomMessage },
|
||||||
|
{ enabled: notifyDialogOpen }
|
||||||
|
)
|
||||||
|
const notifyEligible = trpc.specialAward.notifyEligibleProjects.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success(`Notified ${result.notified} projects (${result.emailsSent} emails sent${result.emailsFailed ? `, ${result.emailsFailed} failed` : ''})`)
|
||||||
|
setNotifyDialogOpen(false)
|
||||||
|
setNotifyCustomMessage(undefined)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
const handleStatusChange = async (
|
const handleStatusChange = async (
|
||||||
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
|
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
|
||||||
@@ -398,11 +664,9 @@ export default function AwardDetailPage({
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/awards">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Awards
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -414,7 +678,7 @@ export default function AwardDetailPage({
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
||||||
{award.status.replace('_', ' ')}
|
{award.status.replace(/_/g, ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{award.program.year} Edition
|
{award.program.year} Edition
|
||||||
@@ -444,13 +708,44 @@ export default function AwardDetailPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{award.status === 'NOMINATIONS_OPEN' && (
|
{award.status === 'NOMINATIONS_OPEN' && (
|
||||||
<Button
|
<>
|
||||||
onClick={() => handleStatusChange('VOTING_OPEN')}
|
<Button variant="outline" disabled={award.eligibleCount === 0} onClick={() => setNotifyDialogOpen(true)}>
|
||||||
disabled={updateStatus.isPending}
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
>
|
Notify Pool ({award.eligibleCount})
|
||||||
<Play className="mr-2 h-4 w-4" />
|
</Button>
|
||||||
Open Voting
|
<EmailPreviewDialog
|
||||||
</Button>
|
open={notifyDialogOpen}
|
||||||
|
onOpenChange={setNotifyDialogOpen}
|
||||||
|
title="Notify Eligible Projects"
|
||||||
|
description={`Send "Under consideration for ${award.name}" emails to all ${award.eligibleCount} eligible projects.`}
|
||||||
|
recipientCount={notifyPreview.data?.recipientCount ?? 0}
|
||||||
|
previewHtml={notifyPreview.data?.html}
|
||||||
|
isPreviewLoading={notifyPreview.isLoading}
|
||||||
|
onSend={(msg) => notifyEligible.mutate({ awardId, customMessage: msg })}
|
||||||
|
isSending={notifyEligible.isPending}
|
||||||
|
onRefreshPreview={(msg) => setNotifyCustomMessage(msg)}
|
||||||
|
/>
|
||||||
|
{award.eligibilityMode === 'SEPARATE_POOL' ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => assignToFirstRound.mutate({ awardId })}
|
||||||
|
disabled={assignToFirstRound.isPending || award.eligibleCount === 0}
|
||||||
|
>
|
||||||
|
{assignToFirstRound.isPending ? (
|
||||||
|
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Assigning...</>
|
||||||
|
) : (
|
||||||
|
<><ArrowRight className="mr-2 h-4 w-4" />Assign to First Round</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleStatusChange('VOTING_OPEN')}
|
||||||
|
disabled={updateStatus.isPending}
|
||||||
|
>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Open Voting
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{award.status === 'VOTING_OPEN' && (
|
{award.status === 'VOTING_OPEN' && (
|
||||||
<Button
|
<Button
|
||||||
@@ -570,7 +865,7 @@ export default function AwardDetailPage({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
|
||||||
<p className="text-2xl font-bold tabular-nums">{award._count.eligibilities}</p>
|
<p className="text-2xl font-bold tabular-nums">{(award as any).totalAssessed ?? award._count.eligibilities}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
|
||||||
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
@@ -619,6 +914,10 @@ export default function AwardDetailPage({
|
|||||||
<Users className="mr-2 h-4 w-4" />
|
<Users className="mr-2 h-4 w-4" />
|
||||||
Jurors ({award._count.jurors})
|
Jurors ({award._count.jurors})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="rounds">
|
||||||
|
<Layers className="mr-2 h-4 w-4" />
|
||||||
|
Rounds {awardRounds ? `(${awardRounds.length})` : ''}
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="results">
|
<TabsTrigger value="results">
|
||||||
<BarChart3 className="mr-2 h-4 w-4" />
|
<BarChart3 className="mr-2 h-4 w-4" />
|
||||||
Results
|
Results
|
||||||
@@ -629,7 +928,7 @@ export default function AwardDetailPage({
|
|||||||
<TabsContent value="eligibility" className="space-y-4">
|
<TabsContent value="eligibility" className="space-y-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{award.eligibleCount} of {award._count.eligibilities} projects
|
{award.eligibleCount} of {(award as any).totalAssessed ?? award._count.eligibilities} projects
|
||||||
eligible
|
eligible
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -724,7 +1023,7 @@ export default function AwardDetailPage({
|
|||||||
'-'
|
'-'
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">{project.country || '-'}</TableCell>
|
<TableCell className="text-sm">{project.country ? <CountryDisplay country={project.country} /> : '-'}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -890,7 +1189,7 @@ export default function AwardDetailPage({
|
|||||||
'-'
|
'-'
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{e.project.country || '-'}</TableCell>
|
<TableCell>{e.project.country ? <CountryDisplay country={e.project.country} /> : '-'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs gap-1">
|
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs gap-1">
|
||||||
{e.method === 'MANUAL' ? 'Manual' : <><Bot className="h-3 w-3" />AI Assessed</>}
|
{e.method === 'MANUAL' ? 'Manual' : <><Bot className="h-3 w-3" />AI Assessed</>}
|
||||||
@@ -1037,7 +1336,7 @@ export default function AwardDetailPage({
|
|||||||
<TableRow key={j.id}>
|
<TableRow key={j.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<UserAvatar user={j.user} size="sm" />
|
<UserAvatar user={j.user} avatarUrl={j.user.avatarUrl} size="sm" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{j.user.name || 'Unnamed'}
|
{j.user.name || 'Unnamed'}
|
||||||
@@ -1083,6 +1382,113 @@ export default function AwardDetailPage({
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Rounds Tab */}
|
||||||
|
<TabsContent value="rounds" className="space-y-4">
|
||||||
|
{award.eligibilityMode !== 'SEPARATE_POOL' && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
|
||||||
|
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm">
|
||||||
|
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!award.competitionId && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
|
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm">
|
||||||
|
Link this award to a competition first before creating rounds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Award Rounds ({awardRounds?.length ?? 0})</h2>
|
||||||
|
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline" disabled={!award.competitionId}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add Round
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Award Round</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a new round to the "{award.name}" award evaluation track.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="round-name">Round Name</Label>
|
||||||
|
<Input
|
||||||
|
id="round-name"
|
||||||
|
placeholder="e.g. Award Evaluation"
|
||||||
|
value={roundForm.name}
|
||||||
|
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="round-type">Round Type</Label>
|
||||||
|
<Select
|
||||||
|
value={roundForm.roundType}
|
||||||
|
onValueChange={(v) => setRoundForm({ ...roundForm, roundType: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="round-type">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="EVALUATION">Evaluation</SelectItem>
|
||||||
|
<SelectItem value="FILTERING">Filtering</SelectItem>
|
||||||
|
<SelectItem value="SUBMISSION">Submission</SelectItem>
|
||||||
|
<SelectItem value="MENTORING">Mentoring</SelectItem>
|
||||||
|
<SelectItem value="LIVE_FINAL">Live Final</SelectItem>
|
||||||
|
<SelectItem value="DELIBERATION">Deliberation</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setAddRoundOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => createRound.mutate({
|
||||||
|
awardId,
|
||||||
|
name: roundForm.name.trim(),
|
||||||
|
roundType: roundForm.roundType as any,
|
||||||
|
})}
|
||||||
|
disabled={!roundForm.name.trim() || createRound.isPending}
|
||||||
|
>
|
||||||
|
{createRound.isPending ? (
|
||||||
|
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Creating...</>
|
||||||
|
) : 'Create Round'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!awardRounds ? (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-32 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : awardRounds.length === 0 ? (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No rounds yet. Create your first award round to build an evaluation track.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<RoundsDndGrid
|
||||||
|
rounds={awardRounds}
|
||||||
|
awardId={awardId}
|
||||||
|
onReorder={(roundIds) => reorderRounds.mutate({ awardId, roundIds })}
|
||||||
|
onDelete={(roundId) => deleteRound.mutate({ roundId })}
|
||||||
|
isDeleting={deleteRound.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* Results Tab */}
|
{/* Results Tab */}
|
||||||
<TabsContent value="results" className="space-y-4">
|
<TabsContent value="results" className="space-y-4">
|
||||||
{voteResults && voteResults.results.length > 0 ? (() => {
|
{voteResults && voteResults.results.length > 0 ? (() => {
|
||||||
|
|||||||
@@ -69,11 +69,9 @@ export default function CreateAwardPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/awards">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Awards
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ const SCORING_LABELS: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AwardsListPage() {
|
export default function AwardsListPage() {
|
||||||
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({})
|
const { data: awards, isLoading } = trpc.specialAward.list.useQuery(
|
||||||
|
{},
|
||||||
|
{ refetchInterval: 30_000 }
|
||||||
|
)
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const debouncedSearch = useDebounce(search, 300)
|
const debouncedSearch = useDebounce(search, 300)
|
||||||
@@ -168,7 +171,7 @@ export default function AwardsListPage() {
|
|||||||
{award.name}
|
{award.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
||||||
{award.status.replace('_', ' ')}
|
{award.status.replace(/_/g, ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{award.description && (
|
{award.description && (
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
|
||||||
import { ArrowLeft, PlayCircle } from 'lucide-react'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
|
||||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
|
|
||||||
export default function AssignmentsDashboardPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const competitionId = params.competitionId as string
|
|
||||||
|
|
||||||
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
|
|
||||||
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
|
|
||||||
|
|
||||||
const { data: competition, isLoading: isLoadingCompetition } = trpc.competition.getById.useQuery({
|
|
||||||
id: competitionId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: unassignedQueue, isLoading: isLoadingQueue } =
|
|
||||||
trpc.roundAssignment.unassignedQueue.useQuery(
|
|
||||||
{ roundId: selectedRoundId, requiredReviews: 3 },
|
|
||||||
{ enabled: !!selectedRoundId }
|
|
||||||
)
|
|
||||||
|
|
||||||
const rounds = competition?.rounds || []
|
|
||||||
const currentRound = rounds.find((r) => r.id === selectedRoundId)
|
|
||||||
|
|
||||||
if (isLoadingCompetition) {
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<Skeleton className="h-96 w-full" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!competition) {
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
||||||
<p>Competition not found</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
||||||
<Button variant="ghost" onClick={() => router.back()} className="mb-4" aria-label="Back to competition details">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Competition
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Assignment Dashboard</h1>
|
|
||||||
<p className="text-muted-foreground">Manage jury assignments for rounds</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Select Round</CardTitle>
|
|
||||||
<CardDescription>Choose a round to view and manage assignments</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Select value={selectedRoundId} onValueChange={setSelectedRoundId}>
|
|
||||||
<SelectTrigger className="w-full sm:w-[300px]">
|
|
||||||
<SelectValue placeholder="Select a round..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{rounds.length === 0 ? (
|
|
||||||
<div className="px-2 py-1 text-sm text-muted-foreground">No rounds available</div>
|
|
||||||
) : (
|
|
||||||
rounds.map((round) => (
|
|
||||||
<SelectItem key={round.id} value={round.id}>
|
|
||||||
{round.name} ({round.roundType})
|
|
||||||
</SelectItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{selectedRoundId && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={() => setPreviewSheetOpen(true)}>
|
|
||||||
<PlayCircle className="mr-2 h-4 w-4" />
|
|
||||||
Generate Assignments
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="coverage" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="coverage">Coverage Report</TabsTrigger>
|
|
||||||
<TabsTrigger value="unassigned">Unassigned Queue</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="coverage" className="mt-6">
|
|
||||||
<CoverageReport roundId={selectedRoundId} />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="unassigned" className="mt-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Unassigned Projects</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Projects with fewer than 3 assignments
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoadingQueue ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<Skeleton key={i} className="h-16 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : unassignedQueue && unassignedQueue.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{unassignedQueue.map((project: any) => (
|
|
||||||
<div
|
|
||||||
key={project.id}
|
|
||||||
className="flex justify-between items-center p-3 border rounded-md"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{project.title}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{project.competitionCategory || 'No category'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{project.assignmentCount || 0} / 3 assignments
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
All projects have sufficient assignments
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<AssignmentPreviewSheet
|
|
||||||
roundId={selectedRoundId}
|
|
||||||
open={previewSheetOpen}
|
|
||||||
onOpenChange={setPreviewSheetOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { use } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { trpc } from '@/lib/trpc/client';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
import type { Route } from 'next';
|
|
||||||
|
|
||||||
export default function AwardDetailPage({
|
|
||||||
params: paramsPromise
|
|
||||||
}: {
|
|
||||||
params: Promise<{ competitionId: string; awardId: string }>;
|
|
||||||
}) {
|
|
||||||
const params = use(paramsPromise);
|
|
||||||
const router = useRouter();
|
|
||||||
const { data: award, isLoading } = trpc.specialAward.get.useQuery({
|
|
||||||
id: params.awardId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Loading...</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!award) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Award Not Found</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() =>
|
|
||||||
router.push(`/admin/competitions/${params.competitionId}/awards` as Route)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h1 className="text-3xl font-bold">{award.name}</h1>
|
|
||||||
<p className="text-muted-foreground">{award.description || 'No description'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="overview" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
||||||
<TabsTrigger value="eligible">Eligible Projects</TabsTrigger>
|
|
||||||
<TabsTrigger value="winners">Winners</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="overview" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Award Information</CardTitle>
|
|
||||||
<CardDescription>Configuration and settings</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Scoring Mode</p>
|
|
||||||
<Badge variant="outline" className="mt-1">
|
|
||||||
{award.scoringMode}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">AI Eligibility</p>
|
|
||||||
<Badge variant="outline" className="mt-1">
|
|
||||||
{award.useAiEligibility ? 'Enabled' : 'Disabled'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Status</p>
|
|
||||||
<Badge variant={award.status === 'DRAFT' ? 'secondary' : 'default'} className="mt-1">
|
|
||||||
{award.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Program</p>
|
|
||||||
<p className="mt-1 text-sm">{award.program?.name}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="eligible" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Eligible Projects</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Projects that qualify for this award ({award?.eligibleCount || 0})
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-center text-muted-foreground">
|
|
||||||
{award?.eligibleCount || 0} eligible projects
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="winners" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Award Winners</CardTitle>
|
|
||||||
<CardDescription>Selected winners for this award</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{award?.winnerProject ? (
|
|
||||||
<div className="rounded-lg border p-4">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{award.winnerProject.title}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{award.winnerProject.teamName}</p>
|
|
||||||
</div>
|
|
||||||
<Badge className="mt-2">Winner</Badge>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-center text-muted-foreground">No winner selected yet</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { use, useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { trpc } from '@/lib/trpc/client';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import type { Route } from 'next';
|
|
||||||
|
|
||||||
export default function NewAwardPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
|
|
||||||
const params = use(paramsPromise);
|
|
||||||
const router = useRouter();
|
|
||||||
const utils = trpc.useUtils();
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
useAiEligibility: false,
|
|
||||||
scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED'
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: competition } = trpc.competition.getById.useQuery({
|
|
||||||
id: params.competitionId
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: juryGroups } = trpc.juryGroup.list.useQuery({
|
|
||||||
competitionId: params.competitionId
|
|
||||||
});
|
|
||||||
|
|
||||||
const createMutation = trpc.specialAward.create.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.specialAward.list.invalidate();
|
|
||||||
toast.success('Award created successfully');
|
|
||||||
router.push(`/admin/competitions/${params.competitionId}/awards` as Route);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!formData.name.trim()) {
|
|
||||||
toast.error('Award name is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!competition?.programId) {
|
|
||||||
toast.error('Competition data not loaded');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
createMutation.mutate({
|
|
||||||
programId: competition.programId,
|
|
||||||
name: formData.name.trim(),
|
|
||||||
description: formData.description.trim() || undefined,
|
|
||||||
scoringMode: formData.scoringMode,
|
|
||||||
useAiEligibility: formData.useAiEligibility
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => router.push(`/admin/competitions/${params.competitionId}/awards` as Route)}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Create Special Award</h1>
|
|
||||||
<p className="text-muted-foreground">Define a new award for this competition</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Award Details</CardTitle>
|
|
||||||
<CardDescription>Configure the award properties and eligibility</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Award Name *</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
placeholder="e.g., Best Innovation Award"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
||||||
placeholder="Describe the award criteria and purpose"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="scoringMode">Scoring Mode</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.scoringMode}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData({ ...formData, scoringMode: value as 'PICK_WINNER' | 'RANKED' | 'SCORED' })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="scoringMode">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
|
|
||||||
<SelectItem value="RANKED">Ranked</SelectItem>
|
|
||||||
<SelectItem value="SCORED">Scored</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="useAiEligibility"
|
|
||||||
checked={formData.useAiEligibility}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setFormData({ ...formData, useAiEligibility: checked as boolean })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="useAiEligibility" className="font-normal">
|
|
||||||
Use AI-based eligibility assessment
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push(`/admin/competitions/${params.competitionId}/awards` as Route)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={createMutation.isPending}>
|
|
||||||
{createMutation.isPending ? 'Creating...' : 'Create Award'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { use } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { trpc } from '@/lib/trpc/client';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ArrowLeft, Plus } from 'lucide-react';
|
|
||||||
import type { Route } from 'next';
|
|
||||||
|
|
||||||
export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
|
|
||||||
const params = use(paramsPromise);
|
|
||||||
const router = useRouter();
|
|
||||||
const { data: competition } = trpc.competition.getById.useQuery({
|
|
||||||
id: params.competitionId
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({
|
|
||||||
programId: competition?.programId
|
|
||||||
}, {
|
|
||||||
enabled: !!competition?.programId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Special Awards</h1>
|
|
||||||
<p className="text-muted-foreground">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Special Awards</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage special awards and prizes for this competition
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link href={`/admin/competitions/${params.competitionId}/awards/new` as Route}>
|
|
||||||
<Button>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Create Award
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{awards?.map((award) => (
|
|
||||||
<Link
|
|
||||||
key={award.id}
|
|
||||||
href={`/admin/competitions/${params.competitionId}/awards/${award.id}` as Route}
|
|
||||||
>
|
|
||||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-start justify-between">
|
|
||||||
<span className="line-clamp-1">{award.name}</span>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="line-clamp-2">
|
|
||||||
{award.description || 'No description'}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Badge variant="outline">{award.scoringMode}</Badge>
|
|
||||||
<Badge variant={award.status === 'DRAFT' ? 'secondary' : 'default'}>
|
|
||||||
{award.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{awards?.length === 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<p className="text-muted-foreground">No awards created yet</p>
|
|
||||||
<Link href={`/admin/competitions/${params.competitionId}/awards/new` as Route}>
|
|
||||||
<Button variant="link">Create your first award</Button>
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { use } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { trpc } from '@/lib/trpc/client';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { ResultsPanel } from '@/components/admin/deliberation/results-panel';
|
|
||||||
import type { Route } from 'next';
|
|
||||||
|
|
||||||
export default function DeliberationSessionPage({
|
|
||||||
params: paramsPromise
|
|
||||||
}: {
|
|
||||||
params: Promise<{ competitionId: string; sessionId: string }>;
|
|
||||||
}) {
|
|
||||||
const params = use(paramsPromise);
|
|
||||||
const router = useRouter();
|
|
||||||
const utils = trpc.useUtils();
|
|
||||||
|
|
||||||
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery({
|
|
||||||
sessionId: params.sessionId
|
|
||||||
});
|
|
||||||
|
|
||||||
const openVotingMutation = trpc.deliberation.openVoting.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.deliberation.getSession.invalidate();
|
|
||||||
toast.success('Voting opened');
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const closeVotingMutation = trpc.deliberation.closeVoting.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.deliberation.getSession.invalidate();
|
|
||||||
toast.success('Voting closed');
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Loading...</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Session Not Found</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() =>
|
|
||||||
router.push(`/admin/competitions/${params.competitionId}/deliberation` as Route)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className="text-3xl font-bold">Deliberation Session</h1>
|
|
||||||
<Badge>{session.status}</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{session.round?.name} - {session.category}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="setup" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
|
||||||
<TabsTrigger value="setup">Setup</TabsTrigger>
|
|
||||||
<TabsTrigger value="voting">Voting Control</TabsTrigger>
|
|
||||||
<TabsTrigger value="results">Results</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="setup" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Session Configuration</CardTitle>
|
|
||||||
<CardDescription>Deliberation settings and participants</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Mode</p>
|
|
||||||
<p className="mt-1">
|
|
||||||
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Tie Break Method</p>
|
|
||||||
<p className="mt-1">{session.tieBreakMethod}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
|
||||||
Show Collective Rankings
|
|
||||||
</p>
|
|
||||||
<p className="mt-1">{session.showCollectiveRankings ? 'Yes' : 'No'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Show Prior Jury Data</p>
|
|
||||||
<p className="mt-1">{session.showPriorJuryData ? 'Yes' : 'No'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Participants ({session.participants?.length || 0})</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{session.participants?.map((participant: any) => (
|
|
||||||
<div
|
|
||||||
key={participant.id}
|
|
||||||
className="flex items-center justify-between rounded-lg border p-3"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{participant.user?.name}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{participant.user?.email}</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant={participant.hasVoted ? 'default' : 'outline'}>
|
|
||||||
{participant.hasVoted ? 'Voted' : 'Pending'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="voting" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Voting Controls</CardTitle>
|
|
||||||
<CardDescription>Manage the voting window for jury members</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
|
||||||
<Button
|
|
||||||
onClick={() => openVotingMutation.mutate({ sessionId: params.sessionId })}
|
|
||||||
disabled={
|
|
||||||
openVotingMutation.isPending || session.status !== 'DELIB_OPEN'
|
|
||||||
}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Open Voting
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => closeVotingMutation.mutate({ sessionId: params.sessionId })}
|
|
||||||
disabled={
|
|
||||||
closeVotingMutation.isPending || session.status !== 'DELIB_VOTING'
|
|
||||||
}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Close Voting
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Voting Status</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{session.participants?.map((participant: any) => (
|
|
||||||
<div
|
|
||||||
key={participant.id}
|
|
||||||
className="flex items-center justify-between rounded-lg border p-3"
|
|
||||||
>
|
|
||||||
<span>{participant.user?.name}</span>
|
|
||||||
<Badge variant={participant.hasVoted ? 'default' : 'secondary'}>
|
|
||||||
{participant.hasVoted ? 'Submitted' : 'Not Voted'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="results" className="space-y-4">
|
|
||||||
<ResultsPanel sessionId={params.sessionId} />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { use, useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { trpc } from '@/lib/trpc/client';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
import { ArrowLeft, Plus } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import type { Route } from 'next';
|
|
||||||
|
|
||||||
export default function DeliberationListPage({
|
|
||||||
params: paramsPromise
|
|
||||||
}: {
|
|
||||||
params: Promise<{ competitionId: string }>;
|
|
||||||
}) {
|
|
||||||
const params = use(paramsPromise);
|
|
||||||
const router = useRouter();
|
|
||||||
const utils = trpc.useUtils();
|
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
roundId: '',
|
|
||||||
category: 'STARTUP' as 'STARTUP' | 'BUSINESS_CONCEPT',
|
|
||||||
mode: 'SINGLE_WINNER_VOTE' as 'SINGLE_WINNER_VOTE' | 'FULL_RANKING',
|
|
||||||
tieBreakMethod: 'TIE_RUNOFF' as 'TIE_RUNOFF' | 'TIE_ADMIN_DECIDES' | 'SCORE_FALLBACK',
|
|
||||||
showCollectiveRankings: false,
|
|
||||||
showPriorJuryData: false,
|
|
||||||
participantUserIds: [] as string[]
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: sessions = [], isLoading } = trpc.deliberation.listSessions.useQuery(
|
|
||||||
{ competitionId: params.competitionId },
|
|
||||||
{ enabled: !!params.competitionId }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get rounds for this competition
|
|
||||||
const { data: competition } = trpc.competition.getById.useQuery(
|
|
||||||
{ id: params.competitionId },
|
|
||||||
{ enabled: !!params.competitionId }
|
|
||||||
);
|
|
||||||
const rounds = competition?.rounds || [];
|
|
||||||
|
|
||||||
// TODO: Add getJuryMembers endpoint if needed for participant selection
|
|
||||||
const juryMembers: any[] = [];
|
|
||||||
|
|
||||||
const createSessionMutation = trpc.deliberation.createSession.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
utils.deliberation.listSessions.invalidate({ competitionId: params.competitionId });
|
|
||||||
toast.success('Deliberation session created');
|
|
||||||
setCreateDialogOpen(false);
|
|
||||||
router.push(
|
|
||||||
`/admin/competitions/${params.competitionId}/deliberation/${data.id}` as Route
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCreateSession = () => {
|
|
||||||
if (!formData.roundId) {
|
|
||||||
toast.error('Please select a round');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
createSessionMutation.mutate({
|
|
||||||
competitionId: params.competitionId,
|
|
||||||
roundId: formData.roundId,
|
|
||||||
category: formData.category,
|
|
||||||
mode: formData.mode,
|
|
||||||
tieBreakMethod: formData.tieBreakMethod,
|
|
||||||
showCollectiveRankings: formData.showCollectiveRankings,
|
|
||||||
showPriorJuryData: formData.showPriorJuryData,
|
|
||||||
participantUserIds: formData.participantUserIds
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
|
||||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
||||||
DELIB_OPEN: 'outline',
|
|
||||||
DELIB_VOTING: 'default',
|
|
||||||
DELIB_TALLYING: 'secondary',
|
|
||||||
DELIB_LOCKED: 'destructive'
|
|
||||||
};
|
|
||||||
return <Badge variant={variants[status] || 'outline'}>{status}</Badge>;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 p-4 sm:p-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Skeleton className="h-8 w-8 shrink-0" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-8 w-64" />
|
|
||||||
<Skeleton className="h-4 w-96 max-w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<Skeleton key={i} className="h-32 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 p-4 sm:p-6">
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Back to competition details">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Deliberation Sessions</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage final jury deliberations and winner selection
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
New Session
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
|
||||||
{sessions?.map((session: any) => (
|
|
||||||
<Link
|
|
||||||
key={session.id}
|
|
||||||
href={
|
|
||||||
`/admin/competitions/${params.competitionId}/deliberation/${session.id}` as Route
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle>
|
|
||||||
{session.round?.name} - {session.category}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="mt-1">
|
|
||||||
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
{getStatusBadge(session.status)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>{session.participants?.length || 0} participants</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Tie break: {session.tieBreakMethod}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sessions?.length === 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<p className="text-muted-foreground">No deliberation sessions yet</p>
|
|
||||||
<Button variant="link" onClick={() => setCreateDialogOpen(true)}>
|
|
||||||
Create your first session
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create Session Dialog */}
|
|
||||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create Deliberation Session</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Set up a new deliberation session for final winner selection
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="round">Round *</Label>
|
|
||||||
<Select value={formData.roundId} onValueChange={(value) => setFormData({ ...formData, roundId: value })}>
|
|
||||||
<SelectTrigger id="round">
|
|
||||||
<SelectValue placeholder="Select round" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{rounds?.map((round: any) => (
|
|
||||||
<SelectItem key={round.id} value={round.id}>
|
|
||||||
{round.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="category">Category *</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.category}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData({ ...formData, category: value as 'STARTUP' | 'BUSINESS_CONCEPT' })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="category">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="STARTUP">Startup</SelectItem>
|
|
||||||
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="mode">Voting Mode *</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.mode}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
mode: value as 'SINGLE_WINNER_VOTE' | 'FULL_RANKING'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="mode">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="SINGLE_WINNER_VOTE">Single Winner Vote</SelectItem>
|
|
||||||
<SelectItem value="FULL_RANKING">Full Ranking</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="tieBreak">Tie Break Method *</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.tieBreakMethod}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
tieBreakMethod: value as 'TIE_RUNOFF' | 'TIE_ADMIN_DECIDES' | 'SCORE_FALLBACK'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="tieBreak">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="TIE_RUNOFF">Runoff Vote</SelectItem>
|
|
||||||
<SelectItem value="TIE_ADMIN_DECIDES">Admin Decides</SelectItem>
|
|
||||||
<SelectItem value="SCORE_FALLBACK">Score Fallback</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="showCollective"
|
|
||||||
checked={formData.showCollectiveRankings}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setFormData({ ...formData, showCollectiveRankings: checked as boolean })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="showCollective" className="font-normal">
|
|
||||||
Show collective rankings during voting
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="showPrior"
|
|
||||||
checked={formData.showPriorJuryData}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setFormData({ ...formData, showPriorJuryData: checked as boolean })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="showPrior" className="font-normal">
|
|
||||||
Show prior jury evaluation data
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreateSession} disabled={createSessionMutation.isPending}>
|
|
||||||
{createSessionMutation.isPending ? 'Creating...' : 'Create Session'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
|
||||||
import { ArrowLeft } from 'lucide-react'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { JuryMembersTable } from '@/components/admin/jury/jury-members-table'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
|
|
||||||
export default function JuryGroupDetailPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const juryGroupId = params.juryGroupId as string
|
|
||||||
|
|
||||||
const { data: juryGroup, isLoading } = trpc.juryGroup.getById.useQuery({ id: juryGroupId })
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<Skeleton className="h-96 w-full" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!juryGroup) {
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
||||||
<p>Jury group not found</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
||||||
<Button variant="ghost" onClick={() => router.back()} className="mb-4" aria-label="Back to jury groups list">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Juries
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">{juryGroup.name}</h1>
|
|
||||||
<p className="text-muted-foreground">{juryGroup.slug}</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary" className="text-lg px-4 py-2">
|
|
||||||
{juryGroup.defaultCapMode}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="members" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="members">Members</TabsTrigger>
|
|
||||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="members" className="mt-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Jury Members</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Manage the members of this jury group
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<JuryMembersTable
|
|
||||||
juryGroupId={juryGroupId}
|
|
||||||
members={juryGroup.members}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="settings" className="mt-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Jury Group Settings</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
View and edit settings for this jury group
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Name</h3>
|
|
||||||
<p className="text-base font-medium">{juryGroup.name}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Slug</h3>
|
|
||||||
<p className="text-base font-medium">{juryGroup.slug}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Default Max Assignments</h3>
|
|
||||||
<p className="text-base font-medium">{juryGroup.defaultMaxAssignments}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Default Cap Mode</h3>
|
|
||||||
<Badge variant="secondary">{juryGroup.defaultCapMode}</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Soft Cap Buffer</h3>
|
|
||||||
<p className="text-base font-medium">{juryGroup.softCapBuffer}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Allow Juror Cap Adjustment</h3>
|
|
||||||
<Badge variant={juryGroup.allowJurorCapAdjustment ? 'default' : 'secondary'}>
|
|
||||||
{juryGroup.allowJurorCapAdjustment ? 'Yes' : 'No'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Allow Ratio Adjustment</h3>
|
|
||||||
<Badge variant={juryGroup.allowJurorRatioAdjustment ? 'default' : 'secondary'}>
|
|
||||||
{juryGroup.allowJurorRatioAdjustment ? 'Yes' : 'No'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Category Quotas Enabled</h3>
|
|
||||||
<Badge variant={juryGroup.categoryQuotasEnabled ? 'default' : 'secondary'}>
|
|
||||||
{juryGroup.categoryQuotasEnabled ? 'Yes' : 'No'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{juryGroup.description && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">Description</h3>
|
|
||||||
<p className="text-base">{juryGroup.description}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { ArrowLeft, Plus, Users } from 'lucide-react'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
export default function JuriesListPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const competitionId = params.competitionId as string
|
|
||||||
const utils = trpc.useUtils()
|
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: juryGroups, isLoading } = trpc.juryGroup.list.useQuery({ competitionId })
|
|
||||||
|
|
||||||
const createMutation = trpc.juryGroup.create.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.juryGroup.list.invalidate({ competitionId })
|
|
||||||
toast.success('Jury group created')
|
|
||||||
setCreateOpen(false)
|
|
||||||
setFormData({ name: '', description: '' })
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleCreate = () => {
|
|
||||||
if (!formData.name.trim()) {
|
|
||||||
toast.error('Name is required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const slug = formData.name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-|-$/g, '')
|
|
||||||
createMutation.mutate({
|
|
||||||
competitionId,
|
|
||||||
name: formData.name.trim(),
|
|
||||||
slug,
|
|
||||||
description: formData.description.trim() || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<Skeleton key={i} className="h-40" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
className="mb-4"
|
|
||||||
aria-label="Back to competition details"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Jury Groups</h1>
|
|
||||||
<p className="text-muted-foreground">Manage jury groups and members for this competition</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => setCreateOpen(true)}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Create Jury Group
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{juryGroups && juryGroups.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<Users className="h-12 w-12 text-muted-foreground mb-4" />
|
|
||||||
<p className="text-muted-foreground text-center">No jury groups yet. Create one to get started.</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{juryGroups?.map((group) => (
|
|
||||||
<Link
|
|
||||||
key={group.id}
|
|
||||||
href={`/admin/competitions/${competitionId}/juries/${group.id}` as Route}
|
|
||||||
>
|
|
||||||
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
{group.name}
|
|
||||||
<Badge variant="secondary">{group.defaultCapMode}</Badge>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>{group.slug}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Members</span>
|
|
||||||
<span className="font-medium">{group._count.members}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Assignments</span>
|
|
||||||
<span className="font-medium">{group._count.assignments || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Default Max</span>
|
|
||||||
<span className="font-medium">{group.defaultMaxAssignments}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create Jury Group Dialog */}
|
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create Jury Group</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Create a new jury group for this competition. You can add members after creation.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="jury-name">Name *</Label>
|
|
||||||
<Input
|
|
||||||
id="jury-name"
|
|
||||||
placeholder="e.g. Main Jury Panel"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="jury-description">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="jury-description"
|
|
||||||
placeholder="Optional description of this jury group's role"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreate} disabled={createMutation.isPending}>
|
|
||||||
{createMutation.isPending ? 'Creating...' : 'Create'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { use } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
import { LiveControlPanel } from '@/components/admin/live/live-control-panel';
|
|
||||||
import type { Route } from 'next';
|
|
||||||
|
|
||||||
export default function LiveFinalsPage({
|
|
||||||
params: paramsPromise
|
|
||||||
}: {
|
|
||||||
params: Promise<{ competitionId: string; roundId: string }>;
|
|
||||||
}) {
|
|
||||||
const params = use(paramsPromise);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => router.push(`/admin/competitions/${params.competitionId}` as Route)}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Live Finals Control</h1>
|
|
||||||
<p className="text-muted-foreground">Manage live ceremony presentation and voting</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LiveControlPanel roundId={params.roundId} competitionId={params.competitionId} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,531 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
ChevronDown,
|
|
||||||
Layers,
|
|
||||||
Users,
|
|
||||||
FileBox,
|
|
||||||
ClipboardList,
|
|
||||||
Settings,
|
|
||||||
MoreHorizontal,
|
|
||||||
Archive,
|
|
||||||
Loader2,
|
|
||||||
Plus,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
|
|
||||||
|
|
||||||
const ROUND_TYPES = [
|
|
||||||
{ value: 'INTAKE', label: 'Intake' },
|
|
||||||
{ value: 'FILTERING', label: 'Filtering' },
|
|
||||||
{ value: 'EVALUATION', label: 'Evaluation' },
|
|
||||||
{ value: 'SUBMISSION', label: 'Submission' },
|
|
||||||
{ value: 'MENTORING', label: 'Mentoring' },
|
|
||||||
{ value: 'LIVE_FINAL', label: 'Live Final' },
|
|
||||||
{ value: 'DELIBERATION', label: 'Deliberation' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const statusConfig = {
|
|
||||||
DRAFT: {
|
|
||||||
label: 'Draft',
|
|
||||||
bgClass: 'bg-gray-100 text-gray-700',
|
|
||||||
dotClass: 'bg-gray-500',
|
|
||||||
},
|
|
||||||
ACTIVE: {
|
|
||||||
label: 'Active',
|
|
||||||
bgClass: 'bg-emerald-100 text-emerald-700',
|
|
||||||
dotClass: 'bg-emerald-500',
|
|
||||||
},
|
|
||||||
CLOSED: {
|
|
||||||
label: 'Closed',
|
|
||||||
bgClass: 'bg-blue-100 text-blue-700',
|
|
||||||
dotClass: 'bg-blue-500',
|
|
||||||
},
|
|
||||||
ARCHIVED: {
|
|
||||||
label: 'Archived',
|
|
||||||
bgClass: 'bg-muted text-muted-foreground',
|
|
||||||
dotClass: 'bg-muted-foreground',
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const roundTypeColors: Record<string, string> = {
|
|
||||||
INTAKE: 'bg-gray-100 text-gray-700',
|
|
||||||
FILTERING: 'bg-amber-100 text-amber-700',
|
|
||||||
EVALUATION: 'bg-blue-100 text-blue-700',
|
|
||||||
SUBMISSION: 'bg-purple-100 text-purple-700',
|
|
||||||
MENTORING: 'bg-teal-100 text-teal-700',
|
|
||||||
LIVE_FINAL: 'bg-red-100 text-red-700',
|
|
||||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CompetitionDetailPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const competitionId = params.competitionId as string
|
|
||||||
const utils = trpc.useUtils()
|
|
||||||
const [addRoundOpen, setAddRoundOpen] = useState(false)
|
|
||||||
const [roundForm, setRoundForm] = useState({
|
|
||||||
name: '',
|
|
||||||
roundType: '' as string,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
|
|
||||||
id: competitionId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateMutation = trpc.competition.update.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.competition.getById.invalidate({ id: competitionId })
|
|
||||||
toast.success('Competition updated')
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const createRoundMutation = trpc.round.create.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.competition.getById.invalidate({ id: competitionId })
|
|
||||||
toast.success('Round created')
|
|
||||||
setAddRoundOpen(false)
|
|
||||||
setRoundForm({ name: '', roundType: '' })
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleStatusChange = (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => {
|
|
||||||
updateMutation.mutate({ id: competitionId, status: newStatus })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateRound = () => {
|
|
||||||
if (!roundForm.name.trim() || !roundForm.roundType) {
|
|
||||||
toast.error('Name and type are required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const slug = roundForm.name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-|-$/g, '')
|
|
||||||
const nextOrder = competition?.rounds.length ?? 0
|
|
||||||
createRoundMutation.mutate({
|
|
||||||
competitionId,
|
|
||||||
name: roundForm.name.trim(),
|
|
||||||
slug,
|
|
||||||
roundType: roundForm.roundType as any,
|
|
||||||
sortOrder: nextOrder,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Skeleton className="h-8 w-8" />
|
|
||||||
<div>
|
|
||||||
<Skeleton className="h-6 w-48" />
|
|
||||||
<Skeleton className="h-4 w-32 mt-1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!competition) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link href={"/admin/competitions" as Route}>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competitions list">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Competition Not Found</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
The requested competition does not exist
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = competition.status as keyof typeof statusConfig
|
|
||||||
const config = statusConfig[status] || statusConfig.DRAFT
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
|
||||||
<Link href={"/admin/competitions" as Route} className="mt-1 shrink-0">
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competitions list">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<h1 className="text-xl font-bold truncate">{competition.name}</h1>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors shrink-0',
|
|
||||||
config.bgClass,
|
|
||||||
'hover:opacity-80'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{config.label}
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
{(['DRAFT', 'ACTIVE', 'CLOSED'] as const).map((s) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={s}
|
|
||||||
onClick={() => handleStatusChange(s)}
|
|
||||||
disabled={competition.status === s || updateMutation.isPending}
|
|
||||||
>
|
|
||||||
{s.charAt(0) + s.slice(1).toLowerCase()}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleStatusChange('ARCHIVED')}
|
|
||||||
disabled={competition.status === 'ARCHIVED' || updateMutation.isPending}
|
|
||||||
>
|
|
||||||
<Archive className="h-4 w-4 mr-2" />
|
|
||||||
Archive
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground font-mono">{competition.slug}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" className="h-8 w-8" aria-label="More actions">
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
|
|
||||||
<ClipboardList className="h-4 w-4 mr-2" />
|
|
||||||
Assignments
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`/admin/competitions/${competitionId}/deliberation` as Route}>
|
|
||||||
<Users className="h-4 w-4 mr-2" />
|
|
||||||
Deliberation
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleStatusChange('ARCHIVED')}
|
|
||||||
disabled={updateMutation.isPending}
|
|
||||||
>
|
|
||||||
{updateMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Archive className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Archive
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Cards */}
|
|
||||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-4 pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Layers className="h-4 w-4 text-blue-500" />
|
|
||||||
<span className="text-sm font-medium">Rounds</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold mt-1">{competition.rounds.length}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-4 pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Users className="h-4 w-4 text-purple-500" />
|
|
||||||
<span className="text-sm font-medium">Juries</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold mt-1">{competition.juryGroups.length}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-4 pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FileBox className="h-4 w-4 text-emerald-500" />
|
|
||||||
<span className="text-sm font-medium">Windows</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold mt-1">{competition.submissionWindows.length}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-4 pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Settings className="h-4 w-4 text-amber-500" />
|
|
||||||
<span className="text-sm font-medium">Category</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-bold mt-1 truncate">{competition.categoryMode}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<Tabs defaultValue="overview" className="space-y-4">
|
|
||||||
<TabsList className="w-full sm:w-auto overflow-x-auto">
|
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
||||||
<TabsTrigger value="rounds">Rounds</TabsTrigger>
|
|
||||||
<TabsTrigger value="juries">Juries</TabsTrigger>
|
|
||||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* Overview Tab */}
|
|
||||||
<TabsContent value="overview" className="space-y-6">
|
|
||||||
<CompetitionTimeline
|
|
||||||
competitionId={competitionId}
|
|
||||||
rounds={competition.rounds}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Rounds Tab */}
|
|
||||||
<TabsContent value="rounds" className="space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<h2 className="text-lg font-semibold">Rounds ({competition.rounds.length})</h2>
|
|
||||||
<Button size="sm" variant="outline" className="w-full sm:w-auto" onClick={() => setAddRoundOpen(true)}>
|
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
|
||||||
Add Round
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{competition.rounds.length === 0 ? (
|
|
||||||
<Card className="border-dashed">
|
|
||||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
No rounds configured. Add rounds to define the competition flow.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{competition.rounds.map((round, index) => (
|
|
||||||
<Link
|
|
||||||
key={round.id}
|
|
||||||
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
|
|
||||||
>
|
|
||||||
<Card className="hover:shadow-sm transition-shadow cursor-pointer">
|
|
||||||
<CardContent className="flex items-center gap-3 py-3">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-medium truncate">{round.name}</p>
|
|
||||||
<p className="text-xs text-muted-foreground font-mono">{round.slug}</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className={cn(
|
|
||||||
'text-[10px] shrink-0',
|
|
||||||
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{round.roundType.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] shrink-0 hidden sm:inline-flex"
|
|
||||||
>
|
|
||||||
{round.status.replace('ROUND_', '')}
|
|
||||||
</Badge>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Juries Tab */}
|
|
||||||
<TabsContent value="juries" className="space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<h2 className="text-lg font-semibold">Jury Groups ({competition.juryGroups.length})</h2>
|
|
||||||
<Link href={`/admin/competitions/${competitionId}/juries` as Route}>
|
|
||||||
<Button size="sm" variant="outline" className="w-full sm:w-auto">
|
|
||||||
<Users className="h-4 w-4 mr-1" />
|
|
||||||
Manage Juries
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{competition.juryGroups.length === 0 ? (
|
|
||||||
<Card className="border-dashed">
|
|
||||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
No jury groups configured. Create jury groups to assign evaluators.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{competition.juryGroups.map((group) => (
|
|
||||||
<Link
|
|
||||||
key={group.id}
|
|
||||||
href={`/admin/competitions/${competitionId}/juries/${group.id}` as Route}
|
|
||||||
>
|
|
||||||
<Card className="hover:shadow-sm transition-shadow cursor-pointer h-full">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm">{group.name}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
||||||
<span>{group._count.members} members</span>
|
|
||||||
<Badge variant="outline" className="text-[10px]">
|
|
||||||
Cap: {group.defaultCapMode}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Settings Tab */}
|
|
||||||
<TabsContent value="settings" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Competition Settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-muted-foreground">Category Mode</label>
|
|
||||||
<p className="text-sm mt-1">{competition.categoryMode}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-muted-foreground">Startup Finalists</label>
|
|
||||||
<p className="text-sm mt-1">{competition.startupFinalistCount}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-muted-foreground">Concept Finalists</label>
|
|
||||||
<p className="text-sm mt-1">{competition.conceptFinalistCount}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-muted-foreground">Notifications</label>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-1">
|
|
||||||
{competition.notifyOnRoundAdvance && (
|
|
||||||
<Badge variant="secondary" className="text-[10px]">Round Advance</Badge>
|
|
||||||
)}
|
|
||||||
{competition.notifyOnDeadlineApproach && (
|
|
||||||
<Badge variant="secondary" className="text-[10px]">Deadline Approach</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{competition.deadlineReminderDays && (
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-muted-foreground">
|
|
||||||
Reminder Days
|
|
||||||
</label>
|
|
||||||
<p className="text-sm mt-1">
|
|
||||||
{(competition.deadlineReminderDays as number[]).join(', ')} days before deadline
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* Add Round Dialog */}
|
|
||||||
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add Round</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Add a new round to this competition. It will be appended to the current round sequence.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="round-name">Name *</Label>
|
|
||||||
<Input
|
|
||||||
id="round-name"
|
|
||||||
placeholder="e.g. Initial Screening"
|
|
||||||
value={roundForm.name}
|
|
||||||
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="round-type">Round Type *</Label>
|
|
||||||
<Select
|
|
||||||
value={roundForm.roundType}
|
|
||||||
onValueChange={(value) => setRoundForm({ ...roundForm, roundType: value })}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="round-type">
|
|
||||||
<SelectValue placeholder="Select type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{ROUND_TYPES.map((rt) => (
|
|
||||||
<SelectItem key={rt.value} value={rt.value}>
|
|
||||||
{rt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setAddRoundOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreateRound} disabled={createRoundMutation.isPending}>
|
|
||||||
{createRoundMutation.isPending ? 'Creating...' : 'Create Round'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
|
||||||
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
|
||||||
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
|
|
||||||
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
|
|
||||||
|
|
||||||
const roundTypeColors: Record<string, string> = {
|
|
||||||
INTAKE: 'bg-gray-100 text-gray-700',
|
|
||||||
FILTERING: 'bg-amber-100 text-amber-700',
|
|
||||||
EVALUATION: 'bg-blue-100 text-blue-700',
|
|
||||||
SUBMISSION: 'bg-purple-100 text-purple-700',
|
|
||||||
MENTORING: 'bg-teal-100 text-teal-700',
|
|
||||||
LIVE_FINAL: 'bg-red-100 text-red-700',
|
|
||||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RoundDetailPage() {
|
|
||||||
const params = useParams()
|
|
||||||
const competitionId = params.competitionId as string
|
|
||||||
const roundId = params.roundId as string
|
|
||||||
|
|
||||||
const [config, setConfig] = useState<Record<string, unknown>>({})
|
|
||||||
const [hasChanges, setHasChanges] = useState(false)
|
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
|
||||||
|
|
||||||
const { data: round, isLoading } = trpc.round.getById.useQuery({ id: roundId })
|
|
||||||
|
|
||||||
// Update local config when round data changes
|
|
||||||
if (round && !hasChanges) {
|
|
||||||
const roundConfig = (round.configJson as Record<string, unknown>) ?? {}
|
|
||||||
if (JSON.stringify(roundConfig) !== JSON.stringify(config)) {
|
|
||||||
setConfig(roundConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateMutation = trpc.round.update.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
utils.round.getById.invalidate({ id: roundId })
|
|
||||||
toast.success('Round configuration saved')
|
|
||||||
setHasChanges(false)
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleConfigChange = (newConfig: Record<string, unknown>) => {
|
|
||||||
setConfig(newConfig)
|
|
||||||
setHasChanges(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
updateMutation.mutate({
|
|
||||||
id: roundId,
|
|
||||||
configJson: config,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Skeleton className="h-8 w-8" />
|
|
||||||
<div>
|
|
||||||
<Skeleton className="h-6 w-48" />
|
|
||||||
<Skeleton className="h-4 w-32 mt-1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!round) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link href={`/admin/competitions/${competitionId}` as Route}>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competition details">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Round Not Found</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
The requested round does not exist
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
|
||||||
<Link href={`/admin/competitions/${competitionId}` as Route} className="mt-1 shrink-0">
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competition details">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<h1 className="text-xl font-bold truncate">{round.name}</h1>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className={roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}
|
|
||||||
>
|
|
||||||
{round.roundType.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground font-mono">{round.slug}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
{hasChanges && (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={updateMutation.isPending}
|
|
||||||
>
|
|
||||||
{updateMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<Tabs defaultValue="config" className="space-y-4">
|
|
||||||
<TabsList className="w-full sm:w-auto overflow-x-auto">
|
|
||||||
<TabsTrigger value="config">Configuration</TabsTrigger>
|
|
||||||
<TabsTrigger value="projects">Projects</TabsTrigger>
|
|
||||||
<TabsTrigger value="windows">Submission Windows</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* Config Tab */}
|
|
||||||
<TabsContent value="config" className="space-y-4">
|
|
||||||
<RoundConfigForm
|
|
||||||
roundType={round.roundType}
|
|
||||||
config={config}
|
|
||||||
onChange={handleConfigChange}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Projects Tab */}
|
|
||||||
<TabsContent value="projects" className="space-y-4">
|
|
||||||
<ProjectStatesTable competitionId={competitionId} roundId={roundId} />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Submission Windows Tab */}
|
|
||||||
<TabsContent value="windows" className="space-y-4">
|
|
||||||
<SubmissionWindowManager competitionId={competitionId} roundId={roundId} />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { SidebarStepper } from '@/components/ui/sidebar-stepper'
|
|
||||||
import type { StepConfig } from '@/components/ui/sidebar-stepper'
|
|
||||||
import { ArrowLeft } from 'lucide-react'
|
|
||||||
import { BasicsSection } from '@/components/admin/competition/sections/basics-section'
|
|
||||||
import { RoundsSection } from '@/components/admin/competition/sections/rounds-section'
|
|
||||||
import { JuryGroupsSection } from '@/components/admin/competition/sections/jury-groups-section'
|
|
||||||
import { ReviewSection } from '@/components/admin/competition/sections/review-section'
|
|
||||||
import { useEdition } from '@/contexts/edition-context'
|
|
||||||
|
|
||||||
type WizardRound = {
|
|
||||||
tempId: string
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
roundType: string
|
|
||||||
sortOrder: number
|
|
||||||
configJson: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
type WizardJuryGroup = {
|
|
||||||
tempId: string
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
defaultMaxAssignments: number
|
|
||||||
defaultCapMode: string
|
|
||||||
sortOrder: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type WizardState = {
|
|
||||||
programId: string
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
categoryMode: string
|
|
||||||
startupFinalistCount: number
|
|
||||||
conceptFinalistCount: number
|
|
||||||
notifyOnRoundAdvance: boolean
|
|
||||||
notifyOnDeadlineApproach: boolean
|
|
||||||
deadlineReminderDays: number[]
|
|
||||||
rounds: WizardRound[]
|
|
||||||
juryGroups: WizardJuryGroup[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultRounds: WizardRound[] = [
|
|
||||||
{
|
|
||||||
tempId: crypto.randomUUID(),
|
|
||||||
name: 'Intake',
|
|
||||||
slug: 'intake',
|
|
||||||
roundType: 'INTAKE',
|
|
||||||
sortOrder: 0,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tempId: crypto.randomUUID(),
|
|
||||||
name: 'Filtering',
|
|
||||||
slug: 'filtering',
|
|
||||||
roundType: 'FILTERING',
|
|
||||||
sortOrder: 1,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tempId: crypto.randomUUID(),
|
|
||||||
name: 'Evaluation (Jury 1)',
|
|
||||||
slug: 'evaluation-jury-1',
|
|
||||||
roundType: 'EVALUATION',
|
|
||||||
sortOrder: 2,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tempId: crypto.randomUUID(),
|
|
||||||
name: 'Submission',
|
|
||||||
slug: 'submission',
|
|
||||||
roundType: 'SUBMISSION',
|
|
||||||
sortOrder: 3,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tempId: crypto.randomUUID(),
|
|
||||||
name: 'Evaluation (Jury 2)',
|
|
||||||
slug: 'evaluation-jury-2',
|
|
||||||
roundType: 'EVALUATION',
|
|
||||||
sortOrder: 4,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tempId: crypto.randomUUID(),
|
|
||||||
name: 'Mentoring',
|
|
||||||
slug: 'mentoring',
|
|
||||||
roundType: 'MENTORING',
|
|
||||||
sortOrder: 5,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tempId: crypto.randomUUID(),
|
|
||||||
name: 'Live Final',
|
|
||||||
slug: 'live-final',
|
|
||||||
roundType: 'LIVE_FINAL',
|
|
||||||
sortOrder: 6,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tempId: crypto.randomUUID(),
|
|
||||||
name: 'Deliberation',
|
|
||||||
slug: 'deliberation',
|
|
||||||
roundType: 'DELIBERATION',
|
|
||||||
sortOrder: 7,
|
|
||||||
configJson: {},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function NewCompetitionPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const { currentEdition } = useEdition()
|
|
||||||
const paramProgramId = searchParams.get('programId')
|
|
||||||
const programId = paramProgramId || currentEdition?.id || ''
|
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState(0)
|
|
||||||
const [isDirty, setIsDirty] = useState(false)
|
|
||||||
|
|
||||||
const [state, setState] = useState<WizardState>({
|
|
||||||
programId,
|
|
||||||
name: '',
|
|
||||||
slug: '',
|
|
||||||
categoryMode: 'SHARED',
|
|
||||||
startupFinalistCount: 3,
|
|
||||||
conceptFinalistCount: 3,
|
|
||||||
notifyOnRoundAdvance: true,
|
|
||||||
notifyOnDeadlineApproach: true,
|
|
||||||
deadlineReminderDays: [7, 3, 1],
|
|
||||||
rounds: defaultRounds,
|
|
||||||
juryGroups: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (programId) {
|
|
||||||
setState((prev) => ({ ...prev, programId }))
|
|
||||||
}
|
|
||||||
}, [programId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
||||||
if (isDirty) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.returnValue = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
}, [isDirty])
|
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
|
||||||
const createCompetitionMutation = trpc.competition.create.useMutation()
|
|
||||||
const createRoundMutation = trpc.round.create.useMutation()
|
|
||||||
const createJuryGroupMutation = trpc.juryGroup.create.useMutation()
|
|
||||||
|
|
||||||
const handleStateChange = (updates: Partial<WizardState>) => {
|
|
||||||
setState((prev) => ({ ...prev, ...updates }))
|
|
||||||
setIsDirty(true)
|
|
||||||
|
|
||||||
// Auto-generate slug from name if name changed
|
|
||||||
if (updates.name !== undefined && updates.slug === undefined) {
|
|
||||||
const autoSlug = updates.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
|
||||||
setState((prev) => ({ ...prev, slug: autoSlug }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!state.name.trim()) {
|
|
||||||
toast.error('Competition name is required')
|
|
||||||
setCurrentStep(0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.slug.trim()) {
|
|
||||||
toast.error('Competition slug is required')
|
|
||||||
setCurrentStep(0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.rounds.length === 0) {
|
|
||||||
toast.error('At least one round is required')
|
|
||||||
setCurrentStep(1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create competition
|
|
||||||
const competition = await createCompetitionMutation.mutateAsync({
|
|
||||||
programId: state.programId,
|
|
||||||
name: state.name,
|
|
||||||
slug: state.slug,
|
|
||||||
categoryMode: state.categoryMode,
|
|
||||||
startupFinalistCount: state.startupFinalistCount,
|
|
||||||
conceptFinalistCount: state.conceptFinalistCount,
|
|
||||||
notifyOnRoundAdvance: state.notifyOnRoundAdvance,
|
|
||||||
notifyOnDeadlineApproach: state.notifyOnDeadlineApproach,
|
|
||||||
deadlineReminderDays: state.deadlineReminderDays,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create rounds
|
|
||||||
for (const round of state.rounds) {
|
|
||||||
await createRoundMutation.mutateAsync({
|
|
||||||
competitionId: competition.id,
|
|
||||||
name: round.name,
|
|
||||||
slug: round.slug,
|
|
||||||
roundType: round.roundType as any,
|
|
||||||
sortOrder: round.sortOrder,
|
|
||||||
configJson: round.configJson,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create jury groups
|
|
||||||
for (const group of state.juryGroups) {
|
|
||||||
await createJuryGroupMutation.mutateAsync({
|
|
||||||
competitionId: competition.id,
|
|
||||||
name: group.name,
|
|
||||||
slug: group.slug,
|
|
||||||
defaultMaxAssignments: group.defaultMaxAssignments,
|
|
||||||
defaultCapMode: group.defaultCapMode as any,
|
|
||||||
sortOrder: group.sortOrder,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Competition created successfully')
|
|
||||||
setIsDirty(false)
|
|
||||||
utils.competition.list.invalidate()
|
|
||||||
router.push(`/admin/competitions/${competition.id}` as Route)
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.error(err.message || 'Failed to create competition')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const steps: StepConfig[] = [
|
|
||||||
{
|
|
||||||
title: 'Basics',
|
|
||||||
description: 'Name and settings',
|
|
||||||
isValid: !!state.name && !!state.slug,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Rounds',
|
|
||||||
description: 'Configure rounds',
|
|
||||||
isValid: state.rounds.length > 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Jury Groups',
|
|
||||||
description: 'Add jury groups',
|
|
||||||
isValid: true, // Optional
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Review',
|
|
||||||
description: 'Confirm and create',
|
|
||||||
isValid: !!state.name && !!state.slug && state.rounds.length > 0,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const canSubmit = steps.every((s) => s.isValid)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link href={'/admin/competitions' as Route}>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competitions list">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">New Competition</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Create a multi-round competition workflow
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Wizard */}
|
|
||||||
<SidebarStepper
|
|
||||||
steps={steps}
|
|
||||||
currentStep={currentStep}
|
|
||||||
onStepChange={setCurrentStep}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
isSubmitting={
|
|
||||||
createCompetitionMutation.isPending ||
|
|
||||||
createRoundMutation.isPending ||
|
|
||||||
createJuryGroupMutation.isPending
|
|
||||||
}
|
|
||||||
submitLabel="Create Competition"
|
|
||||||
canSubmit={canSubmit}
|
|
||||||
>
|
|
||||||
<BasicsSection state={state} onChange={handleStateChange} />
|
|
||||||
<RoundsSection rounds={state.rounds} onChange={(rounds) => handleStateChange({ rounds })} />
|
|
||||||
<JuryGroupsSection
|
|
||||||
juryGroups={state.juryGroups}
|
|
||||||
onChange={(juryGroups) => handleStateChange({ juryGroups })}
|
|
||||||
/>
|
|
||||||
<ReviewSection state={state} />
|
|
||||||
</SidebarStepper>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import Link from 'next/link'
|
|
||||||
import type { Route } from 'next'
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
Medal,
|
|
||||||
Calendar,
|
|
||||||
Users,
|
|
||||||
Layers,
|
|
||||||
FileBox,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
|
||||||
import { useEdition } from '@/contexts/edition-context'
|
|
||||||
|
|
||||||
const statusConfig = {
|
|
||||||
DRAFT: {
|
|
||||||
label: 'Draft',
|
|
||||||
bgClass: 'bg-gray-100 text-gray-700',
|
|
||||||
dotClass: 'bg-gray-500',
|
|
||||||
},
|
|
||||||
ACTIVE: {
|
|
||||||
label: 'Active',
|
|
||||||
bgClass: 'bg-emerald-100 text-emerald-700',
|
|
||||||
dotClass: 'bg-emerald-500',
|
|
||||||
},
|
|
||||||
CLOSED: {
|
|
||||||
label: 'Closed',
|
|
||||||
bgClass: 'bg-blue-100 text-blue-700',
|
|
||||||
dotClass: 'bg-blue-500',
|
|
||||||
},
|
|
||||||
ARCHIVED: {
|
|
||||||
label: 'Archived',
|
|
||||||
bgClass: 'bg-muted text-muted-foreground',
|
|
||||||
dotClass: 'bg-muted-foreground',
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export default function CompetitionListPage() {
|
|
||||||
const { currentEdition } = useEdition()
|
|
||||||
const programId = currentEdition?.id
|
|
||||||
|
|
||||||
const { data: competitions, isLoading } = trpc.competition.list.useQuery(
|
|
||||||
{ programId: programId! },
|
|
||||||
{ enabled: !!programId }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!programId) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Competitions</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Select an edition to view competitions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<Calendar className="h-12 w-12 text-muted-foreground/50" />
|
|
||||||
<p className="mt-2 font-medium">No Edition Selected</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Select an edition from the sidebar to view its competitions
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 px-4 sm:px-0">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Competitions</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Manage competitions for {currentEdition?.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
|
|
||||||
<Button size="sm" className="w-full sm:w-auto">
|
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
|
||||||
New Competition
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading */}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<Card key={i}>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-5 w-32" />
|
|
||||||
<Skeleton className="h-4 w-20 mt-1" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className="h-4 w-3/4 mt-2" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty State */}
|
|
||||||
{!isLoading && (!competitions || competitions.length === 0) && (
|
|
||||||
<Card className="border-2 border-dashed">
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
|
||||||
<div className="rounded-full bg-primary/10 p-4 mb-4">
|
|
||||||
<Medal className="h-10 w-10 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold mb-2">No Competitions Yet</h3>
|
|
||||||
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
|
||||||
Competitions organize your multi-round evaluation workflow with jury groups,
|
|
||||||
submission windows, and scoring. Create your first competition to get started.
|
|
||||||
</p>
|
|
||||||
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
|
|
||||||
<Button>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Create Your First Competition
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Competition Cards */}
|
|
||||||
{competitions && competitions.length > 0 && (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{competitions.map((competition) => {
|
|
||||||
const status = competition.status as keyof typeof statusConfig
|
|
||||||
const config = statusConfig[status] || statusConfig.DRAFT
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link key={competition.id} href={`/admin/competitions/${competition.id}` as Route}>
|
|
||||||
<Card className="group cursor-pointer hover:shadow-md transition-shadow h-full flex flex-col">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<CardTitle className="text-base leading-tight">
|
|
||||||
{competition.name}
|
|
||||||
</CardTitle>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 font-mono">
|
|
||||||
{competition.slug}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className={cn(
|
|
||||||
'text-[10px] shrink-0 flex items-center gap-1.5',
|
|
||||||
config.bgClass
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn('h-1.5 w-1.5 rounded-full', config.dotClass)} />
|
|
||||||
{config.label}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="mt-auto">
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Layers className="h-3.5 w-3.5" />
|
|
||||||
<span>{competition._count.rounds} rounds</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Users className="h-3.5 w-3.5" />
|
|
||||||
<span>{competition._count.juryGroups} juries</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<FileBox className="h-3.5 w-3.5" />
|
|
||||||
<span>{competition._count.submissionWindows} windows</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
|
||||||
Updated {formatDistanceToNow(new Date(competition.updatedAt))} ago
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -195,10 +194,8 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
|
|||||||
<Card className="border-dashed">
|
<Card className="border-dashed">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<p className="text-muted-foreground">The requested jury group could not be found.</p>
|
<p className="text-muted-foreground">The requested jury group could not be found.</p>
|
||||||
<Button asChild className="mt-4" variant="outline">
|
<Button className="mt-4" variant="outline" onClick={() => router.back()}>
|
||||||
<Link href={'/admin/juries' as Route}>
|
Back
|
||||||
Back to Juries
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -213,13 +210,11 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
asChild
|
|
||||||
className="mb-2"
|
className="mb-2"
|
||||||
|
onClick={() => router.back()}
|
||||||
>
|
>
|
||||||
<Link href={'/admin/juries' as Route}>
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
Back
|
||||||
Back to Juries
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -290,7 +285,9 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
|
|||||||
{group.members.map((member) => (
|
{group.members.map((member) => (
|
||||||
<TableRow key={member.id}>
|
<TableRow key={member.id}>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
{member.user.name || 'Unnamed'}
|
<Link href={`/admin/members/${member.user.id}` as Route} className="hover:underline text-primary">
|
||||||
|
{member.user.name || 'Unnamed'}
|
||||||
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{member.user.email}
|
{member.user.email}
|
||||||
@@ -523,11 +520,6 @@ function SettingsForm({ group, onSave, isPending }: SettingsFormProps) {
|
|||||||
name: group.name,
|
name: group.name,
|
||||||
description: group.description || '',
|
description: group.description || '',
|
||||||
defaultMaxAssignments: group.defaultMaxAssignments,
|
defaultMaxAssignments: group.defaultMaxAssignments,
|
||||||
defaultCapMode: group.defaultCapMode,
|
|
||||||
softCapBuffer: group.softCapBuffer,
|
|
||||||
categoryQuotasEnabled: group.categoryQuotasEnabled,
|
|
||||||
allowJurorCapAdjustment: group.allowJurorCapAdjustment,
|
|
||||||
allowJurorRatioAdjustment: group.allowJurorRatioAdjustment,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
@@ -562,99 +554,20 @@ function SettingsForm({ group, onSave, isPending }: SettingsFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<Label>Default Max Assignments</Label>
|
||||||
<Label>Default Max Assignments</Label>
|
<Input
|
||||||
<Input
|
type="number"
|
||||||
type="number"
|
min="1"
|
||||||
min="1"
|
max="50"
|
||||||
value={formData.defaultMaxAssignments}
|
value={formData.defaultMaxAssignments}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, defaultMaxAssignments: parseInt(e.target.value, 10) })
|
setFormData({ ...formData, defaultMaxAssignments: parseInt(e.target.value, 10) || 15 })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Suggested cap for new members. Per-member overrides and juror self-service preferences take priority.
|
||||||
<div className="space-y-2">
|
</p>
|
||||||
<Label>Cap Mode</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.defaultCapMode}
|
|
||||||
onValueChange={(v) => setFormData({ ...formData, defaultCapMode: v })}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="HARD">Hard Cap</SelectItem>
|
|
||||||
<SelectItem value="SOFT">Soft Cap</SelectItem>
|
|
||||||
<SelectItem value="NONE">No Cap</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.defaultCapMode === 'SOFT' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Soft Cap Buffer</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={formData.softCapBuffer}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, softCapBuffer: parseInt(e.target.value, 10) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Number of assignments allowed above the cap when in soft mode
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-3 border-t pt-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label>Category Quotas Enabled</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Enable category-based assignment quotas
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={formData.categoryQuotasEnabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setFormData({ ...formData, categoryQuotasEnabled: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label>Allow Juror Cap Adjustment</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Allow jurors to set their own assignment cap during onboarding
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={formData.allowJurorCapAdjustment}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setFormData({ ...formData, allowJurorCapAdjustment: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label>Allow Juror Ratio Adjustment</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Allow jurors to set their own startup/concept ratio during onboarding
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={formData.allowJurorRatioAdjustment}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setFormData({ ...formData, allowJurorRatioAdjustment: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={isPending} className="w-full sm:w-auto">
|
<Button type="submit" disabled={isPending} className="w-full sm:w-auto">
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn, formatEnumLabel } from '@/lib/utils'
|
||||||
import { Plus, Scale, Users, Loader2 } from 'lucide-react'
|
import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot } from 'lucide-react'
|
||||||
|
|
||||||
const capModeLabels = {
|
const capModeLabels = {
|
||||||
HARD: 'Hard Cap',
|
HARD: 'Hard Cap',
|
||||||
@@ -267,33 +267,82 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
|
|||||||
No jury groups configured for this competition.
|
No jury groups configured for this competition.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="space-y-3">
|
||||||
{juryGroups.map((group) => (
|
{juryGroups.map((group) => (
|
||||||
<Link key={group.id} href={`/admin/juries/${group.id}` as Route}>
|
<Link key={group.id} href={`/admin/juries/${group.id}` as Route}>
|
||||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md cursor-pointer">
|
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md cursor-pointer group">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
|
{/* Header row */}
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h3 className="font-semibold text-sm line-clamp-1">{group.name}</h3>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Badge
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10 shrink-0">
|
||||||
variant="secondary"
|
<Scale className="h-4 w-4 text-brand-blue" />
|
||||||
className={cn('text-[10px] shrink-0', capModeColors[group.defaultCapMode as keyof typeof capModeColors])}
|
</div>
|
||||||
>
|
<div className="min-w-0">
|
||||||
{capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]}
|
<h3 className="font-semibold text-sm line-clamp-1 group-hover:text-brand-blue transition-colors">
|
||||||
</Badge>
|
{group.name}
|
||||||
</div>
|
</h3>
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
<div className="flex items-center gap-1">
|
{group._count.members} member{group._count.members !== 1 ? 's' : ''}
|
||||||
<Users className="h-3 w-3" />
|
{' · '}
|
||||||
<span>{group._count.members} members</span>
|
{group._count.assignments} assignment{group._count.assignments !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{group._count.assignments} assignments
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn('text-[10px]', capModeColors[group.defaultCapMode as keyof typeof capModeColors])}
|
||||||
|
>
|
||||||
|
{capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]}
|
||||||
|
</Badge>
|
||||||
|
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-brand-blue transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Default max: {group.defaultMaxAssignments}
|
{/* Round assignments */}
|
||||||
</div>
|
{(group as any).rounds?.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(group as any).rounds.map((r: any) => (
|
||||||
|
<Badge
|
||||||
|
key={r.id}
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] gap-1',
|
||||||
|
r.status === 'ROUND_ACTIVE' && 'border-blue-300 bg-blue-50 text-blue-700',
|
||||||
|
r.status === 'ROUND_CLOSED' && 'border-emerald-300 bg-emerald-50 text-emerald-700',
|
||||||
|
r.status === 'ROUND_DRAFT' && 'border-slate-200 text-slate-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CircleDot className="h-2.5 w-2.5" />
|
||||||
|
{r.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Member preview */}
|
||||||
|
{(group as any).members?.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="flex -space-x-1.5">
|
||||||
|
{(group as any).members.slice(0, 5).map((m: any) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="h-6 w-6 rounded-full bg-brand-blue/10 border-2 border-white flex items-center justify-center text-[9px] font-semibold text-brand-blue"
|
||||||
|
title={m.user?.name || m.user?.email}
|
||||||
|
>
|
||||||
|
{(m.user?.name || m.user?.email || '?').charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{group._count.members > 5 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
+{group._count.members - 5} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
Card,
|
import { Separator } from '@/components/ui/separator'
|
||||||
CardContent,
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
CardDescription,
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -24,6 +19,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -35,46 +38,62 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Save,
|
Save,
|
||||||
Loader2,
|
Loader2,
|
||||||
FileText,
|
Settings,
|
||||||
Video,
|
|
||||||
Link as LinkIcon,
|
|
||||||
File,
|
|
||||||
Trash2,
|
|
||||||
Eye,
|
Eye,
|
||||||
|
Trash2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Dynamically import BlockEditor to avoid SSR issues
|
// Dynamically import editors to avoid SSR issues
|
||||||
const BlockEditor = dynamic(
|
const BlockEditor = dynamic(
|
||||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
<div className="mx-auto max-w-3xl min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const resourceTypeOptions = [
|
const ResourceRenderer = dynamic(
|
||||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
{
|
||||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
ssr: false,
|
||||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
loading: () => (
|
||||||
{ value: 'OTHER', label: 'Other', icon: File },
|
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = [
|
||||||
|
{ value: 'JURY_MEMBER', label: 'Jury Members' },
|
||||||
|
{ value: 'MENTOR', label: 'Mentors' },
|
||||||
|
{ value: 'OBSERVER', label: 'Observers' },
|
||||||
|
{ value: 'APPLICANT', label: 'Applicants' },
|
||||||
|
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const cohortOptions = [
|
type AccessRule =
|
||||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
| { type: 'everyone' }
|
||||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
| { type: 'roles'; roles: string[] }
|
||||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
| { type: 'jury_group'; juryGroupIds: string[] }
|
||||||
]
|
| { type: 'round'; roundIds: string[] }
|
||||||
|
|
||||||
|
function parseAccessJson(accessJson: unknown): { mode: 'everyone' | 'roles'; roles: string[] } {
|
||||||
|
if (!accessJson || !Array.isArray(accessJson) || accessJson.length === 0) {
|
||||||
|
return { mode: 'everyone', roles: [] }
|
||||||
|
}
|
||||||
|
const firstRule = accessJson[0] as AccessRule
|
||||||
|
if (firstRule.type === 'roles' && 'roles' in firstRule) {
|
||||||
|
return { mode: 'roles', roles: firstRule.roles }
|
||||||
|
}
|
||||||
|
return { mode: 'everyone', roles: [] }
|
||||||
|
}
|
||||||
|
|
||||||
export default function EditLearningResourcePage() {
|
export default function EditLearningResourcePage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -89,11 +108,14 @@ export default function EditLearningResourcePage() {
|
|||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [contentJson, setContentJson] = useState<string>('')
|
const [contentJson, setContentJson] = useState<string>('')
|
||||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
|
||||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
|
||||||
const [externalUrl, setExternalUrl] = useState('')
|
const [externalUrl, setExternalUrl] = useState('')
|
||||||
const [isPublished, setIsPublished] = useState(false)
|
const [isPublished, setIsPublished] = useState(false)
|
||||||
const [programId, setProgramId] = useState<string | null>(null)
|
const [programId, setProgramId] = useState<string | null>(null)
|
||||||
|
const [previewing, setPreviewing] = useState(false)
|
||||||
|
|
||||||
|
// Access rules state
|
||||||
|
const [accessMode, setAccessMode] = useState<'everyone' | 'roles'>('everyone')
|
||||||
|
const [selectedRoles, setSelectedRoles] = useState<string[]>([])
|
||||||
|
|
||||||
// API
|
// API
|
||||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||||
@@ -115,11 +137,13 @@ export default function EditLearningResourcePage() {
|
|||||||
setTitle(resource.title)
|
setTitle(resource.title)
|
||||||
setDescription(resource.description || '')
|
setDescription(resource.description || '')
|
||||||
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
|
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
|
||||||
setResourceType(resource.resourceType)
|
|
||||||
setCohortLevel(resource.cohortLevel)
|
|
||||||
setExternalUrl(resource.externalUrl || '')
|
setExternalUrl(resource.externalUrl || '')
|
||||||
setIsPublished(resource.isPublished)
|
setIsPublished(resource.isPublished)
|
||||||
setProgramId(resource.programId)
|
setProgramId(resource.programId)
|
||||||
|
|
||||||
|
const { mode, roles } = parseAccessJson(resource.accessJson)
|
||||||
|
setAccessMode(mode)
|
||||||
|
setSelectedRoles(roles)
|
||||||
}
|
}
|
||||||
}, [resource])
|
}, [resource])
|
||||||
|
|
||||||
@@ -134,74 +158,88 @@ export default function EditLearningResourcePage() {
|
|||||||
await fetch(url, {
|
await fetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: file,
|
body: file,
|
||||||
headers: {
|
headers: { 'Content-Type': file.type },
|
||||||
'Content-Type': file.type,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to upload file')
|
toast.error('Failed to upload file')
|
||||||
throw error
|
throw new Error('Upload failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const buildAccessJson = (): AccessRule[] | null => {
|
||||||
|
if (accessMode === 'everyone') return null
|
||||||
|
if (accessMode === 'roles' && selectedRoles.length > 0) {
|
||||||
|
return [{ type: 'roles', roles: selectedRoles }]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
toast.error('Please enter a title')
|
toast.error('Please enter a title')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceType === 'LINK' && !externalUrl) {
|
|
||||||
toast.error('Please enter an external URL')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateResource.mutateAsync({
|
await updateResource.mutateAsync({
|
||||||
id: resourceId,
|
id: resourceId,
|
||||||
|
programId,
|
||||||
title,
|
title,
|
||||||
description: description || undefined,
|
description: description || null,
|
||||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
accessJson: buildAccessJson(),
|
||||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
|
||||||
externalUrl: externalUrl || null,
|
externalUrl: externalUrl || null,
|
||||||
isPublished,
|
isPublished,
|
||||||
})
|
})
|
||||||
|
|
||||||
toast.success('Resource updated successfully')
|
toast.success('Resource updated')
|
||||||
router.push('/admin/learning')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
|
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
|
||||||
}
|
}
|
||||||
}
|
}, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles, resourceId])
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteResource.mutateAsync({ id: resourceId })
|
await deleteResource.mutateAsync({ id: resourceId })
|
||||||
toast.success('Resource deleted successfully')
|
toast.success('Resource deleted')
|
||||||
router.push('/admin/learning')
|
router.push('/admin/learning')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
|
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+S save
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [handleSubmit])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex min-h-screen flex-col">
|
||||||
<div className="flex items-center gap-4">
|
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2">
|
||||||
<Skeleton className="h-9 w-40" />
|
<Skeleton className="h-8 w-20" />
|
||||||
</div>
|
<div className="flex gap-2">
|
||||||
<Skeleton className="h-8 w-64" />
|
<Skeleton className="h-8 w-20" />
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<Skeleton className="h-8 w-20" />
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<Skeleton className="h-8 w-16" />
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
<Skeleton className="h-96 w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6">
|
</div>
|
||||||
<Skeleton className="h-48 w-full" />
|
<div className="flex-1 px-4 py-8">
|
||||||
<Skeleton className="h-32 w-full" />
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
<Skeleton className="h-10 w-2/3" />
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
<Skeleton className="h-px w-full" />
|
||||||
|
<Skeleton className="h-96 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,7 +248,7 @@ export default function EditLearningResourcePage() {
|
|||||||
|
|
||||||
if (error || !resource) {
|
if (error || !resource) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 p-6">
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Resource not found</AlertTitle>
|
<AlertTitle>Resource not found</AlertTitle>
|
||||||
@@ -218,264 +256,257 @@ export default function EditLearningResourcePage() {
|
|||||||
The resource you're looking for does not exist.
|
The resource you're looking for does not exist.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Button asChild>
|
<Button onClick={() => router.back()}>
|
||||||
<Link href="/admin/learning">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Learning Hub
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Header */}
|
{/* Sticky toolbar */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||||
<Link href="/admin/learning">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Learning Hub
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-center gap-2">
|
||||||
<div>
|
<Button
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
|
variant={previewing ? 'default' : 'outline'}
|
||||||
<p className="text-muted-foreground">
|
size="sm"
|
||||||
Update this learning resource
|
onClick={() => setPreviewing(!previewing)}
|
||||||
</p>
|
>
|
||||||
</div>
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
<AlertDialog>
|
{previewing ? 'Edit' : 'Preview'}
|
||||||
<AlertDialogTrigger asChild>
|
</Button>
|
||||||
<Button variant="outline" className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Are you sure you want to delete "{resource.title}"? This action
|
|
||||||
cannot be undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
{deleteResource.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : null}
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<Sheet>
|
||||||
{/* Main content */}
|
<SheetTrigger asChild>
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<Button variant="outline" size="sm">
|
||||||
{/* Basic Info */}
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
<Card>
|
Settings
|
||||||
<CardHeader>
|
</Button>
|
||||||
<CardTitle>Resource Details</CardTitle>
|
</SheetTrigger>
|
||||||
<CardDescription>
|
<SheetContent className="overflow-y-auto">
|
||||||
Basic information about this resource
|
<SheetHeader>
|
||||||
</CardDescription>
|
<SheetTitle>Resource Settings</SheetTitle>
|
||||||
</CardHeader>
|
<SheetDescription>
|
||||||
<CardContent className="space-y-4">
|
Configure publishing, access, and metadata
|
||||||
<div className="space-y-2">
|
</SheetDescription>
|
||||||
<Label htmlFor="title">Title *</Label>
|
</SheetHeader>
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="e.g., Ocean Conservation Best Practices"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="mt-6 space-y-6">
|
||||||
<Label htmlFor="description">Short Description</Label>
|
{/* Publish toggle */}
|
||||||
<Textarea
|
<div className="flex items-center justify-between">
|
||||||
id="description"
|
<div>
|
||||||
value={description}
|
<Label>Published</Label>
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
<p className="text-sm text-muted-foreground">
|
||||||
placeholder="Brief description of this resource"
|
Make visible to users
|
||||||
rows={2}
|
</p>
|
||||||
maxLength={500}
|
</div>
|
||||||
/>
|
<Switch
|
||||||
</div>
|
checked={isPublished}
|
||||||
|
onCheckedChange={setIsPublished}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<Separator />
|
||||||
|
|
||||||
|
{/* Program */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type">Resource Type</Label>
|
<Label>Program</Label>
|
||||||
<Select value={resourceType} onValueChange={setResourceType}>
|
<Select
|
||||||
<SelectTrigger id="type">
|
value={programId || 'global'}
|
||||||
<SelectValue />
|
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select program" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{resourceTypeOptions.map((option) => (
|
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
{programs?.map((program) => (
|
||||||
<div className="flex items-center gap-2">
|
<SelectItem key={program.id} value={program.id}>
|
||||||
<option.icon className="h-4 w-4" />
|
{program.year} Edition
|
||||||
{option.label}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Separator />
|
||||||
<Label htmlFor="cohort">Access Level</Label>
|
|
||||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
{/* Access Rules */}
|
||||||
<SelectTrigger id="cohort">
|
<div className="space-y-3">
|
||||||
|
<Label>Access Rules</Label>
|
||||||
|
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
|
||||||
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{cohortOptions.map((option) => (
|
<SelectItem value="everyone">Everyone</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem value="roles">By Role</SelectItem>
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{resourceType === 'LINK' && (
|
{accessMode === 'roles' && (
|
||||||
|
<div className="space-y-2 rounded-lg border p-3">
|
||||||
|
{ROLE_OPTIONS.map((role) => (
|
||||||
|
<label key={role.value} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRoles.includes(role.value)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setSelectedRoles(
|
||||||
|
checked
|
||||||
|
? [...selectedRoles, role.value]
|
||||||
|
: selectedRoles.filter((r) => r !== role.value)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{role.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* External URL */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="url">External URL *</Label>
|
<Label>External URL</Label>
|
||||||
<Input
|
<Input
|
||||||
id="url"
|
|
||||||
type="url"
|
type="url"
|
||||||
value={externalUrl}
|
value={externalUrl}
|
||||||
onChange={(e) => setExternalUrl(e.target.value)}
|
onChange={(e) => setExternalUrl(e.target.value)}
|
||||||
placeholder="https://example.com/resource"
|
placeholder="https://example.com/resource"
|
||||||
/>
|
/>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">
|
||||||
)}
|
Optional link to an external resource
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Content Editor */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Content</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Rich text content with images and videos. Type / for commands.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<BlockEditor
|
|
||||||
key={resourceId}
|
|
||||||
initialContent={contentJson || undefined}
|
|
||||||
onChange={setContentJson}
|
|
||||||
onUploadFile={handleUploadFile}
|
|
||||||
className="min-h-[300px]"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Publish Settings */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Publish Settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="published">Published</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Make this resource visible to jury members
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
|
||||||
id="published"
|
|
||||||
checked={isPublished}
|
|
||||||
onCheckedChange={setIsPublished}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Separator />
|
||||||
<Label htmlFor="program">Program</Label>
|
|
||||||
<Select
|
|
||||||
value={programId || 'global'}
|
|
||||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="program">
|
|
||||||
<SelectValue placeholder="Select program" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
|
||||||
{programs?.map((program) => (
|
|
||||||
<SelectItem key={program.id} value={program.id}>
|
|
||||||
{program.year} Edition
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Statistics */}
|
{/* Statistics */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<Card>
|
<div className="space-y-2">
|
||||||
<CardHeader>
|
<Label>Statistics</Label>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="grid grid-cols-2 gap-4 rounded-lg border p-3">
|
||||||
<Eye className="h-5 w-5" />
|
<div>
|
||||||
Statistics
|
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
||||||
</CardTitle>
|
<p className="text-xs text-muted-foreground">Total views</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
||||||
<div>
|
<p className="text-xs text-muted-foreground">Unique users</p>
|
||||||
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Total views</p>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">Unique users</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-destructive">Danger Zone</Label>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete Resource
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete "{resource.title}"?
|
||||||
|
This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{deleteResource.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={updateResource.isPending || !title.trim()}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{updateResource.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild className="w-full">
|
|
||||||
<Link href="/admin/learning">Cancel</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</SheetContent>
|
||||||
</Card>
|
</Sheet>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={updateResource.isPending || !title.trim()}
|
||||||
|
>
|
||||||
|
{updateResource.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="flex-1 px-4 py-8">
|
||||||
|
{previewing ? (
|
||||||
|
<ResourceRenderer
|
||||||
|
title={title || 'Untitled'}
|
||||||
|
description={description || null}
|
||||||
|
contentJson={contentJson ? JSON.parse(contentJson) : null}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
{/* Inline title */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Untitled"
|
||||||
|
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Inline description */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Add a description..."
|
||||||
|
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<hr className="border-border" />
|
||||||
|
|
||||||
|
{/* Block editor */}
|
||||||
|
<BlockEditor
|
||||||
|
key={resourceId}
|
||||||
|
initialContent={contentJson || undefined}
|
||||||
|
onChange={setContentJson}
|
||||||
|
onUploadFile={handleUploadFile}
|
||||||
|
className="min-h-[400px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
Card,
|
import { Separator } from '@/components/ui/separator'
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -24,33 +17,57 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ArrowLeft, Save, Loader2, FileText, Video, Link as LinkIcon, File } from 'lucide-react'
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Save,
|
||||||
|
Loader2,
|
||||||
|
Settings,
|
||||||
|
Eye,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Dynamically import BlockEditor to avoid SSR issues
|
// Dynamically import editors to avoid SSR issues
|
||||||
const BlockEditor = dynamic(
|
const BlockEditor = dynamic(
|
||||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
<div className="mx-auto max-w-3xl min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const resourceTypeOptions = [
|
const ResourceRenderer = dynamic(
|
||||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
{
|
||||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
ssr: false,
|
||||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
loading: () => (
|
||||||
{ value: 'OTHER', label: 'Other', icon: File },
|
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = [
|
||||||
|
{ value: 'JURY_MEMBER', label: 'Jury Members' },
|
||||||
|
{ value: 'MENTOR', label: 'Mentors' },
|
||||||
|
{ value: 'OBSERVER', label: 'Observers' },
|
||||||
|
{ value: 'APPLICANT', label: 'Applicants' },
|
||||||
|
{ value: 'AWARD_MASTER', label: 'Award Masters' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const cohortOptions = [
|
type AccessRule =
|
||||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
| { type: 'everyone' }
|
||||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
| { type: 'roles'; roles: string[] }
|
||||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
| { type: 'jury_group'; juryGroupIds: string[] }
|
||||||
]
|
| { type: 'round'; roundIds: string[] }
|
||||||
|
|
||||||
export default function NewLearningResourcePage() {
|
export default function NewLearningResourcePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -59,14 +76,17 @@ export default function NewLearningResourcePage() {
|
|||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [contentJson, setContentJson] = useState<string>('')
|
const [contentJson, setContentJson] = useState<string>('')
|
||||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
|
||||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
|
||||||
const [externalUrl, setExternalUrl] = useState('')
|
const [externalUrl, setExternalUrl] = useState('')
|
||||||
const [isPublished, setIsPublished] = useState(false)
|
const [isPublished, setIsPublished] = useState(false)
|
||||||
|
const [programId, setProgramId] = useState<string | null>(null)
|
||||||
|
const [previewing, setPreviewing] = useState(false)
|
||||||
|
|
||||||
|
// Access rules state
|
||||||
|
const [accessMode, setAccessMode] = useState<'everyone' | 'roles'>('everyone')
|
||||||
|
const [selectedRoles, setSelectedRoles] = useState<string[]>([])
|
||||||
|
|
||||||
// API
|
// API
|
||||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||||
const [programId, setProgramId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const createResource = trpc.learningResource.create.useMutation({
|
const createResource = trpc.learningResource.create.useMutation({
|
||||||
@@ -82,43 +102,41 @@ export default function NewLearningResourcePage() {
|
|||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Upload to MinIO
|
|
||||||
await fetch(url, {
|
await fetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: file,
|
body: file,
|
||||||
headers: {
|
headers: { 'Content-Type': file.type },
|
||||||
'Content-Type': file.type,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Return the MinIO URL
|
|
||||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to upload file')
|
toast.error('Failed to upload file')
|
||||||
throw error
|
throw new Error('Upload failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const buildAccessJson = (): AccessRule[] | null => {
|
||||||
|
if (accessMode === 'everyone') return null
|
||||||
|
if (accessMode === 'roles' && selectedRoles.length > 0) {
|
||||||
|
return [{ type: 'roles', roles: selectedRoles }]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
toast.error('Please enter a title')
|
toast.error('Please enter a title')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceType === 'LINK' && !externalUrl) {
|
|
||||||
toast.error('Please enter an external URL')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createResource.mutateAsync({
|
await createResource.mutateAsync({
|
||||||
programId,
|
programId,
|
||||||
title,
|
title,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
accessJson: buildAccessJson(),
|
||||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
|
||||||
externalUrl: externalUrl || undefined,
|
externalUrl: externalUrl || undefined,
|
||||||
isPublished,
|
isPublished,
|
||||||
})
|
})
|
||||||
@@ -128,200 +146,203 @@ export default function NewLearningResourcePage() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
|
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
|
||||||
}
|
}
|
||||||
}
|
}, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles])
|
||||||
|
|
||||||
|
// Ctrl+S save
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [handleSubmit])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Header */}
|
{/* Sticky toolbar */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||||
<Link href="/admin/learning">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Learning Hub
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
|
<Button
|
||||||
<p className="text-muted-foreground">
|
variant={previewing ? 'default' : 'outline'}
|
||||||
Create a new learning resource for jury members
|
size="sm"
|
||||||
</p>
|
onClick={() => setPreviewing(!previewing)}
|
||||||
</div>
|
>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
{previewing ? 'Edit' : 'Preview'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<Sheet>
|
||||||
{/* Main content */}
|
<SheetTrigger asChild>
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<Button variant="outline" size="sm">
|
||||||
{/* Basic Info */}
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
<Card>
|
Settings
|
||||||
<CardHeader>
|
</Button>
|
||||||
<CardTitle>Resource Details</CardTitle>
|
</SheetTrigger>
|
||||||
<CardDescription>
|
<SheetContent className="overflow-y-auto">
|
||||||
Basic information about this resource
|
<SheetHeader>
|
||||||
</CardDescription>
|
<SheetTitle>Resource Settings</SheetTitle>
|
||||||
</CardHeader>
|
<SheetDescription>
|
||||||
<CardContent className="space-y-4">
|
Configure publishing, access, and metadata
|
||||||
<div className="space-y-2">
|
</SheetDescription>
|
||||||
<Label htmlFor="title">Title *</Label>
|
</SheetHeader>
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="e.g., Ocean Conservation Best Practices"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="mt-6 space-y-6">
|
||||||
<Label htmlFor="description">Short Description</Label>
|
{/* Publish toggle */}
|
||||||
<Textarea
|
<div className="flex items-center justify-between">
|
||||||
id="description"
|
<div>
|
||||||
value={description}
|
<Label>Published</Label>
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
<p className="text-sm text-muted-foreground">
|
||||||
placeholder="Brief description of this resource"
|
Make visible to users
|
||||||
rows={2}
|
</p>
|
||||||
maxLength={500}
|
</div>
|
||||||
/>
|
<Switch
|
||||||
</div>
|
checked={isPublished}
|
||||||
|
onCheckedChange={setIsPublished}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<Separator />
|
||||||
|
|
||||||
|
{/* Program */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type">Resource Type</Label>
|
<Label>Program</Label>
|
||||||
<Select value={resourceType} onValueChange={setResourceType}>
|
<Select
|
||||||
<SelectTrigger id="type">
|
value={programId || 'global'}
|
||||||
<SelectValue />
|
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select program" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{resourceTypeOptions.map((option) => (
|
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
{programs?.map((program) => (
|
||||||
<div className="flex items-center gap-2">
|
<SelectItem key={program.id} value={program.id}>
|
||||||
<option.icon className="h-4 w-4" />
|
{program.year} Edition
|
||||||
{option.label}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Separator />
|
||||||
<Label htmlFor="cohort">Access Level</Label>
|
|
||||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
{/* Access Rules */}
|
||||||
<SelectTrigger id="cohort">
|
<div className="space-y-3">
|
||||||
|
<Label>Access Rules</Label>
|
||||||
|
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
|
||||||
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{cohortOptions.map((option) => (
|
<SelectItem value="everyone">Everyone</SelectItem>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem value="roles">By Role</SelectItem>
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{resourceType === 'LINK' && (
|
{accessMode === 'roles' && (
|
||||||
|
<div className="space-y-2 rounded-lg border p-3">
|
||||||
|
{ROLE_OPTIONS.map((role) => (
|
||||||
|
<label key={role.value} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRoles.includes(role.value)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setSelectedRoles(
|
||||||
|
checked
|
||||||
|
? [...selectedRoles, role.value]
|
||||||
|
: selectedRoles.filter((r) => r !== role.value)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{role.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* External URL */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="url">External URL *</Label>
|
<Label>External URL</Label>
|
||||||
<Input
|
<Input
|
||||||
id="url"
|
|
||||||
type="url"
|
type="url"
|
||||||
value={externalUrl}
|
value={externalUrl}
|
||||||
onChange={(e) => setExternalUrl(e.target.value)}
|
onChange={(e) => setExternalUrl(e.target.value)}
|
||||||
placeholder="https://example.com/resource"
|
placeholder="https://example.com/resource"
|
||||||
/>
|
/>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">
|
||||||
)}
|
Optional link to an external resource
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Content Editor */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Content</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Rich text content with images and videos. Type / for commands.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<BlockEditor
|
|
||||||
initialContent={contentJson || undefined}
|
|
||||||
onChange={setContentJson}
|
|
||||||
onUploadFile={handleUploadFile}
|
|
||||||
className="min-h-[300px]"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Publish Settings */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Publish Settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="published">Published</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Make this resource visible to jury members
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
|
||||||
id="published"
|
|
||||||
checked={isPublished}
|
|
||||||
onCheckedChange={setIsPublished}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Button
|
||||||
<Label htmlFor="program">Program</Label>
|
size="sm"
|
||||||
<Select
|
onClick={handleSubmit}
|
||||||
value={programId || 'global'}
|
disabled={createResource.isPending || !title.trim()}
|
||||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
>
|
||||||
>
|
{createResource.isPending ? (
|
||||||
<SelectTrigger id="program">
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<SelectValue placeholder="Select program" />
|
) : (
|
||||||
</SelectTrigger>
|
<Save className="mr-2 h-4 w-4" />
|
||||||
<SelectContent>
|
)}
|
||||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
Save
|
||||||
{programs?.map((program) => (
|
</Button>
|
||||||
<SelectItem key={program.id} value={program.id}>
|
|
||||||
{program.year} Edition
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={createResource.isPending || !title.trim()}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{createResource.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Create Resource
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild className="w-full">
|
|
||||||
<Link href="/admin/learning">Cancel</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="flex-1 px-4 py-8">
|
||||||
|
{previewing ? (
|
||||||
|
<ResourceRenderer
|
||||||
|
title={title || 'Untitled'}
|
||||||
|
description={description || null}
|
||||||
|
contentJson={contentJson ? JSON.parse(contentJson) : null}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
{/* Inline title */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Untitled"
|
||||||
|
autoFocus
|
||||||
|
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Inline description */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Add a description..."
|
||||||
|
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<hr className="border-border" />
|
||||||
|
|
||||||
|
{/* Block editor */}
|
||||||
|
<BlockEditor
|
||||||
|
onChange={setContentJson}
|
||||||
|
onUploadFile={handleUploadFile}
|
||||||
|
className="min-h-[400px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -22,48 +23,212 @@ import {
|
|||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
FileText,
|
FileText,
|
||||||
Video,
|
|
||||||
Link as LinkIcon,
|
|
||||||
File,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Search,
|
Search,
|
||||||
|
GripVertical,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
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 { toast } from 'sonner'
|
||||||
|
|
||||||
const resourceTypeIcons = {
|
type Resource = {
|
||||||
PDF: FileText,
|
id: string
|
||||||
VIDEO: Video,
|
title: string
|
||||||
DOCUMENT: File,
|
description: string | null
|
||||||
LINK: LinkIcon,
|
isPublished: boolean
|
||||||
OTHER: File,
|
sortOrder: number
|
||||||
|
externalUrl: string | null
|
||||||
|
objectKey: string | null
|
||||||
|
contentJson: unknown
|
||||||
|
accessJson: unknown
|
||||||
|
_count: { accessLogs: number }
|
||||||
|
program: { id: string; name: string; year: number } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const cohortColors: Record<string, string> = {
|
function getAccessSummary(accessJson: unknown): string {
|
||||||
ALL: 'bg-gray-100 text-gray-800',
|
if (!accessJson || !Array.isArray(accessJson) || accessJson.length === 0) {
|
||||||
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
return 'Everyone'
|
||||||
FINALIST: 'bg-purple-100 text-purple-800',
|
}
|
||||||
|
const rule = accessJson[0] as { type: string; roles?: string[] }
|
||||||
|
if (rule.type === 'everyone') return 'Everyone'
|
||||||
|
if (rule.type === 'roles' && rule.roles) {
|
||||||
|
if (rule.roles.length === 1) return rule.roles[0].replace('_', ' ').toLowerCase()
|
||||||
|
return `${rule.roles.length} roles`
|
||||||
|
}
|
||||||
|
if (rule.type === 'jury_group') return 'Jury groups'
|
||||||
|
if (rule.type === 'round') return 'By round'
|
||||||
|
return 'Custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableResourceCard({
|
||||||
|
resource,
|
||||||
|
onTogglePublished,
|
||||||
|
}: {
|
||||||
|
resource: Resource
|
||||||
|
onTogglePublished: (id: string, published: boolean) => void
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: resource.id })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={isDragging ? 'opacity-50 shadow-lg' : ''}
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center gap-3 py-3">
|
||||||
|
{/* Drag handle */}
|
||||||
|
<button
|
||||||
|
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Drag to reorder"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & meta */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium truncate">{resource.title}</h3>
|
||||||
|
{!resource.isPublished && (
|
||||||
|
<Badge variant="secondary" className="text-xs">Draft</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||||
|
<span className="capitalize">{getAccessSummary(resource.accessJson)}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{resource._count.accessLogs} views</span>
|
||||||
|
{resource.program && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{resource.program.year}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick publish toggle */}
|
||||||
|
<Switch
|
||||||
|
checked={resource.isPublished}
|
||||||
|
onCheckedChange={(checked) => onTogglePublished(resource.id, checked)}
|
||||||
|
aria-label={resource.isPublished ? 'Unpublish' : 'Publish'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{resource.externalUrl && (
|
||||||
|
<a
|
||||||
|
href={resource.externalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<Link href={`/admin/learning/${resource.id}`}>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LearningHubPage() {
|
export default function LearningHubPage() {
|
||||||
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 50 })
|
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 100 })
|
||||||
const resources = data?.data
|
const resources = (data?.data || []) as Resource[]
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const debouncedSearch = useDebounce(search, 300)
|
const debouncedSearch = useDebounce(search, 300)
|
||||||
const [typeFilter, setTypeFilter] = useState('all')
|
const [publishedFilter, setPublishedFilter] = useState('all')
|
||||||
const [cohortFilter, setCohortFilter] = useState('all')
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const reorderMutation = trpc.learningResource.reorder.useMutation({
|
||||||
|
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||||
|
})
|
||||||
|
const updateMutation = trpc.learningResource.update.useMutation({
|
||||||
|
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const filteredResources = useMemo(() => {
|
const filteredResources = useMemo(() => {
|
||||||
if (!resources) return []
|
|
||||||
return resources.filter((resource) => {
|
return resources.filter((resource) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
!debouncedSearch ||
|
!debouncedSearch ||
|
||||||
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
|
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||||
const matchesType = typeFilter === 'all' || resource.resourceType === typeFilter
|
const matchesPublished =
|
||||||
const matchesCohort = cohortFilter === 'all' || resource.cohortLevel === cohortFilter
|
publishedFilter === 'all' ||
|
||||||
return matchesSearch && matchesType && matchesCohort
|
(publishedFilter === 'published' && resource.isPublished) ||
|
||||||
|
(publishedFilter === 'draft' && !resource.isPublished)
|
||||||
|
return matchesSearch && matchesPublished
|
||||||
})
|
})
|
||||||
}, [resources, debouncedSearch, typeFilter, cohortFilter])
|
}, [resources, debouncedSearch, publishedFilter])
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id) return
|
||||||
|
|
||||||
|
const oldIndex = filteredResources.findIndex((r) => r.id === active.id)
|
||||||
|
const newIndex = filteredResources.findIndex((r) => r.id === over.id)
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return
|
||||||
|
|
||||||
|
const reordered = arrayMove(filteredResources, oldIndex, newIndex)
|
||||||
|
const items = reordered.map((r, i) => ({ id: r.id, sortOrder: i }))
|
||||||
|
|
||||||
|
reorderMutation.mutate({ items }, {
|
||||||
|
onError: () => toast.error('Failed to reorder'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTogglePublished = (id: string, published: boolean) => {
|
||||||
|
updateMutation.mutate({ id, isPublished: published }, {
|
||||||
|
onSuccess: () => toast.success(published ? 'Published' : 'Unpublished'),
|
||||||
|
onError: () => toast.error('Failed to update'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -75,25 +240,20 @@ export default function LearningHubPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-9 w-32" />
|
<Skeleton className="h-9 w-32" />
|
||||||
</div>
|
</div>
|
||||||
{/* Toolbar skeleton */}
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<Skeleton className="h-10 flex-1" />
|
<Skeleton className="h-10 flex-1" />
|
||||||
<div className="flex items-center gap-2">
|
<Skeleton className="h-10 w-[160px]" />
|
||||||
<Skeleton className="h-10 w-[160px]" />
|
|
||||||
<Skeleton className="h-10 w-[160px]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Resource list skeleton */}
|
<div className="grid gap-3">
|
||||||
<div className="grid gap-4">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<Card key={i}>
|
<Card key={i}>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
<Skeleton className="h-4 w-4" />
|
||||||
|
<Skeleton className="h-9 w-9 rounded-lg" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Skeleton className="h-5 w-48" />
|
<Skeleton className="h-5 w-48" />
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="h-3 w-32" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-8 w-8 rounded" />
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -109,7 +269,7 @@ export default function LearningHubPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Learning Hub</h1>
|
<h1 className="text-2xl font-bold">Learning Hub</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Manage educational resources for jury members
|
Manage educational resources for program participants
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/learning/new">
|
<Link href="/admin/learning/new">
|
||||||
@@ -131,92 +291,49 @@ export default function LearningHubPage() {
|
|||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<Select value={publishedFilter} onValueChange={setPublishedFilter}>
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
<SelectTrigger className="w-[160px]">
|
||||||
<SelectTrigger className="w-[160px]">
|
<SelectValue placeholder="All" />
|
||||||
<SelectValue placeholder="All types" />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
<SelectItem value="all">All</SelectItem>
|
||||||
<SelectItem value="all">All types</SelectItem>
|
<SelectItem value="published">Published</SelectItem>
|
||||||
<SelectItem value="PDF">PDF</SelectItem>
|
<SelectItem value="draft">Drafts</SelectItem>
|
||||||
<SelectItem value="VIDEO">Video</SelectItem>
|
</SelectContent>
|
||||||
<SelectItem value="DOCUMENT">Document</SelectItem>
|
</Select>
|
||||||
<SelectItem value="LINK">Link</SelectItem>
|
|
||||||
<SelectItem value="OTHER">Other</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select value={cohortFilter} onValueChange={setCohortFilter}>
|
|
||||||
<SelectTrigger className="w-[160px]">
|
|
||||||
<SelectValue placeholder="All cohorts" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All cohorts</SelectItem>
|
|
||||||
<SelectItem value="ALL">All (cohort)</SelectItem>
|
|
||||||
<SelectItem value="SEMIFINALIST">Semifinalist</SelectItem>
|
|
||||||
<SelectItem value="FINALIST">Finalist</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
{resources && (
|
{resources.length > 0 && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{filteredResources.length} of {resources.length} resources
|
{filteredResources.length} of {resources.length} resources
|
||||||
|
{reorderMutation.isPending && ' · Saving order...'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resource List */}
|
{/* Resource List with DnD */}
|
||||||
{filteredResources.length > 0 ? (
|
{filteredResources.length > 0 ? (
|
||||||
<div className="grid gap-4">
|
<DndContext
|
||||||
{filteredResources.map((resource) => {
|
sensors={sensors}
|
||||||
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
|
collisionDetection={closestCenter}
|
||||||
return (
|
onDragEnd={handleDragEnd}
|
||||||
<Card key={resource.id}>
|
>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<SortableContext
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
items={filteredResources.map((r) => r.id)}
|
||||||
<Icon className="h-5 w-5" />
|
strategy={verticalListSortingStrategy}
|
||||||
</div>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="grid gap-3">
|
||||||
<div className="flex items-center gap-2">
|
{filteredResources.map((resource) => (
|
||||||
<h3 className="font-medium truncate">{resource.title}</h3>
|
<SortableResourceCard
|
||||||
{!resource.isPublished && (
|
key={resource.id}
|
||||||
<Badge variant="secondary">Draft</Badge>
|
resource={resource}
|
||||||
)}
|
onTogglePublished={handleTogglePublished}
|
||||||
</div>
|
/>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
))}
|
||||||
<Badge className={cohortColors[resource.cohortLevel] || ''} variant="outline">
|
</div>
|
||||||
{resource.cohortLevel}
|
</SortableContext>
|
||||||
</Badge>
|
</DndContext>
|
||||||
<span>{resource.resourceType}</span>
|
) : resources.length > 0 ? (
|
||||||
<span>-</span>
|
|
||||||
<span>{resource._count.accessLogs} views</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{resource.externalUrl && (
|
|
||||||
<a
|
|
||||||
href={resource.externalUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<Link href={`/admin/learning/${resource.id}`}>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : resources && resources.length > 0 ? (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useCallback, useMemo } from 'react'
|
import { useState, useCallback, useMemo } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import Papa from 'papaparse'
|
import Papa from 'papaparse'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -257,6 +258,7 @@ function TagPicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MemberInvitePage() {
|
export default function MemberInvitePage() {
|
||||||
|
const router = useRouter()
|
||||||
const [step, setStep] = useState<Step>('input')
|
const [step, setStep] = useState<Step>('input')
|
||||||
const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual')
|
const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual')
|
||||||
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
|
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
|
||||||
@@ -1044,11 +1046,9 @@ export default function MemberInvitePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/members">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Members
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -83,6 +83,7 @@ const defaultForm: TemplateFormData = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageTemplatesPage() {
|
export default function MessageTemplatesPage() {
|
||||||
|
const router = useRouter()
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
@@ -183,11 +184,9 @@ export default function MessageTemplatesPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||||
<Link href="/admin/messages">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
Back
|
||||||
Back to Messages
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user