Compare commits
No commits in common. "c321d4711e001df8841aa5909b11b887ad72aa24" and "9ee767b6cd407ed9e5d5b241b934cb58288b3a07" have entirely different histories.
c321d4711e
...
9ee767b6cd
|
|
@ -1,12 +0,0 @@
|
|||
* text=auto
|
||||
|
||||
# Deployment/runtime scripts must stay LF for Linux containers/shells.
|
||||
*.sh text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
**/Dockerfile text eol=lf
|
||||
|
||||
# Keep YAML and env-ish config files LF across platforms.
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.env text eol=lf
|
||||
*.sql text eol=lf
|
||||
|
|
@ -6,8 +6,8 @@ on:
|
|||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: code.monaco-opc.com
|
||||
IMAGE_NAME: mopc/mopc-portal
|
||||
REGISTRY: code.letsbe.solutions
|
||||
IMAGE_NAME: letsbe/mopc-app
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
|
|||
776
CLAUDE.md
776
CLAUDE.md
|
|
@ -1,388 +1,388 @@
|
|||
# MOPC Platform - Claude Code Context
|
||||
|
||||
## Project Overview
|
||||
|
||||
**MOPC (Monaco Ocean Protection Challenge)** is a secure jury online voting platform for managing project selection rounds. The platform enables jury members to evaluate submitted ocean conservation projects, with Phase 1 supporting two selection rounds:
|
||||
|
||||
- **Round 1**: ~130 projects → ~60 semi-finalists
|
||||
- **Round 2**: ~60 projects → 6 finalists
|
||||
|
||||
**Domain**: `monaco-opc.com`
|
||||
|
||||
The platform is designed for future expansion into a comprehensive program management system including learning hub, communication workflows, and partner modules.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Choice |
|
||||
|----------|--------|
|
||||
| Evaluation Criteria | Fully configurable per round (admin defines) |
|
||||
| CSV Import | Flexible column mapping (admin maps columns) |
|
||||
| Max File Size | 500MB (for videos) |
|
||||
| Observer Role | Included in Phase 1 |
|
||||
| First Admin | Database seed script |
|
||||
| Past Evaluations | Visible read-only after submit |
|
||||
| Grace Period | Admin-configurable per juror/project |
|
||||
| Smart Assignment | AI-powered (GPT) + Smart Algorithm fallback + geo-diversity, familiarity, COI scoring |
|
||||
| AI Data Privacy | All data anonymized before sending to GPT |
|
||||
| Evaluation Criteria Types | `numeric`, `text`, `boolean`, `section_header` (backward-compatible) |
|
||||
| COI Workflow | Mandatory declaration before evaluation, admin review |
|
||||
| Evaluation Reminders | Cron-based email reminders with countdown urgency |
|
||||
|
||||
## Brand Identity
|
||||
|
||||
| Name | Hex | Usage |
|
||||
|------|-----|-------|
|
||||
| Primary Red | `#de0f1e` | CTAs, alerts |
|
||||
| Dark Blue | `#053d57` | Headers, sidebar |
|
||||
| White | `#fefefe` | Backgrounds |
|
||||
| Teal | `#557f8c` | Links, secondary |
|
||||
|
||||
**Typography**: Montserrat (600/700 for headings, 300/400 for body)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology | Version |
|
||||
|-------|-----------|---------|
|
||||
| **Framework** | Next.js (App Router) | 15.x |
|
||||
| **Language** | TypeScript | 5.x |
|
||||
| **UI Components** | shadcn/ui | latest |
|
||||
| **Styling** | Tailwind CSS | 3.x |
|
||||
| **API Layer** | tRPC | 11.x |
|
||||
| **Database** | PostgreSQL | 16.x |
|
||||
| **ORM** | Prisma | 6.x |
|
||||
| **Authentication** | NextAuth.js (Auth.js) | 5.x |
|
||||
| **AI** | OpenAI GPT | 4.x SDK |
|
||||
| **Animation** | Motion (Framer Motion) | 11.x |
|
||||
| **Notifications** | Sonner | 1.x |
|
||||
| **Command Palette** | cmdk | 1.x |
|
||||
| **File Storage** | MinIO (S3-compatible) | External |
|
||||
| **Email** | Nodemailer + Poste.io | External |
|
||||
| **Containerization** | Docker Compose | 2.x |
|
||||
| **Reverse Proxy** | Nginx | External |
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
1. **Type Safety First**: End-to-end TypeScript from database to UI via Prisma → tRPC → React
|
||||
2. **Mobile-First Responsive**: All components designed for mobile, enhanced for desktop
|
||||
3. **Full Control**: No black-box services; every component is understood and maintainable
|
||||
4. **Extensible Data Model**: JSON fields for future attributes without schema migrations
|
||||
5. **Security by Default**: RBAC, audit logging, secure file access with pre-signed URLs
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
mopc-platform/
|
||||
├── CLAUDE.md # This file - project context
|
||||
├── docs/
|
||||
│ └── architecture/ # Architecture documentation
|
||||
│ ├── README.md # System overview
|
||||
│ ├── database.md # Database design
|
||||
│ ├── api.md # API design
|
||||
│ ├── infrastructure.md # Deployment docs
|
||||
│ └── ui.md # UI/UX patterns
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router pages
|
||||
│ │ ├── (auth)/ # Public auth routes (login, verify)
|
||||
│ │ ├── (admin)/ # Admin dashboard (protected)
|
||||
│ │ ├── (jury)/ # Jury interface (protected)
|
||||
│ │ ├── api/ # API routes
|
||||
│ │ │ ├── trpc/ # tRPC endpoint
|
||||
│ │ │ └── cron/
|
||||
│ │ │ └── reminders/ # Cron endpoint for evaluation reminders (F4)
|
||||
│ │ ├── layout.tsx # Root layout
|
||||
│ │ └── page.tsx # Home/landing
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # shadcn/ui components
|
||||
│ │ ├── admin/ # Admin-specific components
|
||||
│ │ │ └── evaluation-summary-card.tsx # AI summary display
|
||||
│ │ ├── forms/ # Form components
|
||||
│ │ │ ├── evaluation-form.tsx # With progress indicator (F1)
|
||||
│ │ │ ├── coi-declaration-dialog.tsx # COI blocking dialog (F5)
|
||||
│ │ │ └── evaluation-form-with-coi.tsx # COI-gated wrapper (F5)
|
||||
│ │ ├── layouts/ # Layout components (sidebar, nav)
|
||||
│ │ └── shared/ # Shared components
|
||||
│ │ └── countdown-timer.tsx # Live countdown with urgency (F4)
|
||||
│ ├── lib/
|
||||
│ │ ├── auth.ts # NextAuth configuration
|
||||
│ │ ├── prisma.ts # Prisma client singleton
|
||||
│ │ ├── trpc/ # tRPC client & server setup
|
||||
│ │ ├── minio.ts # MinIO client
|
||||
│ │ └── email.ts # Email utilities
|
||||
│ ├── server/
|
||||
│ │ ├── routers/ # tRPC routers by domain
|
||||
│ │ │ ├── program.ts
|
||||
│ │ │ ├── round.ts
|
||||
│ │ │ ├── project.ts
|
||||
│ │ │ ├── user.ts
|
||||
│ │ │ ├── assignment.ts
|
||||
│ │ │ ├── evaluation.ts
|
||||
│ │ │ ├── audit.ts
|
||||
│ │ │ ├── settings.ts
|
||||
│ │ │ ├── gracePeriod.ts
|
||||
│ │ │ ├── export.ts # CSV export incl. filtering results (F2)
|
||||
│ │ │ ├── analytics.ts # Reports/analytics (observer access, F3)
|
||||
│ │ │ └── mentor.ts # Mentor messaging endpoints (F10)
|
||||
│ │ ├── services/ # Business logic services
|
||||
│ │ │ ├── smart-assignment.ts # With geo/familiarity/COI scoring (F8)
|
||||
│ │ │ ├── evaluation-reminders.ts # Email reminder service (F4)
|
||||
│ │ │ └── ai-evaluation-summary.ts # GPT summary generation (F7)
|
||||
│ │ └── middleware/ # RBAC & auth middleware
|
||||
│ ├── hooks/ # React hooks
|
||||
│ ├── types/ # Shared TypeScript types
|
||||
│ └── utils/ # Utility functions
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # Database schema
|
||||
│ ├── migrations/ # Migration files
|
||||
│ └── seed.ts # Seed data
|
||||
├── public/ # Static assets
|
||||
├── docker/
|
||||
│ ├── Dockerfile # Production build
|
||||
│ ├── docker-compose.yml # Production stack
|
||||
│ └── docker-compose.dev.yml # Development stack
|
||||
├── tests/
|
||||
│ ├── unit/ # Unit tests
|
||||
│ └── e2e/ # End-to-end tests
|
||||
└── config files... # package.json, tsconfig, etc.
|
||||
```
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### TypeScript
|
||||
- Strict mode enabled
|
||||
- Explicit return types for functions
|
||||
- Use `type` over `interface` for consistency (unless extending)
|
||||
- Prefer `unknown` over `any`
|
||||
|
||||
### React/Next.js
|
||||
- Use Server Components by default
|
||||
- `'use client'` only when needed (interactivity, hooks)
|
||||
- Collocate components with their routes when specific to that route
|
||||
- Use React Query (via tRPC) for server state
|
||||
|
||||
### Naming Conventions
|
||||
- **Files**: kebab-case (`user-profile.tsx`)
|
||||
- **Components**: PascalCase (`UserProfile`)
|
||||
- **Functions/Variables**: camelCase (`getUserById`)
|
||||
- **Constants**: SCREAMING_SNAKE_CASE (`MAX_FILE_SIZE`)
|
||||
- **Database Tables**: PascalCase in Prisma (`User`, `Project`)
|
||||
- **Database Columns**: camelCase in Prisma (`createdAt`)
|
||||
|
||||
### Styling
|
||||
- Tailwind CSS utility classes
|
||||
- Mobile-first: base styles for mobile, `md:` for tablet, `lg:` for desktop
|
||||
- Use shadcn/ui components as base, customize via CSS variables
|
||||
- No inline styles; no separate CSS files unless absolutely necessary
|
||||
|
||||
### API Design (tRPC)
|
||||
- Group by domain: `trpc.program.create()`, `trpc.round.list()`
|
||||
- Use Zod for input validation
|
||||
- Return consistent response shapes
|
||||
- Throw `TRPCError` with appropriate codes
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start Next.js dev server
|
||||
npm run db:studio # Open Prisma Studio
|
||||
npm run db:push # Push schema changes (dev only)
|
||||
npm run db:migrate # Run migrations
|
||||
npm run db:seed # Seed database
|
||||
|
||||
# Testing
|
||||
npm run test # Run unit tests
|
||||
npm run test:e2e # Run E2E tests
|
||||
npm run test:coverage # Test with coverage
|
||||
|
||||
# Build & Deploy
|
||||
npm run build # Production build
|
||||
npm run start # Start production server
|
||||
docker compose up -d # Start Docker stack
|
||||
docker compose logs -f app # View app logs
|
||||
|
||||
# Code Quality
|
||||
npm run lint # ESLint
|
||||
npm run format # Prettier
|
||||
npm run typecheck # TypeScript check
|
||||
```
|
||||
|
||||
## Windows Development Notes
|
||||
|
||||
**IMPORTANT**: On Windows, all Docker commands AND all npm/node commands must be run using PowerShell (`powershell -ExecutionPolicy Bypass -Command "..."`), not bash/cmd. This is required for proper Docker Desktop integration and Node.js execution.
|
||||
|
||||
**IMPORTANT**: When invoking PowerShell from bash, always use `-ExecutionPolicy Bypass` to skip the user profile script which is blocked by execution policy:
|
||||
```bash
|
||||
powershell -ExecutionPolicy Bypass -Command "..."
|
||||
```
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
# npm commands
|
||||
powershell -ExecutionPolicy Bypass -Command "npm install"
|
||||
powershell -ExecutionPolicy Bypass -Command "npm run build"
|
||||
powershell -ExecutionPolicy Bypass -Command "npx prisma generate"
|
||||
|
||||
# Docker commands
|
||||
powershell -ExecutionPolicy Bypass -Command "docker compose -f docker/docker-compose.dev.yml up -d"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Docker commands on Windows (use PowerShell)
|
||||
docker compose -f docker/docker-compose.dev.yml up -d
|
||||
docker compose -f docker/docker-compose.dev.yml build --no-cache app
|
||||
docker compose -f docker/docker-compose.dev.yml logs -f app
|
||||
docker compose -f docker/docker-compose.dev.yml down
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL="postgresql://user:pass@localhost:5432/mopc"
|
||||
|
||||
# NextAuth
|
||||
NEXTAUTH_URL="https://monaco-opc.com"
|
||||
NEXTAUTH_SECRET="your-secret-key"
|
||||
|
||||
# MinIO (existing separate stack)
|
||||
MINIO_ENDPOINT="http://localhost:9000"
|
||||
MINIO_ACCESS_KEY="your-access-key"
|
||||
MINIO_SECRET_KEY="your-secret-key"
|
||||
MINIO_BUCKET="mopc-files"
|
||||
|
||||
# Email (Poste.io - existing)
|
||||
SMTP_HOST="localhost"
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER="noreply@monaco-opc.com"
|
||||
SMTP_PASS="your-smtp-password"
|
||||
EMAIL_FROM="MOPC Platform <noreply@monaco-opc.com>"
|
||||
|
||||
# OpenAI (for smart assignment and AI evaluation summaries)
|
||||
OPENAI_API_KEY="your-openai-api-key"
|
||||
|
||||
# Cron (for scheduled evaluation reminders)
|
||||
CRON_SECRET="your-cron-secret-key"
|
||||
```
|
||||
|
||||
## Key Architectural Decisions
|
||||
|
||||
### 1. Next.js App Router over Pages Router
|
||||
**Rationale**: Server Components reduce client bundle, better data fetching patterns, layouts system
|
||||
|
||||
### 2. tRPC over REST
|
||||
**Rationale**: End-to-end type safety without code generation, excellent DX with autocomplete
|
||||
|
||||
### 3. Prisma over raw SQL
|
||||
**Rationale**: Type-safe queries, migration system, works seamlessly with TypeScript
|
||||
|
||||
### 4. NextAuth.js over custom auth
|
||||
**Rationale**: Battle-tested, supports magic links, session management built-in
|
||||
|
||||
### 5. MinIO (external) over local file storage
|
||||
**Rationale**: S3-compatible, pre-signed URLs for security, scalable, already deployed
|
||||
|
||||
### 6. JSON fields for extensibility
|
||||
**Rationale**: `metadata_json`, `settings_json` allow adding attributes without migrations
|
||||
|
||||
### 7. Soft deletes with status fields
|
||||
**Rationale**: Audit trail preservation, recovery capability, referential integrity
|
||||
|
||||
## User Roles (RBAC)
|
||||
|
||||
| Role | Permissions |
|
||||
|------|------------|
|
||||
| **SUPER_ADMIN** | Full system access, all programs, user management |
|
||||
| **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury |
|
||||
| **JURY_MEMBER** | View assigned projects only, submit evaluations, declare COI |
|
||||
| **OBSERVER** | Read-only access to dashboards, all analytics/reports |
|
||||
| **MENTOR** | View assigned projects, message applicants via `mentorProcedure` |
|
||||
| **APPLICANT** | View own project status, upload documents per round, message mentor |
|
||||
|
||||
## Important Constraints
|
||||
|
||||
1. **Jury can only see assigned projects** - enforced at query level
|
||||
2. **Voting windows are strict** - submissions blocked outside active window
|
||||
3. **Evaluations are versioned** - edits create new versions
|
||||
4. **All admin actions are audited** - immutable audit log
|
||||
5. **Files accessed via pre-signed URLs** - no public bucket access
|
||||
6. **Mobile responsiveness is mandatory** - every view must work on phones
|
||||
7. **File downloads require project authorization** - jury/mentor must be assigned to the project
|
||||
8. **Mentor endpoints require MENTOR role** - uses `mentorProcedure` middleware
|
||||
9. **COI declaration required before evaluation** - blocking dialog gates evaluation form; admin reviews COI declarations
|
||||
10. **Evaluation form supports multiple criterion types** - `numeric`, `text`, `boolean`, `section_header`; defaults to `numeric` for backward compatibility
|
||||
11. **Smart assignment respects COI** - jurors with declared conflicts are skipped entirely; geo-diversity penalty and prior-round familiarity bonus applied
|
||||
12. **Cron endpoints protected by CRON_SECRET** - `/api/cron/reminders` validates secret header
|
||||
13. **Project status changes tracked** - every status update creates a `ProjectStatusHistory` record
|
||||
14. **Per-round document management** - `ProjectFile` supports `roundId` scoping and `isLate` deadline tracking
|
||||
|
||||
## Security Notes
|
||||
|
||||
### CSRF Protection
|
||||
tRPC mutations are protected against CSRF attacks because:
|
||||
- tRPC uses `application/json` content type, which triggers CORS preflight on cross-origin requests
|
||||
- Browsers block cross-origin JSON POSTs by default (Same-Origin Policy)
|
||||
- NextAuth's own routes (`/api/auth/*`) have built-in CSRF token protection
|
||||
- No custom CORS headers are configured to allow external origins
|
||||
|
||||
**Do NOT add permissive CORS headers** (e.g., `Access-Control-Allow-Origin: *`) without also implementing explicit CSRF token validation on all mutation endpoints.
|
||||
|
||||
### Rate Limiting
|
||||
- tRPC API: 100 requests/minute per IP
|
||||
- Auth endpoints: 10 POST requests/minute per IP
|
||||
- Account lockout: 5 failed password attempts triggers 15-minute lockout
|
||||
|
||||
## External Services (Pre-existing)
|
||||
|
||||
These services are already running on the VPS in separate Docker Compose stacks:
|
||||
|
||||
- **MinIO**: `http://localhost:9000` - S3-compatible storage
|
||||
- **Poste.io**: `localhost:587` - SMTP server for emails
|
||||
- **Nginx**: Host-level reverse proxy with SSL (certbot)
|
||||
|
||||
The MOPC platform connects to these via environment variables.
|
||||
|
||||
## Phase 1 Scope
|
||||
|
||||
### In Scope
|
||||
- Round management (create, configure, activate/close)
|
||||
- Project import (CSV) and file uploads
|
||||
- Jury invitation (magic link)
|
||||
- Manual project assignment (single + bulk)
|
||||
- Evaluation form (configurable criteria)
|
||||
- Autosave + final submit
|
||||
- Voting window enforcement
|
||||
- Progress dashboards
|
||||
- CSV export
|
||||
- Audit logging
|
||||
- **F1: Evaluation progress indicator** - sticky status bar with percentage tracking across criteria, global score, decision, feedback
|
||||
- **F2: Export filtering results as CSV** - dynamic AI column flattening from `aiScreeningJson`
|
||||
- **F3: Observer access to reports/analytics** - all 8 analytics procedures use `observerProcedure`; observer reports page with round selector, tabs, charts
|
||||
- **F4: Countdown timer + email reminders** - live countdown with urgency colors; `EvaluationRemindersService` with cron endpoint (`/api/cron/reminders`)
|
||||
- **F5: Conflict of Interest declaration** - `ConflictOfInterest` model; blocking dialog before evaluation; admin COI review page
|
||||
- **F6: Bulk status update UI** - checkbox selection, floating toolbar, `ProjectStatusHistory` tracking
|
||||
- **F7: AI-powered evaluation summary** - `EvaluationSummary` model; GPT-generated strengths/weaknesses, themes, scoring stats
|
||||
- **F8: Smart assignment improvements** - `geoDiversityPenalty`, `previousRoundFamiliarity`, `coiPenalty` scoring factors
|
||||
- **F9: Evaluation form flexibility** - extended criterion types (`numeric`, `text`, `boolean`, `section_header`); conditional visibility, section grouping
|
||||
- **F10: Applicant portal enhancements** - `ProjectStatusHistory` timeline; per-round document management (`roundId` + `isLate` on `ProjectFile`); `MentorMessage` model for mentor-applicant chat
|
||||
|
||||
### Out of Scope (Phase 2+)
|
||||
- Typeform/Notion integrations
|
||||
- WhatsApp notifications
|
||||
- Learning hub
|
||||
- Partner modules
|
||||
- Public website
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit Tests**: Business logic, utilities, validators
|
||||
- **Integration Tests**: tRPC routers with test database
|
||||
- **E2E Tests**: Critical user flows (Playwright)
|
||||
- **Manual Testing**: Responsive design on real devices
|
||||
|
||||
## Documentation Links
|
||||
|
||||
- [Architecture Overview](./docs/architecture/README.md)
|
||||
- [Database Design](./docs/architecture/database.md)
|
||||
- [API Design](./docs/architecture/api.md)
|
||||
- [Infrastructure](./docs/architecture/infrastructure.md)
|
||||
- [UI/UX Patterns](./docs/architecture/ui.md)
|
||||
# MOPC Platform - Claude Code Context
|
||||
|
||||
## Project Overview
|
||||
|
||||
**MOPC (Monaco Ocean Protection Challenge)** is a secure jury online voting platform for managing project selection rounds. The platform enables jury members to evaluate submitted ocean conservation projects, with Phase 1 supporting two selection rounds:
|
||||
|
||||
- **Round 1**: ~130 projects → ~60 semi-finalists
|
||||
- **Round 2**: ~60 projects → 6 finalists
|
||||
|
||||
**Domain**: `monaco-opc.com`
|
||||
|
||||
The platform is designed for future expansion into a comprehensive program management system including learning hub, communication workflows, and partner modules.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Choice |
|
||||
|----------|--------|
|
||||
| Evaluation Criteria | Fully configurable per round (admin defines) |
|
||||
| CSV Import | Flexible column mapping (admin maps columns) |
|
||||
| Max File Size | 500MB (for videos) |
|
||||
| Observer Role | Included in Phase 1 |
|
||||
| First Admin | Database seed script |
|
||||
| Past Evaluations | Visible read-only after submit |
|
||||
| Grace Period | Admin-configurable per juror/project |
|
||||
| Smart Assignment | AI-powered (GPT) + Smart Algorithm fallback + geo-diversity, familiarity, COI scoring |
|
||||
| AI Data Privacy | All data anonymized before sending to GPT |
|
||||
| Evaluation Criteria Types | `numeric`, `text`, `boolean`, `section_header` (backward-compatible) |
|
||||
| COI Workflow | Mandatory declaration before evaluation, admin review |
|
||||
| Evaluation Reminders | Cron-based email reminders with countdown urgency |
|
||||
|
||||
## Brand Identity
|
||||
|
||||
| Name | Hex | Usage |
|
||||
|------|-----|-------|
|
||||
| Primary Red | `#de0f1e` | CTAs, alerts |
|
||||
| Dark Blue | `#053d57` | Headers, sidebar |
|
||||
| White | `#fefefe` | Backgrounds |
|
||||
| Teal | `#557f8c` | Links, secondary |
|
||||
|
||||
**Typography**: Montserrat (600/700 for headings, 300/400 for body)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology | Version |
|
||||
|-------|-----------|---------|
|
||||
| **Framework** | Next.js (App Router) | 15.x |
|
||||
| **Language** | TypeScript | 5.x |
|
||||
| **UI Components** | shadcn/ui | latest |
|
||||
| **Styling** | Tailwind CSS | 3.x |
|
||||
| **API Layer** | tRPC | 11.x |
|
||||
| **Database** | PostgreSQL | 16.x |
|
||||
| **ORM** | Prisma | 6.x |
|
||||
| **Authentication** | NextAuth.js (Auth.js) | 5.x |
|
||||
| **AI** | OpenAI GPT | 4.x SDK |
|
||||
| **Animation** | Motion (Framer Motion) | 11.x |
|
||||
| **Notifications** | Sonner | 1.x |
|
||||
| **Command Palette** | cmdk | 1.x |
|
||||
| **File Storage** | MinIO (S3-compatible) | External |
|
||||
| **Email** | Nodemailer + Poste.io | External |
|
||||
| **Containerization** | Docker Compose | 2.x |
|
||||
| **Reverse Proxy** | Nginx | External |
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
1. **Type Safety First**: End-to-end TypeScript from database to UI via Prisma → tRPC → React
|
||||
2. **Mobile-First Responsive**: All components designed for mobile, enhanced for desktop
|
||||
3. **Full Control**: No black-box services; every component is understood and maintainable
|
||||
4. **Extensible Data Model**: JSON fields for future attributes without schema migrations
|
||||
5. **Security by Default**: RBAC, audit logging, secure file access with pre-signed URLs
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
mopc-platform/
|
||||
├── CLAUDE.md # This file - project context
|
||||
├── docs/
|
||||
│ └── architecture/ # Architecture documentation
|
||||
│ ├── README.md # System overview
|
||||
│ ├── database.md # Database design
|
||||
│ ├── api.md # API design
|
||||
│ ├── infrastructure.md # Deployment docs
|
||||
│ └── ui.md # UI/UX patterns
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router pages
|
||||
│ │ ├── (auth)/ # Public auth routes (login, verify)
|
||||
│ │ ├── (admin)/ # Admin dashboard (protected)
|
||||
│ │ ├── (jury)/ # Jury interface (protected)
|
||||
│ │ ├── api/ # API routes
|
||||
│ │ │ ├── trpc/ # tRPC endpoint
|
||||
│ │ │ └── cron/
|
||||
│ │ │ └── reminders/ # Cron endpoint for evaluation reminders (F4)
|
||||
│ │ ├── layout.tsx # Root layout
|
||||
│ │ └── page.tsx # Home/landing
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # shadcn/ui components
|
||||
│ │ ├── admin/ # Admin-specific components
|
||||
│ │ │ └── evaluation-summary-card.tsx # AI summary display
|
||||
│ │ ├── forms/ # Form components
|
||||
│ │ │ ├── evaluation-form.tsx # With progress indicator (F1)
|
||||
│ │ │ ├── coi-declaration-dialog.tsx # COI blocking dialog (F5)
|
||||
│ │ │ └── evaluation-form-with-coi.tsx # COI-gated wrapper (F5)
|
||||
│ │ ├── layouts/ # Layout components (sidebar, nav)
|
||||
│ │ └── shared/ # Shared components
|
||||
│ │ └── countdown-timer.tsx # Live countdown with urgency (F4)
|
||||
│ ├── lib/
|
||||
│ │ ├── auth.ts # NextAuth configuration
|
||||
│ │ ├── prisma.ts # Prisma client singleton
|
||||
│ │ ├── trpc/ # tRPC client & server setup
|
||||
│ │ ├── minio.ts # MinIO client
|
||||
│ │ └── email.ts # Email utilities
|
||||
│ ├── server/
|
||||
│ │ ├── routers/ # tRPC routers by domain
|
||||
│ │ │ ├── program.ts
|
||||
│ │ │ ├── round.ts
|
||||
│ │ │ ├── project.ts
|
||||
│ │ │ ├── user.ts
|
||||
│ │ │ ├── assignment.ts
|
||||
│ │ │ ├── evaluation.ts
|
||||
│ │ │ ├── audit.ts
|
||||
│ │ │ ├── settings.ts
|
||||
│ │ │ ├── gracePeriod.ts
|
||||
│ │ │ ├── export.ts # CSV export incl. filtering results (F2)
|
||||
│ │ │ ├── analytics.ts # Reports/analytics (observer access, F3)
|
||||
│ │ │ └── mentor.ts # Mentor messaging endpoints (F10)
|
||||
│ │ ├── services/ # Business logic services
|
||||
│ │ │ ├── smart-assignment.ts # With geo/familiarity/COI scoring (F8)
|
||||
│ │ │ ├── evaluation-reminders.ts # Email reminder service (F4)
|
||||
│ │ │ └── ai-evaluation-summary.ts # GPT summary generation (F7)
|
||||
│ │ └── middleware/ # RBAC & auth middleware
|
||||
│ ├── hooks/ # React hooks
|
||||
│ ├── types/ # Shared TypeScript types
|
||||
│ └── utils/ # Utility functions
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # Database schema
|
||||
│ ├── migrations/ # Migration files
|
||||
│ └── seed.ts # Seed data
|
||||
├── public/ # Static assets
|
||||
├── docker/
|
||||
│ ├── Dockerfile # Production build
|
||||
│ ├── docker-compose.yml # Production stack
|
||||
│ └── docker-compose.dev.yml # Development stack
|
||||
├── tests/
|
||||
│ ├── unit/ # Unit tests
|
||||
│ └── e2e/ # End-to-end tests
|
||||
└── config files... # package.json, tsconfig, etc.
|
||||
```
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### TypeScript
|
||||
- Strict mode enabled
|
||||
- Explicit return types for functions
|
||||
- Use `type` over `interface` for consistency (unless extending)
|
||||
- Prefer `unknown` over `any`
|
||||
|
||||
### React/Next.js
|
||||
- Use Server Components by default
|
||||
- `'use client'` only when needed (interactivity, hooks)
|
||||
- Collocate components with their routes when specific to that route
|
||||
- Use React Query (via tRPC) for server state
|
||||
|
||||
### Naming Conventions
|
||||
- **Files**: kebab-case (`user-profile.tsx`)
|
||||
- **Components**: PascalCase (`UserProfile`)
|
||||
- **Functions/Variables**: camelCase (`getUserById`)
|
||||
- **Constants**: SCREAMING_SNAKE_CASE (`MAX_FILE_SIZE`)
|
||||
- **Database Tables**: PascalCase in Prisma (`User`, `Project`)
|
||||
- **Database Columns**: camelCase in Prisma (`createdAt`)
|
||||
|
||||
### Styling
|
||||
- Tailwind CSS utility classes
|
||||
- Mobile-first: base styles for mobile, `md:` for tablet, `lg:` for desktop
|
||||
- Use shadcn/ui components as base, customize via CSS variables
|
||||
- No inline styles; no separate CSS files unless absolutely necessary
|
||||
|
||||
### API Design (tRPC)
|
||||
- Group by domain: `trpc.program.create()`, `trpc.round.list()`
|
||||
- Use Zod for input validation
|
||||
- Return consistent response shapes
|
||||
- Throw `TRPCError` with appropriate codes
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start Next.js dev server
|
||||
npm run db:studio # Open Prisma Studio
|
||||
npm run db:push # Push schema changes (dev only)
|
||||
npm run db:migrate # Run migrations
|
||||
npm run db:seed # Seed database
|
||||
|
||||
# Testing
|
||||
npm run test # Run unit tests
|
||||
npm run test:e2e # Run E2E tests
|
||||
npm run test:coverage # Test with coverage
|
||||
|
||||
# Build & Deploy
|
||||
npm run build # Production build
|
||||
npm run start # Start production server
|
||||
docker compose up -d # Start Docker stack
|
||||
docker compose logs -f app # View app logs
|
||||
|
||||
# Code Quality
|
||||
npm run lint # ESLint
|
||||
npm run format # Prettier
|
||||
npm run typecheck # TypeScript check
|
||||
```
|
||||
|
||||
## Windows Development Notes
|
||||
|
||||
**IMPORTANT**: On Windows, all Docker commands AND all npm/node commands must be run using PowerShell (`powershell -ExecutionPolicy Bypass -Command "..."`), not bash/cmd. This is required for proper Docker Desktop integration and Node.js execution.
|
||||
|
||||
**IMPORTANT**: When invoking PowerShell from bash, always use `-ExecutionPolicy Bypass` to skip the user profile script which is blocked by execution policy:
|
||||
```bash
|
||||
powershell -ExecutionPolicy Bypass -Command "..."
|
||||
```
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
# npm commands
|
||||
powershell -ExecutionPolicy Bypass -Command "npm install"
|
||||
powershell -ExecutionPolicy Bypass -Command "npm run build"
|
||||
powershell -ExecutionPolicy Bypass -Command "npx prisma generate"
|
||||
|
||||
# Docker commands
|
||||
powershell -ExecutionPolicy Bypass -Command "docker compose -f docker/docker-compose.dev.yml up -d"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Docker commands on Windows (use PowerShell)
|
||||
docker compose -f docker/docker-compose.dev.yml up -d
|
||||
docker compose -f docker/docker-compose.dev.yml build --no-cache app
|
||||
docker compose -f docker/docker-compose.dev.yml logs -f app
|
||||
docker compose -f docker/docker-compose.dev.yml down
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL="postgresql://user:pass@localhost:5432/mopc"
|
||||
|
||||
# NextAuth
|
||||
NEXTAUTH_URL="https://monaco-opc.com"
|
||||
NEXTAUTH_SECRET="your-secret-key"
|
||||
|
||||
# MinIO (existing separate stack)
|
||||
MINIO_ENDPOINT="http://localhost:9000"
|
||||
MINIO_ACCESS_KEY="your-access-key"
|
||||
MINIO_SECRET_KEY="your-secret-key"
|
||||
MINIO_BUCKET="mopc-files"
|
||||
|
||||
# Email (Poste.io - existing)
|
||||
SMTP_HOST="localhost"
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER="noreply@monaco-opc.com"
|
||||
SMTP_PASS="your-smtp-password"
|
||||
EMAIL_FROM="MOPC Platform <noreply@monaco-opc.com>"
|
||||
|
||||
# OpenAI (for smart assignment and AI evaluation summaries)
|
||||
OPENAI_API_KEY="your-openai-api-key"
|
||||
|
||||
# Cron (for scheduled evaluation reminders)
|
||||
CRON_SECRET="your-cron-secret-key"
|
||||
```
|
||||
|
||||
## Key Architectural Decisions
|
||||
|
||||
### 1. Next.js App Router over Pages Router
|
||||
**Rationale**: Server Components reduce client bundle, better data fetching patterns, layouts system
|
||||
|
||||
### 2. tRPC over REST
|
||||
**Rationale**: End-to-end type safety without code generation, excellent DX with autocomplete
|
||||
|
||||
### 3. Prisma over raw SQL
|
||||
**Rationale**: Type-safe queries, migration system, works seamlessly with TypeScript
|
||||
|
||||
### 4. NextAuth.js over custom auth
|
||||
**Rationale**: Battle-tested, supports magic links, session management built-in
|
||||
|
||||
### 5. MinIO (external) over local file storage
|
||||
**Rationale**: S3-compatible, pre-signed URLs for security, scalable, already deployed
|
||||
|
||||
### 6. JSON fields for extensibility
|
||||
**Rationale**: `metadata_json`, `settings_json` allow adding attributes without migrations
|
||||
|
||||
### 7. Soft deletes with status fields
|
||||
**Rationale**: Audit trail preservation, recovery capability, referential integrity
|
||||
|
||||
## User Roles (RBAC)
|
||||
|
||||
| Role | Permissions |
|
||||
|------|------------|
|
||||
| **SUPER_ADMIN** | Full system access, all programs, user management |
|
||||
| **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury |
|
||||
| **JURY_MEMBER** | View assigned projects only, submit evaluations, declare COI |
|
||||
| **OBSERVER** | Read-only access to dashboards, all analytics/reports |
|
||||
| **MENTOR** | View assigned projects, message applicants via `mentorProcedure` |
|
||||
| **APPLICANT** | View own project status, upload documents per round, message mentor |
|
||||
|
||||
## Important Constraints
|
||||
|
||||
1. **Jury can only see assigned projects** - enforced at query level
|
||||
2. **Voting windows are strict** - submissions blocked outside active window
|
||||
3. **Evaluations are versioned** - edits create new versions
|
||||
4. **All admin actions are audited** - immutable audit log
|
||||
5. **Files accessed via pre-signed URLs** - no public bucket access
|
||||
6. **Mobile responsiveness is mandatory** - every view must work on phones
|
||||
7. **File downloads require project authorization** - jury/mentor must be assigned to the project
|
||||
8. **Mentor endpoints require MENTOR role** - uses `mentorProcedure` middleware
|
||||
9. **COI declaration required before evaluation** - blocking dialog gates evaluation form; admin reviews COI declarations
|
||||
10. **Evaluation form supports multiple criterion types** - `numeric`, `text`, `boolean`, `section_header`; defaults to `numeric` for backward compatibility
|
||||
11. **Smart assignment respects COI** - jurors with declared conflicts are skipped entirely; geo-diversity penalty and prior-round familiarity bonus applied
|
||||
12. **Cron endpoints protected by CRON_SECRET** - `/api/cron/reminders` validates secret header
|
||||
13. **Project status changes tracked** - every status update creates a `ProjectStatusHistory` record
|
||||
14. **Per-round document management** - `ProjectFile` supports `roundId` scoping and `isLate` deadline tracking
|
||||
|
||||
## Security Notes
|
||||
|
||||
### CSRF Protection
|
||||
tRPC mutations are protected against CSRF attacks because:
|
||||
- tRPC uses `application/json` content type, which triggers CORS preflight on cross-origin requests
|
||||
- Browsers block cross-origin JSON POSTs by default (Same-Origin Policy)
|
||||
- NextAuth's own routes (`/api/auth/*`) have built-in CSRF token protection
|
||||
- No custom CORS headers are configured to allow external origins
|
||||
|
||||
**Do NOT add permissive CORS headers** (e.g., `Access-Control-Allow-Origin: *`) without also implementing explicit CSRF token validation on all mutation endpoints.
|
||||
|
||||
### Rate Limiting
|
||||
- tRPC API: 100 requests/minute per IP
|
||||
- Auth endpoints: 10 POST requests/minute per IP
|
||||
- Account lockout: 5 failed password attempts triggers 15-minute lockout
|
||||
|
||||
## External Services (Pre-existing)
|
||||
|
||||
These services are already running on the VPS in separate Docker Compose stacks:
|
||||
|
||||
- **MinIO**: `http://localhost:9000` - S3-compatible storage
|
||||
- **Poste.io**: `localhost:587` - SMTP server for emails
|
||||
- **Nginx**: Host-level reverse proxy with SSL (certbot)
|
||||
|
||||
The MOPC platform connects to these via environment variables.
|
||||
|
||||
## Phase 1 Scope
|
||||
|
||||
### In Scope
|
||||
- Round management (create, configure, activate/close)
|
||||
- Project import (CSV) and file uploads
|
||||
- Jury invitation (magic link)
|
||||
- Manual project assignment (single + bulk)
|
||||
- Evaluation form (configurable criteria)
|
||||
- Autosave + final submit
|
||||
- Voting window enforcement
|
||||
- Progress dashboards
|
||||
- CSV export
|
||||
- Audit logging
|
||||
- **F1: Evaluation progress indicator** - sticky status bar with percentage tracking across criteria, global score, decision, feedback
|
||||
- **F2: Export filtering results as CSV** - dynamic AI column flattening from `aiScreeningJson`
|
||||
- **F3: Observer access to reports/analytics** - all 8 analytics procedures use `observerProcedure`; observer reports page with round selector, tabs, charts
|
||||
- **F4: Countdown timer + email reminders** - live countdown with urgency colors; `EvaluationRemindersService` with cron endpoint (`/api/cron/reminders`)
|
||||
- **F5: Conflict of Interest declaration** - `ConflictOfInterest` model; blocking dialog before evaluation; admin COI review page
|
||||
- **F6: Bulk status update UI** - checkbox selection, floating toolbar, `ProjectStatusHistory` tracking
|
||||
- **F7: AI-powered evaluation summary** - `EvaluationSummary` model; GPT-generated strengths/weaknesses, themes, scoring stats
|
||||
- **F8: Smart assignment improvements** - `geoDiversityPenalty`, `previousRoundFamiliarity`, `coiPenalty` scoring factors
|
||||
- **F9: Evaluation form flexibility** - extended criterion types (`numeric`, `text`, `boolean`, `section_header`); conditional visibility, section grouping
|
||||
- **F10: Applicant portal enhancements** - `ProjectStatusHistory` timeline; per-round document management (`roundId` + `isLate` on `ProjectFile`); `MentorMessage` model for mentor-applicant chat
|
||||
|
||||
### Out of Scope (Phase 2+)
|
||||
- Typeform/Notion integrations
|
||||
- WhatsApp notifications
|
||||
- Learning hub
|
||||
- Partner modules
|
||||
- Public website
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit Tests**: Business logic, utilities, validators
|
||||
- **Integration Tests**: tRPC routers with test database
|
||||
- **E2E Tests**: Critical user flows (Playwright)
|
||||
- **Manual Testing**: Responsive design on real devices
|
||||
|
||||
## Documentation Links
|
||||
|
||||
- [Architecture Overview](./docs/architecture/README.md)
|
||||
- [Database Design](./docs/architecture/database.md)
|
||||
- [API Design](./docs/architecture/api.md)
|
||||
- [Infrastructure](./docs/architecture/infrastructure.md)
|
||||
- [UI/UX Patterns](./docs/architecture/ui.md)
|
||||
|
|
|
|||
654
DEPLOYMENT.md
654
DEPLOYMENT.md
|
|
@ -1,327 +1,327 @@
|
|||
# MOPC Platform - Server Deployment Guide
|
||||
|
||||
Deployment guide for the MOPC platform on a Linux VPS with Docker.
|
||||
|
||||
**Domain**: `portal.monaco-opc.com`
|
||||
**App Port**: 7600 (behind Nginx reverse proxy)
|
||||
**CI/CD**: Gitea Actions (Ubuntu runner) builds and pushes Docker images
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
The app is built automatically by a Gitea runner on every push to `main`:
|
||||
|
||||
1. Gitea Actions workflow builds the Docker image on Ubuntu
|
||||
2. Image is pushed to the Gitea container registry
|
||||
3. On the server, `docker compose up -d` refreshes the image and restarts the app
|
||||
|
||||
### Gitea Setup
|
||||
|
||||
Configure the following in your Gitea repository settings:
|
||||
|
||||
**Repository Variables** (Settings > Actions > Variables):
|
||||
|
||||
| Variable | Value |
|
||||
|----------|-------|
|
||||
| `REGISTRY_URL` | Your Gitea registry URL (e.g. `gitea.example.com/your-org`) |
|
||||
|
||||
**Repository Secrets** (Settings > Actions > Secrets):
|
||||
|
||||
| Secret | Value |
|
||||
|--------|-------|
|
||||
| `REGISTRY_USER` | Gitea username with registry access |
|
||||
| `REGISTRY_PASSWORD` | Gitea access token or password |
|
||||
|
||||
The workflow file is at `.gitea/workflows/build.yml`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Linux VPS (Ubuntu 22.04+ recommended)
|
||||
- Docker Engine 24+ with Compose v2
|
||||
- Nginx installed on the host
|
||||
- Certbot for SSL certificates
|
||||
|
||||
### Install Docker (if needed)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in
|
||||
```
|
||||
|
||||
### Install Nginx & Certbot (if needed)
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install nginx certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
## First-Time Deployment
|
||||
|
||||
### 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone <your-repo-url> /opt/mopc
|
||||
cd /opt/mopc
|
||||
```
|
||||
|
||||
### 2. Configure environment variables
|
||||
|
||||
```bash
|
||||
cp docker/.env.production docker/.env
|
||||
nano docker/.env
|
||||
```
|
||||
|
||||
Fill in all `CHANGE_ME` values. Generate secrets with:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
Required variables:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `REGISTRY_URL` | Gitea registry URL (e.g. `gitea.example.com/your-org`) |
|
||||
| `DB_PASSWORD` | PostgreSQL password |
|
||||
| `NEXTAUTH_SECRET` | Auth session secret (openssl rand) |
|
||||
| `NEXTAUTH_URL` | `https://portal.monaco-opc.com` |
|
||||
| `MINIO_ENDPOINT` | MinIO internal URL (e.g. `http://localhost:9000`) |
|
||||
| `MINIO_ACCESS_KEY` | MinIO access key |
|
||||
| `MINIO_SECRET_KEY` | MinIO secret key |
|
||||
| `MINIO_BUCKET` | MinIO bucket name (`mopc-files`) |
|
||||
| `SMTP_HOST` | SMTP server host |
|
||||
| `SMTP_PORT` | SMTP port (587) |
|
||||
| `SMTP_USER` | SMTP username |
|
||||
| `SMTP_PASS` | SMTP password |
|
||||
| `EMAIL_FROM` | Sender address |
|
||||
|
||||
### 3. Run the deploy script
|
||||
|
||||
```bash
|
||||
chmod +x scripts/deploy.sh scripts/seed.sh scripts/update.sh
|
||||
./scripts/deploy.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
- Log in to the container registry
|
||||
- Pull the latest app image
|
||||
- Start PostgreSQL + the app
|
||||
- Run database migrations automatically on startup
|
||||
- Wait for the health check
|
||||
|
||||
### 4. Seed the database (one time only)
|
||||
|
||||
```bash
|
||||
./scripts/seed.sh
|
||||
```
|
||||
|
||||
This seeds:
|
||||
- Super admin user (`matt.ciaccio@gmail.com`)
|
||||
- System settings
|
||||
- Program & Round 1 configuration
|
||||
- Evaluation form
|
||||
- All candidature data from CSV
|
||||
|
||||
### 5. Set up Nginx
|
||||
|
||||
```bash
|
||||
sudo ln -s /opt/mopc/docker/nginx/mopc-platform.conf /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 6. Set up SSL
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d portal.monaco-opc.com
|
||||
```
|
||||
|
||||
Auto-renewal is configured by default. Test with:
|
||||
|
||||
```bash
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
### 7. Verify
|
||||
|
||||
```bash
|
||||
curl https://portal.monaco-opc.com/api/health
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{"status":"healthy","timestamp":"...","services":{"database":"connected"}}
|
||||
```
|
||||
|
||||
## Updating the Platform
|
||||
|
||||
After Gitea CI builds a new image (push to `main`):
|
||||
|
||||
```bash
|
||||
cd /opt/mopc
|
||||
./scripts/update.sh
|
||||
```
|
||||
|
||||
This pulls the latest image from the registry, restarts only the app container (PostgreSQL stays running), runs migrations via the entrypoint, and waits for the health check.
|
||||
|
||||
Manual equivalent:
|
||||
|
||||
```bash
|
||||
cd /opt/mopc/docker
|
||||
docker compose up -d --pull always --force-recreate app
|
||||
```
|
||||
|
||||
`prisma migrate deploy` runs automatically in the container entrypoint before the app starts.
|
||||
|
||||
## Manual Operations
|
||||
|
||||
### View logs
|
||||
|
||||
```bash
|
||||
cd /opt/mopc/docker
|
||||
docker compose logs -f app # App logs
|
||||
docker compose logs -f postgres # Database logs
|
||||
```
|
||||
|
||||
### Run migrations manually
|
||||
|
||||
```bash
|
||||
cd /opt/mopc/docker
|
||||
docker compose exec app npx prisma migrate deploy
|
||||
```
|
||||
|
||||
### Open a shell in the app container
|
||||
|
||||
```bash
|
||||
cd /opt/mopc/docker
|
||||
docker compose exec app sh
|
||||
```
|
||||
|
||||
### Restart services
|
||||
|
||||
```bash
|
||||
cd /opt/mopc/docker
|
||||
docker compose restart app # App only
|
||||
docker compose restart # All services
|
||||
```
|
||||
|
||||
### Stop everything
|
||||
|
||||
```bash
|
||||
cd /opt/mopc/docker
|
||||
docker compose down # Stop containers (data preserved)
|
||||
docker compose down -v # Stop AND delete volumes (data lost!)
|
||||
```
|
||||
|
||||
## Database Backups
|
||||
|
||||
### Create a backup
|
||||
|
||||
```bash
|
||||
docker exec mopc-postgres pg_dump -U mopc mopc | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
||||
```
|
||||
|
||||
### Restore a backup
|
||||
|
||||
```bash
|
||||
gunzip < backup_20260130_020000.sql.gz | docker exec -i mopc-postgres psql -U mopc mopc
|
||||
```
|
||||
|
||||
### Set up daily backups (cron)
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /data/backups/mopc
|
||||
|
||||
cat > /opt/mopc/scripts/backup-db.sh << 'SCRIPT'
|
||||
#!/bin/bash
|
||||
BACKUP_DIR=/data/backups/mopc
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
docker exec mopc-postgres pg_dump -U mopc mopc | gzip > $BACKUP_DIR/mopc_$DATE.sql.gz
|
||||
find $BACKUP_DIR -name "mopc_*.sql.gz" -mtime +30 -delete
|
||||
SCRIPT
|
||||
|
||||
chmod +x /opt/mopc/scripts/backup-db.sh
|
||||
echo "0 2 * * * /opt/mopc/scripts/backup-db.sh >> /var/log/mopc-backup.log 2>&1" | sudo tee /etc/cron.d/mopc-backup
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Gitea CI (Ubuntu runner)
|
||||
|
|
||||
v (docker push)
|
||||
Container Registry
|
||||
|
|
||||
v (docker pull)
|
||||
Linux VPS
|
||||
|
|
||||
v
|
||||
Nginx (host, port 443) -- SSL termination
|
||||
|
|
||||
v
|
||||
mopc-app (Docker, port 7600) -- Next.js standalone
|
||||
|
|
||||
v
|
||||
mopc-postgres (Docker, port 5432) -- PostgreSQL 16
|
||||
|
||||
External services (separate Docker stacks):
|
||||
- MinIO (port 9000) -- S3-compatible file storage
|
||||
- Poste.io (port 587) -- SMTP email
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### App won't start
|
||||
|
||||
```bash
|
||||
cd /opt/mopc/docker
|
||||
docker compose logs app
|
||||
docker compose exec postgres pg_isready -U mopc
|
||||
```
|
||||
|
||||
### Can't pull image
|
||||
|
||||
```bash
|
||||
# Re-authenticate with registry
|
||||
docker login <your-registry-url>
|
||||
|
||||
# Check image exists
|
||||
docker pull <your-registry-url>/mopc-app:latest
|
||||
```
|
||||
|
||||
### Migration fails
|
||||
|
||||
```bash
|
||||
# Check migration status
|
||||
docker compose exec app npx prisma migrate status
|
||||
|
||||
# Reset (DESTROYS DATA):
|
||||
docker compose exec app npx prisma migrate reset
|
||||
```
|
||||
|
||||
### SSL certificate issues
|
||||
|
||||
```bash
|
||||
sudo certbot certificates
|
||||
sudo certbot renew --force-renewal
|
||||
```
|
||||
|
||||
### Port conflict
|
||||
|
||||
The app runs on port 7600. If something else uses it:
|
||||
|
||||
```bash
|
||||
sudo ss -tlnp | grep 7600
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] SSL certificate active and auto-renewing
|
||||
- [ ] `docker/.env` has strong, unique passwords
|
||||
- [ ] `NEXTAUTH_SECRET` is randomly generated
|
||||
- [ ] Gitea registry credentials secured
|
||||
- [ ] Firewall allows only ports 80, 443, 22
|
||||
- [ ] Docker daemon not exposed to network
|
||||
- [ ] Daily backups configured
|
||||
- [ ] Nginx security headers active
|
||||
# MOPC Platform - Server Deployment Guide
|
||||
|
||||
Deployment guide for the MOPC platform on a Linux VPS with Docker.
|
||||
|
||||
**Domain**: `portal.monaco-opc.com`
|
||||
**App Port**: 7600 (behind Nginx reverse proxy)
|
||||
**CI/CD**: Gitea Actions (Ubuntu runner) builds and pushes Docker images
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
The app is built automatically by a Gitea runner on every push to `main`:
|
||||
|
||||
1. Gitea Actions workflow builds the Docker image on Ubuntu
|
||||
2. Image is pushed to the Gitea container registry
|
||||
3. On the server, `docker compose up -d` refreshes the image and restarts the app
|
||||
|
||||
### Gitea Setup
|
||||
|
||||
Configure the following in your Gitea repository settings:
|
||||
|
||||
**Repository Variables** (Settings > Actions > Variables):
|
||||
|
||||
| Variable | Value |
|
||||
|----------|-------|
|
||||
| `REGISTRY_URL` | Your Gitea registry URL (e.g. `gitea.example.com/your-org`) |
|
||||
|
||||
**Repository Secrets** (Settings > Actions > Secrets):
|
||||
|
||||
| Secret | Value |
|
||||
|--------|-------|
|
||||
| `REGISTRY_USER` | Gitea username with registry access |
|
||||
| `REGISTRY_PASSWORD` | Gitea access token or password |
|
||||
|
||||
The workflow file is at `.gitea/workflows/build.yml`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Linux VPS (Ubuntu 22.04+ recommended)
|
||||
- Docker Engine 24+ with Compose v2
|
||||
- Nginx installed on the host
|
||||
- Certbot for SSL certificates
|
||||
|
||||
### Install Docker (if needed)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in
|
||||
```
|
||||
|
||||
### Install Nginx & Certbot (if needed)
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install nginx certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
## First-Time Deployment
|
||||
|
||||
### 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone <your-repo-url> /opt/mopc
|
||||
cd /opt/mopc
|
||||
```
|
||||
|
||||
### 2. Configure environment variables
|
||||
|
||||
```bash
|
||||
cp docker/.env.production docker/.env
|
||||
nano docker/.env
|
||||
```
|
||||
|
||||
Fill in all `CHANGE_ME` values. Generate secrets with:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
Required variables:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `REGISTRY_URL` | Gitea registry URL (e.g. `gitea.example.com/your-org`) |
|
||||
| `DB_PASSWORD` | PostgreSQL password |
|
||||
| `NEXTAUTH_SECRET` | Auth session secret (openssl rand) |
|
||||
| `NEXTAUTH_URL` | `https://portal.monaco-opc.com` |
|
||||
| `MINIO_ENDPOINT` | MinIO internal URL (e.g. `http://localhost:9000`) |
|
||||
| `MINIO_ACCESS_KEY` | MinIO access key |
|
||||
| `MINIO_SECRET_KEY` | MinIO secret key |
|
||||
| `MINIO_BUCKET` | MinIO bucket name (`mopc-files`) |
|
||||
| `SMTP_HOST` | SMTP server host |
|
||||
| `SMTP_PORT` | SMTP port (587) |
|
||||
| `SMTP_USER` | SMTP username |
|
||||
| `SMTP_PASS` | SMTP password |
|
||||
| `EMAIL_FROM` | Sender address |
|
||||
|
||||
### 3. Run the deploy script
|
||||
|
||||
```bash
|
||||
chmod +x scripts/deploy.sh scripts/seed.sh scripts/update.sh
|
||||
./scripts/deploy.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
- Log in to the container registry
|
||||
- Pull the latest app image
|
||||
- Start PostgreSQL + the app
|
||||
- Run database migrations automatically on startup
|
||||
- Wait for the health check
|
||||
|
||||
### 4. Seed the database (one time only)
|
||||
|
||||
```bash
|
||||
./scripts/seed.sh
|
||||
```
|
||||
|
||||
This seeds:
|
||||
- Super admin user (`matt.ciaccio@gmail.com`)
|
||||
- System settings
|
||||
- Program & Round 1 configuration
|
||||
- Evaluation form
|
||||
- All candidature data from CSV
|
||||
|
||||
### 5. Set up Nginx
|
||||
|
||||
```bash
|
||||
sudo ln -s /opt/mopc/docker/nginx/mopc-platform.conf /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 6. Set up SSL
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d portal.monaco-opc.com
|
||||
```
|
||||
|
||||
Auto-renewal is configured by default. Test with:
|
||||
|
||||
```bash
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
### 7. Verify
|
||||
|
||||
```bash
|
||||
curl https://portal.monaco-opc.com/api/health
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{"status":"healthy","timestamp":"...","services":{"database":"connected"}}
|
||||
```
|
||||
|
||||
## Updating the Platform
|
||||
|
||||
After Gitea CI builds a new image (push to `main`):
|
||||
|
||||
```bash
|
||||
cd /opt/mopc
|
||||
./scripts/update.sh
|
||||
```
|
||||
|
||||
This pulls the latest image from the registry, restarts only the app container (PostgreSQL stays running), runs migrations via the entrypoint, and waits for the health check.
|
||||
|
||||
Manual equivalent:
|
||||
|
||||
```bash
|
||||
cd /opt/mopc/docker
|
||||
docker compose up -d --pull always --force-recreate app
|
||||
```
|
||||
|
||||
`prisma migrate deploy` runs automatically in the container entrypoint before the app starts.
|
||||
|
||||
## Manual Operations
|
||||
|
||||
### View logs
|
||||
|
||||
```bash
|
||||
cd /opt/mopc/docker
|
||||
docker compose logs -f app # App logs
|
||||
docker compose logs -f postgres # Database logs
|
||||
```
|
||||
|
||||
### Run migrations manually
|
||||
|
||||
```bash
|
||||
cd /opt/mopc/docker
|
||||
docker compose exec app npx prisma migrate deploy
|
||||
```
|
||||
|
||||
### Open a shell in the app container
|
||||
|
||||
```bash
|
||||
cd /opt/mopc/docker
|
||||
docker compose exec app sh
|
||||
```
|
||||
|
||||
### Restart services
|
||||
|
||||
```bash
|
||||
cd /opt/mopc/docker
|
||||
docker compose restart app # App only
|
||||
docker compose restart # All services
|
||||
```
|
||||
|
||||
### Stop everything
|
||||
|
||||
```bash
|
||||
cd /opt/mopc/docker
|
||||
docker compose down # Stop containers (data preserved)
|
||||
docker compose down -v # Stop AND delete volumes (data lost!)
|
||||
```
|
||||
|
||||
## Database Backups
|
||||
|
||||
### Create a backup
|
||||
|
||||
```bash
|
||||
docker exec mopc-postgres pg_dump -U mopc mopc | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
||||
```
|
||||
|
||||
### Restore a backup
|
||||
|
||||
```bash
|
||||
gunzip < backup_20260130_020000.sql.gz | docker exec -i mopc-postgres psql -U mopc mopc
|
||||
```
|
||||
|
||||
### Set up daily backups (cron)
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /data/backups/mopc
|
||||
|
||||
cat > /opt/mopc/scripts/backup-db.sh << 'SCRIPT'
|
||||
#!/bin/bash
|
||||
BACKUP_DIR=/data/backups/mopc
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
docker exec mopc-postgres pg_dump -U mopc mopc | gzip > $BACKUP_DIR/mopc_$DATE.sql.gz
|
||||
find $BACKUP_DIR -name "mopc_*.sql.gz" -mtime +30 -delete
|
||||
SCRIPT
|
||||
|
||||
chmod +x /opt/mopc/scripts/backup-db.sh
|
||||
echo "0 2 * * * /opt/mopc/scripts/backup-db.sh >> /var/log/mopc-backup.log 2>&1" | sudo tee /etc/cron.d/mopc-backup
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Gitea CI (Ubuntu runner)
|
||||
|
|
||||
v (docker push)
|
||||
Container Registry
|
||||
|
|
||||
v (docker pull)
|
||||
Linux VPS
|
||||
|
|
||||
v
|
||||
Nginx (host, port 443) -- SSL termination
|
||||
|
|
||||
v
|
||||
mopc-app (Docker, port 7600) -- Next.js standalone
|
||||
|
|
||||
v
|
||||
mopc-postgres (Docker, port 5432) -- PostgreSQL 16
|
||||
|
||||
External services (separate Docker stacks):
|
||||
- MinIO (port 9000) -- S3-compatible file storage
|
||||
- Poste.io (port 587) -- SMTP email
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### App won't start
|
||||
|
||||
```bash
|
||||
cd /opt/mopc/docker
|
||||
docker compose logs app
|
||||
docker compose exec postgres pg_isready -U mopc
|
||||
```
|
||||
|
||||
### Can't pull image
|
||||
|
||||
```bash
|
||||
# Re-authenticate with registry
|
||||
docker login <your-registry-url>
|
||||
|
||||
# Check image exists
|
||||
docker pull <your-registry-url>/mopc-app:latest
|
||||
```
|
||||
|
||||
### Migration fails
|
||||
|
||||
```bash
|
||||
# Check migration status
|
||||
docker compose exec app npx prisma migrate status
|
||||
|
||||
# Reset (DESTROYS DATA):
|
||||
docker compose exec app npx prisma migrate reset
|
||||
```
|
||||
|
||||
### SSL certificate issues
|
||||
|
||||
```bash
|
||||
sudo certbot certificates
|
||||
sudo certbot renew --force-renewal
|
||||
```
|
||||
|
||||
### Port conflict
|
||||
|
||||
The app runs on port 7600. If something else uses it:
|
||||
|
||||
```bash
|
||||
sudo ss -tlnp | grep 7600
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] SSL certificate active and auto-renewing
|
||||
- [ ] `docker/.env` has strong, unique passwords
|
||||
- [ ] `NEXTAUTH_SECRET` is randomly generated
|
||||
- [ ] Gitea registry credentials secured
|
||||
- [ ] Firewall allows only ports 80, 443, 22
|
||||
- [ ] Docker daemon not exposed to network
|
||||
- [ ] Daily backups configured
|
||||
- [ ] Nginx security headers active
|
||||
|
|
|
|||
804
Notes.md
804
Notes.md
|
|
@ -1,402 +1,402 @@
|
|||
Below is a “technical requirements” rendering (not an architecture diagram), structured so you can hand it to a dev team and derive system architecture + backlog from it. Phase 1 is the only critical deliverable; Phase 2+ are explicitly extendable.
|
||||
|
||||
---
|
||||
|
||||
## 0) Product scope and phasing
|
||||
|
||||
### Phase 1 (critical, delivery in ~2 weeks)
|
||||
|
||||
**Secure Jury Online Voting Module** to run two selection rounds:
|
||||
|
||||
* Round 1: ~130 projects → ~60 semi-finalists (Feb 18–23 voting window)
|
||||
* Round 2: ~60 projects → 6 finalists (~April 13 week voting window)
|
||||
* Voting is asynchronous, online, with assigned project access, scoring + feedback capture, and reporting dashboards.
|
||||
|
||||
### Phase 2+ (mid-term)
|
||||
|
||||
Centralized MOPC platform:
|
||||
|
||||
* Applications/projects database
|
||||
* Document management (MinIO S3)
|
||||
* Jury spaces (history, comments, scoring)
|
||||
* Learning hub / resources
|
||||
* Communication workflows (email + possibly WhatsApp)
|
||||
* Partner/sponsor visibility modules
|
||||
* Potential website integration / shared back office
|
||||
|
||||
---
|
||||
|
||||
## 1) Users, roles, permissions (RBAC)
|
||||
|
||||
### Core roles
|
||||
|
||||
1. **Platform Super Admin**
|
||||
|
||||
* Full system configuration, security policies, integrations, user/role management, data export, audit access.
|
||||
2. **Program Admin (MOPC Admin)**
|
||||
|
||||
* Manages cycles/rounds, projects, jury members, assignments, voting windows, criteria forms, dashboards, exports.
|
||||
3. **Jury Member**
|
||||
|
||||
* Can access only assigned projects for active rounds; submit evaluations; view own submitted evaluations; optionally view aggregated results only if permitted.
|
||||
4. **Read-only Observer (optional)**
|
||||
|
||||
* Internal meeting viewer: can see dashboards/aggregates but cannot edit votes.
|
||||
|
||||
### Permission model requirements
|
||||
|
||||
* **Least privilege by default**
|
||||
* **Round-scoped permissions**: access can be constrained per selection round/cycle.
|
||||
* **Project-scoped access control**: jury sees only assigned projects (unless admin toggles “all projects visible”).
|
||||
* **Admin override controls**: reassign projects, revoke access, reopen/lock evaluations, extend voting windows, invalidate votes with reason logging.
|
||||
|
||||
---
|
||||
|
||||
## 2) Core domain objects (data model concepts)
|
||||
|
||||
### Entities
|
||||
|
||||
* **Program** (e.g., “MOPC 2026”)
|
||||
* **Selection Cycle / Round**
|
||||
|
||||
* Attributes: name, start/end of voting window, status (draft/active/closed/archived), required reviews per project (default ≥3), scoring form version, jury cohort.
|
||||
* **Project**
|
||||
|
||||
* Attributes: title, team name, description, tags, status (submitted/eligible/assigned/semi-finalist/finalist/etc.), submission metadata, external IDs (Typeform/Notion), files (exec summary, PDF deck, intro video).
|
||||
* **File Asset**
|
||||
|
||||
* Stored in MinIO (S3-compatible): object key, bucket, version/etag, mime type, size, upload timestamp, retention policy, access policy.
|
||||
* **Jury Member**
|
||||
|
||||
* Profile: name, email, organization (optional), role, expertise tags, status (invited/active/suspended).
|
||||
* **Expertise Tag**
|
||||
|
||||
* Managed vocabulary or free-form with admin approval.
|
||||
* **Assignment**
|
||||
|
||||
* Connects Jury Member ↔ Project ↔ Round
|
||||
* Attributes: assignment method (manual/auto), created by, created timestamp, required review flag, completion status.
|
||||
* **Evaluation (Vote)**
|
||||
|
||||
* Per assignment: criterion scores + global score + binary decision + qualitative feedback
|
||||
* Metadata: submitted_at, last_edited_at, finalization flag, versioning, IP/user-agent logging (optional), conflict handling.
|
||||
* **Audit Log**
|
||||
|
||||
* Immutable events: login, permission changes, voting window changes, assignments, overrides, exports, vote invalidations.
|
||||
|
||||
---
|
||||
|
||||
## 3) Phase 1 functional requirements
|
||||
|
||||
### 3.1 Jury authentication & access
|
||||
|
||||
* Invite flow:
|
||||
|
||||
* Admin imports jury list (CSV) or adds manually.
|
||||
* System sends invitation email with secure link + account activation.
|
||||
* Authentication options (choose one for Phase 1, keep others pluggable):
|
||||
|
||||
* Email magic link (recommended for speed)
|
||||
* Password + MFA optional
|
||||
* Session requirements:
|
||||
|
||||
* Configurable session duration
|
||||
* Forced logout on role revocation
|
||||
* Access gating:
|
||||
|
||||
* Jury can only view projects for **active** rounds and only those assigned.
|
||||
|
||||
### 3.2 Project ingestion & management
|
||||
|
||||
Phase 1 can support either:
|
||||
|
||||
* **Option A (fastest): Manual import**
|
||||
|
||||
* Admin uploads CSV with project metadata + file links or uploads.
|
||||
* **Option B (semi-integrated): Sync from Notion/Typeform**
|
||||
|
||||
* Read projects from existing Notion DB and/or Typeform export.
|
||||
|
||||
Minimum capabilities:
|
||||
|
||||
* Admin CRUD on projects (create/update/archive)
|
||||
* Project tagging (from “Which issue does your project address?” + additional admin tags)
|
||||
* Attach required assets:
|
||||
|
||||
* Executive summary (PDF/doc)
|
||||
* PDF presentation
|
||||
* 30s intro video (mp4)
|
||||
* File storage via MinIO (see Section 6)
|
||||
|
||||
### 3.3 Assignment system (≥3 reviews/project)
|
||||
|
||||
Admin can:
|
||||
|
||||
* Manually assign projects to jury members (bulk assign supported)
|
||||
* Auto-assign (optional but strongly recommended):
|
||||
|
||||
* Input: jury expertise tags + project tags + constraints
|
||||
* Constraints:
|
||||
|
||||
* Each project assigned to at least N jurors (N configurable; default 3)
|
||||
* Load balancing across jurors (minimize variance)
|
||||
* Avoid conflicts (optional): disallow assignment if juror marked conflict with project
|
||||
* Output: assignment set + summary metrics (coverage, per-juror load, unmatched tags)
|
||||
* Reassignment rules:
|
||||
|
||||
* Admin can reassign at any time
|
||||
* If an evaluation exists, admin can:
|
||||
|
||||
* keep existing evaluation tied to original juror
|
||||
* or invalidate/lock it (requires reason + audit event)
|
||||
|
||||
### 3.4 Evaluation form & scoring logic
|
||||
|
||||
Per project evaluation must capture:
|
||||
|
||||
* **Criterion scores** (scale-based, define exact scale as configurable; e.g., 1–5 or 1–10)
|
||||
|
||||
1. Need clarity
|
||||
2. Solution relevance
|
||||
3. Gap analysis (market/competitors)
|
||||
4. Target customers clarity
|
||||
5. Ocean impact
|
||||
* **Global score**: 1–10
|
||||
* **Binary decision**: “Select as semi-finalist?” (Yes/No)
|
||||
* **Qualitative feedback**: long text
|
||||
|
||||
Form requirements:
|
||||
|
||||
* Admin-configurable criteria text, ordering, scales, and whether fields are mandatory
|
||||
* Autosave drafts
|
||||
* Final submit locks evaluation by default (admin can allow edits until window closes)
|
||||
* Support multiple rounds with potentially different forms (versioned forms per round)
|
||||
|
||||
### 3.5 Voting windows and enforcement (must-have)
|
||||
|
||||
Admins must be able to configure and enforce:
|
||||
|
||||
* Voting window start/end **per round** (date-time, timezone-aware)
|
||||
* States:
|
||||
|
||||
* Draft (admins only)
|
||||
* Active (jury can submit)
|
||||
* Closed (jury read-only)
|
||||
* Archived (admin/export only)
|
||||
* Enforcement rules:
|
||||
|
||||
* Jury cannot submit outside the active window
|
||||
* Admin “grace period” toggle to accept late submissions for specific jurors/projects
|
||||
* Admin can extend the window (global or subset) with audit logging
|
||||
* Dashboard countdown + clear messaging for jurors
|
||||
|
||||
### 3.6 Dashboards & outputs
|
||||
|
||||
Must produce:
|
||||
|
||||
* **Jury member view**
|
||||
|
||||
* Assigned projects list, completion status, quick access to files, evaluation status (not started/draft/submitted)
|
||||
* **Admin dashboards**
|
||||
|
||||
* Coverage: projects with <N evaluations
|
||||
* Progress: submission rates by juror
|
||||
* Aggregates per project:
|
||||
|
||||
* Average per criterion
|
||||
* Average global score
|
||||
* Distribution (min/max, std dev optional)
|
||||
* Count of “Yes” votes
|
||||
* Qualitative comments list (with juror identity visible only to admins, configurable)
|
||||
* Shortlisting tools:
|
||||
|
||||
* Filter/sort by aggregate score, yes-vote ratio, tag, missing reviews
|
||||
* Export shortlist (e.g., top 60 / top 6) with manual override controls
|
||||
* Exports (Phase 1):
|
||||
|
||||
* CSV/Excel export for:
|
||||
|
||||
* Evaluations (row per evaluation)
|
||||
* Aggregates (row per project)
|
||||
* Assignment matrix
|
||||
* PDF export (optional) for meeting packs
|
||||
|
||||
---
|
||||
|
||||
## 4) Admin console requirements (robust)
|
||||
|
||||
### 4.1 Governance & configuration
|
||||
|
||||
* Create/manage Programs and Rounds
|
||||
* Set:
|
||||
|
||||
* Required reviews per project (N)
|
||||
* Voting windows (start/end) + grace rules
|
||||
* Evaluation form version
|
||||
* Visibility rules (whether jurors can see aggregates, whether jurors can see their past submissions after close)
|
||||
* Manage tags:
|
||||
|
||||
* Tag taxonomy, synonyms/merging, locked tags
|
||||
|
||||
### 4.2 User management & security controls
|
||||
|
||||
* Bulk invite/import
|
||||
* Role assignment & revocation
|
||||
* Force password reset / disable account
|
||||
* View user activity logs
|
||||
* Configure:
|
||||
|
||||
* Allowed email domains (optional)
|
||||
* MFA requirement (optional)
|
||||
* Session lifetime (optional)
|
||||
|
||||
### 4.3 Assignment controls
|
||||
|
||||
* Manual assignment UI (single + bulk)
|
||||
* Auto-assignment wizard:
|
||||
|
||||
* select round
|
||||
* choose balancing strategy (e.g., “maximize tag match”, “balance load first”)
|
||||
* preview results
|
||||
* apply
|
||||
* Conflict of interest handling:
|
||||
|
||||
* Admin can mark conflicts (juror ↔ project)
|
||||
* Auto-assign must respect conflicts
|
||||
|
||||
### 4.4 Data integrity controls
|
||||
|
||||
* Vote invalidation (requires reason)
|
||||
* Reopen evaluation (admin-only, logged)
|
||||
* Freeze round (hard lock)
|
||||
* Immutable audit log export
|
||||
|
||||
### 4.5 Integrations management
|
||||
|
||||
* Connectors toggles (Typeform/Notion/email provider/WhatsApp) with credentials stored securely
|
||||
* MinIO bucket configuration + retention policies
|
||||
* Webhook management (optional)
|
||||
|
||||
---
|
||||
|
||||
## 5) Non-functional requirements (Phase 1)
|
||||
|
||||
### Security
|
||||
|
||||
* TLS everywhere
|
||||
* RBAC + project-level access control
|
||||
* Secure file access (pre-signed URLs with short TTL; no public buckets)
|
||||
* Audit logging for admin actions + exports
|
||||
* Basic anti-abuse:
|
||||
|
||||
* rate limiting login endpoints
|
||||
* brute-force protection if password auth used
|
||||
|
||||
### Reliability & performance
|
||||
|
||||
* Support:
|
||||
|
||||
* Round 1: 15 jurors, 130 projects, min 390 evaluations
|
||||
* Round 2: ~30 jurors, 60 projects
|
||||
* Fast page load for dashboards and project pages
|
||||
* File streaming for PDFs/videos (avoid timeouts)
|
||||
|
||||
### Compliance & privacy (baseline)
|
||||
|
||||
* Store only necessary personal data for jurors/candidates
|
||||
* Retention policies configurable (especially for candidate files)
|
||||
* Access logs available for security review
|
||||
|
||||
---
|
||||
|
||||
## 6) File storage requirements (MinIO S3)
|
||||
|
||||
### Storage design (requirements-level)
|
||||
|
||||
* Use MinIO as S3-compatible object store for:
|
||||
|
||||
* project documents (exec summary, deck)
|
||||
* video files
|
||||
* optional assets (logos, exports packs)
|
||||
* Buckets:
|
||||
|
||||
* Separate buckets or prefixes by Program/Round to simplify retention + permissions
|
||||
* Access pattern:
|
||||
|
||||
* Upload: direct-to-S3 (preferred) or via backend proxy
|
||||
* Download/view: **pre-signed URLs** generated by backend per authorized user
|
||||
* Optional features:
|
||||
|
||||
* Object versioning enabled
|
||||
* Antivirus scanning hook (Phase 2)
|
||||
* Lifecycle rules (auto-expire after X months)
|
||||
|
||||
---
|
||||
|
||||
## 7) “Current process” integration mapping (future-proof)
|
||||
|
||||
### Existing flow
|
||||
|
||||
* Typeform application → confirmation email → Tally upload → Notion tracking → Google Drive manual upload
|
||||
|
||||
### Platform integration targets
|
||||
|
||||
Phase 1 (minimal):
|
||||
|
||||
* Allow admin to ingest projects and upload assets (replace Drive for jury-facing access)
|
||||
|
||||
Phase 2 options:
|
||||
|
||||
* Typeform: pull submissions via API/webhooks
|
||||
* Tally: capture uploads directly to MinIO (or via platform upload portal)
|
||||
* Notion: sync project status + metadata (one-way or two-way)
|
||||
* Email automation: reminder workflows for incomplete applications
|
||||
|
||||
---
|
||||
|
||||
## 8) Additional ideas as “technical backlog candidates”
|
||||
|
||||
### Automated follow-ups for incomplete applications (Phase 2)
|
||||
|
||||
* State machine for applications: registered → awaiting docs → complete → expired
|
||||
* Scheduler:
|
||||
|
||||
* send reminders at configurable intervals (e.g., +2d, +5d, +7d)
|
||||
* stop on completion
|
||||
* Channels:
|
||||
|
||||
* Email must-have
|
||||
* WhatsApp optional (requires compliance + provider; store consent + opt-out)
|
||||
|
||||
### Learning hub access (semi-finalists only)
|
||||
|
||||
* Resource library stored in MinIO + metadata in DB
|
||||
* Access controlled by cohort + passwordless login or access tokens
|
||||
* Expiring invite links
|
||||
|
||||
### Website integration
|
||||
|
||||
* Shared identity/back office (SSO-ready) OR separate admin domains
|
||||
* Public-facing site remains content-only; platform is operational hub
|
||||
* Requirement: clear separation between “public content” and “private jury/applicant data”
|
||||
|
||||
---
|
||||
|
||||
## 9) Acceptance criteria checklist (Phase 1)
|
||||
|
||||
1. Admin can create a round, set voting window (start/end), and activate it.
|
||||
2. Admin can import projects + upload/attach required files to MinIO.
|
||||
3. Admin can import jurors, invite them, and jurors can log in securely.
|
||||
4. Admin can assign projects (manual + bulk). Auto-assign is optional but if included must guarantee ≥3 reviews/project.
|
||||
5. Juror sees only assigned projects, can view files, and submit evaluation form.
|
||||
6. System blocks submissions outside the voting window (unless admin-granted exception).
|
||||
7. Admin dashboard shows progress + aggregates per project; admin can export results.
|
||||
8. All critical admin actions are audit-logged.
|
||||
9. File access is protected (no public links; pre-signed URLs with TTL).
|
||||
|
||||
---
|
||||
|
||||
If you want, I can turn this into:
|
||||
|
||||
* a clean PRD-style document (Dev-ready) **plus**
|
||||
* a ticket breakdown (Epics → user stories → acceptance tests) for Phase 1 delivery.
|
||||
Below is a “technical requirements” rendering (not an architecture diagram), structured so you can hand it to a dev team and derive system architecture + backlog from it. Phase 1 is the only critical deliverable; Phase 2+ are explicitly extendable.
|
||||
|
||||
---
|
||||
|
||||
## 0) Product scope and phasing
|
||||
|
||||
### Phase 1 (critical, delivery in ~2 weeks)
|
||||
|
||||
**Secure Jury Online Voting Module** to run two selection rounds:
|
||||
|
||||
* Round 1: ~130 projects → ~60 semi-finalists (Feb 18–23 voting window)
|
||||
* Round 2: ~60 projects → 6 finalists (~April 13 week voting window)
|
||||
* Voting is asynchronous, online, with assigned project access, scoring + feedback capture, and reporting dashboards.
|
||||
|
||||
### Phase 2+ (mid-term)
|
||||
|
||||
Centralized MOPC platform:
|
||||
|
||||
* Applications/projects database
|
||||
* Document management (MinIO S3)
|
||||
* Jury spaces (history, comments, scoring)
|
||||
* Learning hub / resources
|
||||
* Communication workflows (email + possibly WhatsApp)
|
||||
* Partner/sponsor visibility modules
|
||||
* Potential website integration / shared back office
|
||||
|
||||
---
|
||||
|
||||
## 1) Users, roles, permissions (RBAC)
|
||||
|
||||
### Core roles
|
||||
|
||||
1. **Platform Super Admin**
|
||||
|
||||
* Full system configuration, security policies, integrations, user/role management, data export, audit access.
|
||||
2. **Program Admin (MOPC Admin)**
|
||||
|
||||
* Manages cycles/rounds, projects, jury members, assignments, voting windows, criteria forms, dashboards, exports.
|
||||
3. **Jury Member**
|
||||
|
||||
* Can access only assigned projects for active rounds; submit evaluations; view own submitted evaluations; optionally view aggregated results only if permitted.
|
||||
4. **Read-only Observer (optional)**
|
||||
|
||||
* Internal meeting viewer: can see dashboards/aggregates but cannot edit votes.
|
||||
|
||||
### Permission model requirements
|
||||
|
||||
* **Least privilege by default**
|
||||
* **Round-scoped permissions**: access can be constrained per selection round/cycle.
|
||||
* **Project-scoped access control**: jury sees only assigned projects (unless admin toggles “all projects visible”).
|
||||
* **Admin override controls**: reassign projects, revoke access, reopen/lock evaluations, extend voting windows, invalidate votes with reason logging.
|
||||
|
||||
---
|
||||
|
||||
## 2) Core domain objects (data model concepts)
|
||||
|
||||
### Entities
|
||||
|
||||
* **Program** (e.g., “MOPC 2026”)
|
||||
* **Selection Cycle / Round**
|
||||
|
||||
* Attributes: name, start/end of voting window, status (draft/active/closed/archived), required reviews per project (default ≥3), scoring form version, jury cohort.
|
||||
* **Project**
|
||||
|
||||
* Attributes: title, team name, description, tags, status (submitted/eligible/assigned/semi-finalist/finalist/etc.), submission metadata, external IDs (Typeform/Notion), files (exec summary, PDF deck, intro video).
|
||||
* **File Asset**
|
||||
|
||||
* Stored in MinIO (S3-compatible): object key, bucket, version/etag, mime type, size, upload timestamp, retention policy, access policy.
|
||||
* **Jury Member**
|
||||
|
||||
* Profile: name, email, organization (optional), role, expertise tags, status (invited/active/suspended).
|
||||
* **Expertise Tag**
|
||||
|
||||
* Managed vocabulary or free-form with admin approval.
|
||||
* **Assignment**
|
||||
|
||||
* Connects Jury Member ↔ Project ↔ Round
|
||||
* Attributes: assignment method (manual/auto), created by, created timestamp, required review flag, completion status.
|
||||
* **Evaluation (Vote)**
|
||||
|
||||
* Per assignment: criterion scores + global score + binary decision + qualitative feedback
|
||||
* Metadata: submitted_at, last_edited_at, finalization flag, versioning, IP/user-agent logging (optional), conflict handling.
|
||||
* **Audit Log**
|
||||
|
||||
* Immutable events: login, permission changes, voting window changes, assignments, overrides, exports, vote invalidations.
|
||||
|
||||
---
|
||||
|
||||
## 3) Phase 1 functional requirements
|
||||
|
||||
### 3.1 Jury authentication & access
|
||||
|
||||
* Invite flow:
|
||||
|
||||
* Admin imports jury list (CSV) or adds manually.
|
||||
* System sends invitation email with secure link + account activation.
|
||||
* Authentication options (choose one for Phase 1, keep others pluggable):
|
||||
|
||||
* Email magic link (recommended for speed)
|
||||
* Password + MFA optional
|
||||
* Session requirements:
|
||||
|
||||
* Configurable session duration
|
||||
* Forced logout on role revocation
|
||||
* Access gating:
|
||||
|
||||
* Jury can only view projects for **active** rounds and only those assigned.
|
||||
|
||||
### 3.2 Project ingestion & management
|
||||
|
||||
Phase 1 can support either:
|
||||
|
||||
* **Option A (fastest): Manual import**
|
||||
|
||||
* Admin uploads CSV with project metadata + file links or uploads.
|
||||
* **Option B (semi-integrated): Sync from Notion/Typeform**
|
||||
|
||||
* Read projects from existing Notion DB and/or Typeform export.
|
||||
|
||||
Minimum capabilities:
|
||||
|
||||
* Admin CRUD on projects (create/update/archive)
|
||||
* Project tagging (from “Which issue does your project address?” + additional admin tags)
|
||||
* Attach required assets:
|
||||
|
||||
* Executive summary (PDF/doc)
|
||||
* PDF presentation
|
||||
* 30s intro video (mp4)
|
||||
* File storage via MinIO (see Section 6)
|
||||
|
||||
### 3.3 Assignment system (≥3 reviews/project)
|
||||
|
||||
Admin can:
|
||||
|
||||
* Manually assign projects to jury members (bulk assign supported)
|
||||
* Auto-assign (optional but strongly recommended):
|
||||
|
||||
* Input: jury expertise tags + project tags + constraints
|
||||
* Constraints:
|
||||
|
||||
* Each project assigned to at least N jurors (N configurable; default 3)
|
||||
* Load balancing across jurors (minimize variance)
|
||||
* Avoid conflicts (optional): disallow assignment if juror marked conflict with project
|
||||
* Output: assignment set + summary metrics (coverage, per-juror load, unmatched tags)
|
||||
* Reassignment rules:
|
||||
|
||||
* Admin can reassign at any time
|
||||
* If an evaluation exists, admin can:
|
||||
|
||||
* keep existing evaluation tied to original juror
|
||||
* or invalidate/lock it (requires reason + audit event)
|
||||
|
||||
### 3.4 Evaluation form & scoring logic
|
||||
|
||||
Per project evaluation must capture:
|
||||
|
||||
* **Criterion scores** (scale-based, define exact scale as configurable; e.g., 1–5 or 1–10)
|
||||
|
||||
1. Need clarity
|
||||
2. Solution relevance
|
||||
3. Gap analysis (market/competitors)
|
||||
4. Target customers clarity
|
||||
5. Ocean impact
|
||||
* **Global score**: 1–10
|
||||
* **Binary decision**: “Select as semi-finalist?” (Yes/No)
|
||||
* **Qualitative feedback**: long text
|
||||
|
||||
Form requirements:
|
||||
|
||||
* Admin-configurable criteria text, ordering, scales, and whether fields are mandatory
|
||||
* Autosave drafts
|
||||
* Final submit locks evaluation by default (admin can allow edits until window closes)
|
||||
* Support multiple rounds with potentially different forms (versioned forms per round)
|
||||
|
||||
### 3.5 Voting windows and enforcement (must-have)
|
||||
|
||||
Admins must be able to configure and enforce:
|
||||
|
||||
* Voting window start/end **per round** (date-time, timezone-aware)
|
||||
* States:
|
||||
|
||||
* Draft (admins only)
|
||||
* Active (jury can submit)
|
||||
* Closed (jury read-only)
|
||||
* Archived (admin/export only)
|
||||
* Enforcement rules:
|
||||
|
||||
* Jury cannot submit outside the active window
|
||||
* Admin “grace period” toggle to accept late submissions for specific jurors/projects
|
||||
* Admin can extend the window (global or subset) with audit logging
|
||||
* Dashboard countdown + clear messaging for jurors
|
||||
|
||||
### 3.6 Dashboards & outputs
|
||||
|
||||
Must produce:
|
||||
|
||||
* **Jury member view**
|
||||
|
||||
* Assigned projects list, completion status, quick access to files, evaluation status (not started/draft/submitted)
|
||||
* **Admin dashboards**
|
||||
|
||||
* Coverage: projects with <N evaluations
|
||||
* Progress: submission rates by juror
|
||||
* Aggregates per project:
|
||||
|
||||
* Average per criterion
|
||||
* Average global score
|
||||
* Distribution (min/max, std dev optional)
|
||||
* Count of “Yes” votes
|
||||
* Qualitative comments list (with juror identity visible only to admins, configurable)
|
||||
* Shortlisting tools:
|
||||
|
||||
* Filter/sort by aggregate score, yes-vote ratio, tag, missing reviews
|
||||
* Export shortlist (e.g., top 60 / top 6) with manual override controls
|
||||
* Exports (Phase 1):
|
||||
|
||||
* CSV/Excel export for:
|
||||
|
||||
* Evaluations (row per evaluation)
|
||||
* Aggregates (row per project)
|
||||
* Assignment matrix
|
||||
* PDF export (optional) for meeting packs
|
||||
|
||||
---
|
||||
|
||||
## 4) Admin console requirements (robust)
|
||||
|
||||
### 4.1 Governance & configuration
|
||||
|
||||
* Create/manage Programs and Rounds
|
||||
* Set:
|
||||
|
||||
* Required reviews per project (N)
|
||||
* Voting windows (start/end) + grace rules
|
||||
* Evaluation form version
|
||||
* Visibility rules (whether jurors can see aggregates, whether jurors can see their past submissions after close)
|
||||
* Manage tags:
|
||||
|
||||
* Tag taxonomy, synonyms/merging, locked tags
|
||||
|
||||
### 4.2 User management & security controls
|
||||
|
||||
* Bulk invite/import
|
||||
* Role assignment & revocation
|
||||
* Force password reset / disable account
|
||||
* View user activity logs
|
||||
* Configure:
|
||||
|
||||
* Allowed email domains (optional)
|
||||
* MFA requirement (optional)
|
||||
* Session lifetime (optional)
|
||||
|
||||
### 4.3 Assignment controls
|
||||
|
||||
* Manual assignment UI (single + bulk)
|
||||
* Auto-assignment wizard:
|
||||
|
||||
* select round
|
||||
* choose balancing strategy (e.g., “maximize tag match”, “balance load first”)
|
||||
* preview results
|
||||
* apply
|
||||
* Conflict of interest handling:
|
||||
|
||||
* Admin can mark conflicts (juror ↔ project)
|
||||
* Auto-assign must respect conflicts
|
||||
|
||||
### 4.4 Data integrity controls
|
||||
|
||||
* Vote invalidation (requires reason)
|
||||
* Reopen evaluation (admin-only, logged)
|
||||
* Freeze round (hard lock)
|
||||
* Immutable audit log export
|
||||
|
||||
### 4.5 Integrations management
|
||||
|
||||
* Connectors toggles (Typeform/Notion/email provider/WhatsApp) with credentials stored securely
|
||||
* MinIO bucket configuration + retention policies
|
||||
* Webhook management (optional)
|
||||
|
||||
---
|
||||
|
||||
## 5) Non-functional requirements (Phase 1)
|
||||
|
||||
### Security
|
||||
|
||||
* TLS everywhere
|
||||
* RBAC + project-level access control
|
||||
* Secure file access (pre-signed URLs with short TTL; no public buckets)
|
||||
* Audit logging for admin actions + exports
|
||||
* Basic anti-abuse:
|
||||
|
||||
* rate limiting login endpoints
|
||||
* brute-force protection if password auth used
|
||||
|
||||
### Reliability & performance
|
||||
|
||||
* Support:
|
||||
|
||||
* Round 1: 15 jurors, 130 projects, min 390 evaluations
|
||||
* Round 2: ~30 jurors, 60 projects
|
||||
* Fast page load for dashboards and project pages
|
||||
* File streaming for PDFs/videos (avoid timeouts)
|
||||
|
||||
### Compliance & privacy (baseline)
|
||||
|
||||
* Store only necessary personal data for jurors/candidates
|
||||
* Retention policies configurable (especially for candidate files)
|
||||
* Access logs available for security review
|
||||
|
||||
---
|
||||
|
||||
## 6) File storage requirements (MinIO S3)
|
||||
|
||||
### Storage design (requirements-level)
|
||||
|
||||
* Use MinIO as S3-compatible object store for:
|
||||
|
||||
* project documents (exec summary, deck)
|
||||
* video files
|
||||
* optional assets (logos, exports packs)
|
||||
* Buckets:
|
||||
|
||||
* Separate buckets or prefixes by Program/Round to simplify retention + permissions
|
||||
* Access pattern:
|
||||
|
||||
* Upload: direct-to-S3 (preferred) or via backend proxy
|
||||
* Download/view: **pre-signed URLs** generated by backend per authorized user
|
||||
* Optional features:
|
||||
|
||||
* Object versioning enabled
|
||||
* Antivirus scanning hook (Phase 2)
|
||||
* Lifecycle rules (auto-expire after X months)
|
||||
|
||||
---
|
||||
|
||||
## 7) “Current process” integration mapping (future-proof)
|
||||
|
||||
### Existing flow
|
||||
|
||||
* Typeform application → confirmation email → Tally upload → Notion tracking → Google Drive manual upload
|
||||
|
||||
### Platform integration targets
|
||||
|
||||
Phase 1 (minimal):
|
||||
|
||||
* Allow admin to ingest projects and upload assets (replace Drive for jury-facing access)
|
||||
|
||||
Phase 2 options:
|
||||
|
||||
* Typeform: pull submissions via API/webhooks
|
||||
* Tally: capture uploads directly to MinIO (or via platform upload portal)
|
||||
* Notion: sync project status + metadata (one-way or two-way)
|
||||
* Email automation: reminder workflows for incomplete applications
|
||||
|
||||
---
|
||||
|
||||
## 8) Additional ideas as “technical backlog candidates”
|
||||
|
||||
### Automated follow-ups for incomplete applications (Phase 2)
|
||||
|
||||
* State machine for applications: registered → awaiting docs → complete → expired
|
||||
* Scheduler:
|
||||
|
||||
* send reminders at configurable intervals (e.g., +2d, +5d, +7d)
|
||||
* stop on completion
|
||||
* Channels:
|
||||
|
||||
* Email must-have
|
||||
* WhatsApp optional (requires compliance + provider; store consent + opt-out)
|
||||
|
||||
### Learning hub access (semi-finalists only)
|
||||
|
||||
* Resource library stored in MinIO + metadata in DB
|
||||
* Access controlled by cohort + passwordless login or access tokens
|
||||
* Expiring invite links
|
||||
|
||||
### Website integration
|
||||
|
||||
* Shared identity/back office (SSO-ready) OR separate admin domains
|
||||
* Public-facing site remains content-only; platform is operational hub
|
||||
* Requirement: clear separation between “public content” and “private jury/applicant data”
|
||||
|
||||
---
|
||||
|
||||
## 9) Acceptance criteria checklist (Phase 1)
|
||||
|
||||
1. Admin can create a round, set voting window (start/end), and activate it.
|
||||
2. Admin can import projects + upload/attach required files to MinIO.
|
||||
3. Admin can import jurors, invite them, and jurors can log in securely.
|
||||
4. Admin can assign projects (manual + bulk). Auto-assign is optional but if included must guarantee ≥3 reviews/project.
|
||||
5. Juror sees only assigned projects, can view files, and submit evaluation form.
|
||||
6. System blocks submissions outside the voting window (unless admin-granted exception).
|
||||
7. Admin dashboard shows progress + aggregates per project; admin can export results.
|
||||
8. All critical admin actions are audit-logged.
|
||||
9. File access is protected (no public links; pre-signed URLs with TTL).
|
||||
|
||||
---
|
||||
|
||||
If you want, I can turn this into:
|
||||
|
||||
* a clean PRD-style document (Dev-ready) **plus**
|
||||
* a ticket breakdown (Epics → user stories → acceptance tests) for Phase 1 delivery.
|
||||
|
|
|
|||
288
build-check.txt
288
build-check.txt
|
|
@ -1,288 +0,0 @@
|
|||
▲ Next.js 15.5.10
|
||||
- Environments: .env.local, .env
|
||||
|
||||
Creating an optimized production build ...
|
||||
✓ Compiled successfully in 10.0s
|
||||
Linting and checking validity of types ...
|
||||
Collecting page data ...
|
||||
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
|
||||
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
|
||||
|
||||
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
|
||||
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
|
||||
clientVersion: '6.19.2',
|
||||
errorCode: undefined,
|
||||
retryable: undefined
|
||||
}
|
||||
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
|
||||
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
|
||||
|
||||
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
|
||||
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
|
||||
clientVersion: '6.19.2',
|
||||
errorCode: undefined,
|
||||
retryable: undefined
|
||||
}
|
||||
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
|
||||
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
|
||||
|
||||
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
|
||||
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
|
||||
clientVersion: '6.19.2',
|
||||
errorCode: undefined,
|
||||
retryable: undefined
|
||||
}
|
||||
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
|
||||
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
|
||||
|
||||
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
|
||||
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
|
||||
clientVersion: '6.19.2',
|
||||
errorCode: undefined,
|
||||
retryable: undefined
|
||||
}
|
||||
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
|
||||
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
|
||||
|
||||
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
|
||||
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
|
||||
clientVersion: '6.19.2',
|
||||
errorCode: undefined,
|
||||
retryable: undefined
|
||||
}
|
||||
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
|
||||
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
|
||||
|
||||
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
|
||||
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
|
||||
clientVersion: '6.19.2',
|
||||
errorCode: undefined,
|
||||
retryable: undefined
|
||||
}
|
||||
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
|
||||
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
|
||||
|
||||
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
|
||||
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
|
||||
clientVersion: '6.19.2',
|
||||
errorCode: undefined,
|
||||
retryable: undefined
|
||||
}
|
||||
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
|
||||
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
|
||||
|
||||
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
|
||||
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
|
||||
clientVersion: '6.19.2',
|
||||
errorCode: undefined,
|
||||
retryable: undefined
|
||||
}
|
||||
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
|
||||
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
|
||||
|
||||
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
|
||||
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
|
||||
clientVersion: '6.19.2',
|
||||
errorCode: undefined,
|
||||
retryable: undefined
|
||||
}
|
||||
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
|
||||
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
|
||||
|
||||
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
|
||||
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
|
||||
clientVersion: '6.19.2',
|
||||
errorCode: undefined,
|
||||
retryable: undefined
|
||||
}
|
||||
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
|
||||
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
|
||||
|
||||
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
|
||||
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
|
||||
clientVersion: '6.19.2',
|
||||
errorCode: undefined,
|
||||
retryable: undefined
|
||||
}
|
||||
Generating static pages (0/37) ...
|
||||
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
|
||||
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
|
||||
|
||||
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
|
||||
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
|
||||
clientVersion: '6.19.2',
|
||||
errorCode: undefined,
|
||||
retryable: undefined
|
||||
}
|
||||
[Error [PrismaClientInitializationError]: Unable to require(`C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node`).
|
||||
The Prisma engines do not seem to be compatible with your system. Please refer to the documentation about Prisma's system requirements: https://pris.ly/d/system-requirements
|
||||
|
||||
Details: \\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node is not a valid Win32 application.
|
||||
\\?\C:\Repos\MOPC\node_modules\.prisma\client\query_engine-windows.dll.node] {
|
||||
clientVersion: '6.19.2',
|
||||
errorCode: undefined,
|
||||
retryable: undefined
|
||||
}
|
||||
Auth check failed in auth layout: Error: Dynamic server usage: Route /accept-invite couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error
|
||||
at s (C:\Repos\MOPC\.next\server\chunks\4586.js:1:28328)
|
||||
at m (C:\Repos\MOPC\.next\server\chunks\2171.js:437:9047)
|
||||
at <unknown> (C:\Repos\MOPC\.next\server\chunks\2171.js:404:57912)
|
||||
at h (C:\Repos\MOPC\.next\server\app\(auth)\error\page.js:1:4755)
|
||||
at stringify (<anonymous>) {
|
||||
description: "Route /accept-invite couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error",
|
||||
digest: 'DYNAMIC_SERVER_USAGE'
|
||||
}
|
||||
Auth check failed in auth layout: Error: Dynamic server usage: Route /error couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error
|
||||
at s (C:\Repos\MOPC\.next\server\chunks\4586.js:1:28328)
|
||||
at m (C:\Repos\MOPC\.next\server\chunks\2171.js:437:9047)
|
||||
at <unknown> (C:\Repos\MOPC\.next\server\chunks\2171.js:404:57912)
|
||||
at h (C:\Repos\MOPC\.next\server\app\(auth)\error\page.js:1:4755)
|
||||
at stringify (<anonymous>) {
|
||||
description: "Route /error couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error",
|
||||
digest: 'DYNAMIC_SERVER_USAGE'
|
||||
}
|
||||
Generating static pages (9/37)
|
||||
Auth check failed in auth layout: Error: Dynamic server usage: Route /verify-email couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error
|
||||
at s (C:\Repos\MOPC\.next\server\chunks\4586.js:1:28328)
|
||||
at m (C:\Repos\MOPC\.next\server\chunks\2171.js:437:9047)
|
||||
at <unknown> (C:\Repos\MOPC\.next\server\chunks\2171.js:404:57912)
|
||||
at h (C:\Repos\MOPC\.next\server\app\(auth)\error\page.js:1:4755)
|
||||
at stringify (<anonymous>) {
|
||||
description: "Route /verify-email couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error",
|
||||
digest: 'DYNAMIC_SERVER_USAGE'
|
||||
}
|
||||
Auth check failed in auth layout: Error: Dynamic server usage: Route /verify couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error
|
||||
at s (C:\Repos\MOPC\.next\server\chunks\4586.js:1:28328)
|
||||
at m (C:\Repos\MOPC\.next\server\chunks\2171.js:437:9047)
|
||||
at <unknown> (C:\Repos\MOPC\.next\server\chunks\2171.js:404:57912)
|
||||
at h (C:\Repos\MOPC\.next\server\app\(auth)\error\page.js:1:4755)
|
||||
at stringify (<anonymous>) {
|
||||
description: "Route /verify couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error",
|
||||
digest: 'DYNAMIC_SERVER_USAGE'
|
||||
}
|
||||
Auth check failed in auth layout: Error: Dynamic server usage: Route /onboarding couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error
|
||||
at s (C:\Repos\MOPC\.next\server\chunks\4586.js:1:28328)
|
||||
at m (C:\Repos\MOPC\.next\server\chunks\2171.js:437:9047)
|
||||
at <unknown> (C:\Repos\MOPC\.next\server\chunks\2171.js:404:57912)
|
||||
at h (C:\Repos\MOPC\.next\server\app\(auth)\error\page.js:1:4755)
|
||||
at stringify (<anonymous>) {
|
||||
description: "Route /onboarding couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error",
|
||||
digest: 'DYNAMIC_SERVER_USAGE'
|
||||
}
|
||||
Auth check failed in auth layout: Error: Dynamic server usage: Route /login couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error
|
||||
at s (C:\Repos\MOPC\.next\server\chunks\4586.js:1:28328)
|
||||
at m (C:\Repos\MOPC\.next\server\chunks\2171.js:437:9047)
|
||||
at <unknown> (C:\Repos\MOPC\.next\server\chunks\2171.js:404:57912)
|
||||
at h (C:\Repos\MOPC\.next\server\app\(auth)\error\page.js:1:4755)
|
||||
at stringify (<anonymous>) {
|
||||
description: "Route /login couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error",
|
||||
digest: 'DYNAMIC_SERVER_USAGE'
|
||||
}
|
||||
Auth check failed in auth layout: Error: Dynamic server usage: Route /set-password couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error
|
||||
at s (C:\Repos\MOPC\.next\server\chunks\4586.js:1:28328)
|
||||
at m (C:\Repos\MOPC\.next\server\chunks\2171.js:437:9047)
|
||||
at <unknown> (C:\Repos\MOPC\.next\server\chunks\2171.js:404:57912)
|
||||
at h (C:\Repos\MOPC\.next\server\app\(auth)\error\page.js:1:4755)
|
||||
at stringify (<anonymous>) {
|
||||
description: "Route /set-password couldn't be rendered statically because it used `headers`. See more info here: https://nextjs.org/docs/messages/dynamic-server-error",
|
||||
digest: 'DYNAMIC_SERVER_USAGE'
|
||||
}
|
||||
Generating static pages (18/37)
|
||||
Generating static pages (27/37)
|
||||
✓ Generating static pages (37/37)
|
||||
Finalizing page optimization ...
|
||||
Collecting build traces ...
|
||||
|
||||
Route (app) Size First Load JS
|
||||
┌ ƒ / 182 B 112 kB
|
||||
├ ○ /_not-found 171 B 103 kB
|
||||
├ ƒ /accept-invite 5.17 kB 141 kB
|
||||
├ ƒ /admin 3.54 kB 275 kB
|
||||
├ ƒ /admin/audit 8.76 kB 172 kB
|
||||
├ ƒ /admin/awards 4.82 kB 141 kB
|
||||
├ ƒ /admin/awards/[id] 13.2 kB 196 kB
|
||||
├ ƒ /admin/awards/[id]/edit 6.02 kB 182 kB
|
||||
├ ƒ /admin/awards/new 5.71 kB 182 kB
|
||||
├ ƒ /admin/learning 180 B 106 kB
|
||||
├ ƒ /admin/learning/[id] 10.5 kB 190 kB
|
||||
├ ƒ /admin/learning/new 7.85 kB 184 kB
|
||||
├ ƒ /admin/members 12.5 kB 191 kB
|
||||
├ ƒ /admin/members/[id] 5.71 kB 197 kB
|
||||
├ ƒ /admin/members/invite 6.6 kB 188 kB
|
||||
├ ƒ /admin/mentors 171 B 103 kB
|
||||
├ ƒ /admin/mentors/[id] 171 B 103 kB
|
||||
├ ƒ /admin/partners 180 B 106 kB
|
||||
├ ƒ /admin/partners/[id] 7.44 kB 187 kB
|
||||
├ ƒ /admin/partners/new 4.2 kB 181 kB
|
||||
├ ƒ /admin/programs 2.47 kB 147 kB
|
||||
├ ƒ /admin/programs/[id] 180 B 106 kB
|
||||
├ ƒ /admin/programs/[id]/apply-settings 12.4 kB 224 kB
|
||||
├ ƒ /admin/programs/[id]/edit 6.22 kB 186 kB
|
||||
├ ƒ /admin/programs/new 4.83 kB 150 kB
|
||||
├ ƒ /admin/projects 16.9 kB 207 kB
|
||||
├ ƒ /admin/projects/[id] 10.1 kB 205 kB
|
||||
├ ƒ /admin/projects/[id]/edit 7.67 kB 220 kB
|
||||
├ ƒ /admin/projects/[id]/mentor 8.78 kB 154 kB
|
||||
├ ƒ /admin/projects/import 10.5 kB 201 kB
|
||||
├ ƒ /admin/projects/new 4.42 kB 195 kB
|
||||
├ ƒ /admin/projects/pool 6.56 kB 186 kB
|
||||
├ ƒ /admin/reports 4.6 kB 308 kB
|
||||
├ ƒ /admin/rounds 9.6 kB 211 kB
|
||||
├ ƒ /admin/rounds/[id] 16.4 kB 210 kB
|
||||
├ ƒ /admin/rounds/[id]/assignments 16.2 kB 196 kB
|
||||
├ ƒ /admin/rounds/[id]/coi 8.73 kB 188 kB
|
||||
├ ƒ /admin/rounds/[id]/edit 10.2 kB 240 kB
|
||||
├ ƒ /admin/rounds/[id]/filtering 504 B 103 kB
|
||||
├ ƒ /admin/rounds/[id]/filtering/results 7.69 kB 187 kB
|
||||
├ ƒ /admin/rounds/[id]/filtering/rules 8.18 kB 188 kB
|
||||
├ ƒ /admin/rounds/[id]/live-voting 8.68 kB 169 kB
|
||||
├ ƒ /admin/rounds/new 3.72 kB 227 kB
|
||||
├ ƒ /admin/settings 21.6 kB 226 kB
|
||||
├ ƒ /admin/settings/tags 8.33 kB 214 kB
|
||||
├ ƒ /api/auth/[...nextauth] 171 B 103 kB
|
||||
├ ƒ /api/cron/reminders 171 B 103 kB
|
||||
├ ƒ /api/email/change-password 171 B 103 kB
|
||||
├ ƒ /api/email/verify-credentials 171 B 103 kB
|
||||
├ ƒ /api/health 171 B 103 kB
|
||||
├ ƒ /api/storage/local 171 B 103 kB
|
||||
├ ƒ /api/trpc/[trpc] 171 B 103 kB
|
||||
├ ƒ /apply/[slug] 954 B 387 kB
|
||||
├ ƒ /apply/edition/[programSlug] 959 B 388 kB
|
||||
├ ○ /email/change-password 6.79 kB 118 kB
|
||||
├ ƒ /error 4.01 kB 118 kB
|
||||
├ ƒ /jury 4.37 kB 119 kB
|
||||
├ ƒ /jury/assignments 180 B 106 kB
|
||||
├ ƒ /jury/awards 3.11 kB 139 kB
|
||||
├ ƒ /jury/awards/[id] 5.59 kB 151 kB
|
||||
├ ƒ /jury/learning 5.09 kB 137 kB
|
||||
├ ƒ /jury/live/[sessionId] 7.21 kB 149 kB
|
||||
├ ƒ /jury/projects/[id] 4.12 kB 144 kB
|
||||
├ ƒ /jury/projects/[id]/evaluate 13.2 kB 225 kB
|
||||
├ ƒ /jury/projects/[id]/evaluation 2.09 kB 116 kB
|
||||
├ ƒ /live-scores/[sessionId] 6.93 kB 139 kB
|
||||
├ ƒ /login 6.09 kB 120 kB
|
||||
├ ƒ /mentor 7.77 kB 144 kB
|
||||
├ ƒ /mentor/projects 5.51 kB 141 kB
|
||||
├ ƒ /mentor/projects/[id] 8.58 kB 148 kB
|
||||
├ ƒ /mentor/resources 5.12 kB 137 kB
|
||||
├ ƒ /my-submission 6.82 kB 146 kB
|
||||
├ ƒ /my-submission/[id] 11.7 kB 155 kB
|
||||
├ ƒ /my-submission/[id]/team 8 kB 216 kB
|
||||
├ ƒ /observer 2.98 kB 114 kB
|
||||
├ ƒ /observer/reports 5.63 kB 309 kB
|
||||
├ ƒ /onboarding 5.44 kB 313 kB
|
||||
├ ƒ /set-password 7.55 kB 143 kB
|
||||
├ ƒ /settings/profile 4.25 kB 210 kB
|
||||
├ ƒ /verify 180 B 106 kB
|
||||
└ ƒ /verify-email 171 B 103 kB
|
||||
+ First Load JS shared by all 103 kB
|
||||
├ chunks/1255-39d374166396f9e9.js 45.7 kB
|
||||
├ chunks/4bd1b696-100b9d70ed4e49c1.js 54.2 kB
|
||||
└ other shared chunks (total) 2.87 kB
|
||||
|
||||
|
||||
ƒ Middleware 86.5 kB
|
||||
|
||||
○ (Static) prerendered as static content
|
||||
ƒ (Dynamic) server-rendered on demand
|
||||
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
Notes: Filtering Round: Criteria- Older than 3 years (for all in the startup category), those who submit something random (like a spam project) (AI?)
|
||||
|
||||
-Add filters to the page (who sent documents, etc.)
|
||||
|
||||
-Partners section should be a semi-crm system to track possible sponsors and partners
|
||||
|
||||
-No translation into french (no localization)
|
||||
|
||||
-Ameliorate the user experience (make it more simple)
|
||||
|
||||
-Special Awards- Specific Jury Members (jury members will have to choose amongst projects that fit specific criteria (like the country they're based))- Spotlight on Africa, Coup de Coeur - Make a special award section and make a special case for judge invitation for special awards and allow us to make special awards and assign them to the selection of judges for the special awards specifically (And give them their own login space to see everything)
|
||||
-Invite jury member (with tag for special awards) --> Make special award (Criteria needed, Add a tag for special award (so for example, if a location is italy it will auto have the tag for COup de Coeur (since it's criteria is it only exists in certain countries))) - This is also a separate Jury Round - Use AI to sort through elligible projects based on the plain-language criteria (so the AI interprets the criteria and all projects and smart-assigns them to the round) - Make sure this round allows them to simply choose which project will win (since they have independent criteria) - Make a mix of voting for which project wins the project (or recommended project for the award), but also in the round they can simply assign a project to the award without any criteria requirements and such
|
||||
|
||||
|
||||
|
||||
Notes: Filtering Round: Criteria- Older than 3 years (for all in the startup category), those who submit something random (like a spam project) (AI?)
|
||||
|
||||
-Add filters to the page (who sent documents, etc.)
|
||||
|
||||
-Partners section should be a semi-crm system to track possible sponsors and partners
|
||||
|
||||
-No translation into french (no localization)
|
||||
|
||||
-Ameliorate the user experience (make it more simple)
|
||||
|
||||
-Special Awards- Specific Jury Members (jury members will have to choose amongst projects that fit specific criteria (like the country they're based))- Spotlight on Africa, Coup de Coeur - Make a special award section and make a special case for judge invitation for special awards and allow us to make special awards and assign them to the selection of judges for the special awards specifically (And give them their own login space to see everything)
|
||||
-Invite jury member (with tag for special awards) --> Make special award (Criteria needed, Add a tag for special award (so for example, if a location is italy it will auto have the tag for COup de Coeur (since it's criteria is it only exists in certain countries))) - This is also a separate Jury Round - Use AI to sort through elligible projects based on the plain-language criteria (so the AI interprets the criteria and all projects and smart-assigns them to the round) - Make sure this round allows them to simply choose which project will win (since they have independent criteria) - Make a mix of voting for which project wins the project (or recommended project for the award), but also in the round they can simply assign a project to the award without any criteria requirements and such
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,68 +1,68 @@
|
|||
# Mixed Round Design Implementation Docs
|
||||
|
||||
## Purpose
|
||||
This folder contains a single consolidated redesign program that intentionally blends:
|
||||
|
||||
- Delivery rigor and governance discipline from `codex-round-system-redesign`
|
||||
- Target architecture depth and runtime detail from `claude-round-system-redesign`
|
||||
- Award governance semantics from `glm-5-round-redesign` (especially `AWARD_MASTER` and explicit decision modes)
|
||||
|
||||
The goal is a complete, production-ready implementation plan for rebuilding round orchestration in MOPC with a full-cutover model.
|
||||
|
||||
## Foundation and Blend Strategy
|
||||
|
||||
### Foundation
|
||||
The execution backbone is the `codex` style program model:
|
||||
|
||||
1. Contract freeze first
|
||||
2. Schema/runtime implementation in explicit phases
|
||||
3. Platform-wide dependency refit (not just feature slices)
|
||||
4. Mandatory phase gates with hard release blockers
|
||||
|
||||
### Borrowed Enhancements
|
||||
The plan imports high-value details from other proposals:
|
||||
|
||||
- `claude`: richer canonical model (`Pipeline -> Track -> Stage`), explicit transition engine, routing and live-control runtime detail
|
||||
- `glm-5`: award decision governance (`JURY_VOTE`, `AWARD_MASTER`, `ADMIN`) and explicit award track behavior options
|
||||
|
||||
## Execution Model
|
||||
|
||||
- Single destructive cutover
|
||||
- Full reseed
|
||||
- No backward-compatibility adapter layer
|
||||
- No dual-write period
|
||||
- One atomic release commit once all gates are green
|
||||
|
||||
This model is intentionally selected because infrastructure reset/rebuild is allowed and preferred for architecture quality.
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
- Competition lifecycle is stage-native, not round-pointer native.
|
||||
- Projects progress through explicit `ProjectStageState` records.
|
||||
- Special awards are first-class tracks, not bolt-on side tables.
|
||||
- Routing is rule-driven with explainability payloads.
|
||||
- Live finals are controlled by an admin cursor as the source of truth.
|
||||
- Every override and decision is reasoned, immutable, and auditable.
|
||||
|
||||
## Folder Layout
|
||||
|
||||
- `master-implementation-plan.md`: end-to-end execution map
|
||||
- `shared/`: cross-phase contracts, governance, test model, risks
|
||||
- `phase-00-contract-freeze/` to `phase-07-validation-release/`: implementation phases
|
||||
- `flowcharts/`: core control and routing diagrams
|
||||
|
||||
## How to Use This Plan
|
||||
|
||||
1. Start at `master-implementation-plan.md`.
|
||||
2. Execute phases in order.
|
||||
3. Do not start a phase unless all prior acceptance gates are complete.
|
||||
4. Attach objective evidence for every gate.
|
||||
5. Treat `phase-06-platform-dependency-refit` as mandatory release work, not cleanup.
|
||||
|
||||
## Non-Negotiable Rules
|
||||
|
||||
1. No hidden edit-only required settings.
|
||||
2. Deterministic routing and ranking tie-break behavior.
|
||||
3. Assignment coverage guarantees for eligible projects.
|
||||
4. Explicit voting window control (schedules are advisory only).
|
||||
5. No legacy orchestration contract references at release.
|
||||
# Mixed Round Design Implementation Docs
|
||||
|
||||
## Purpose
|
||||
This folder contains a single consolidated redesign program that intentionally blends:
|
||||
|
||||
- Delivery rigor and governance discipline from `codex-round-system-redesign`
|
||||
- Target architecture depth and runtime detail from `claude-round-system-redesign`
|
||||
- Award governance semantics from `glm-5-round-redesign` (especially `AWARD_MASTER` and explicit decision modes)
|
||||
|
||||
The goal is a complete, production-ready implementation plan for rebuilding round orchestration in MOPC with a full-cutover model.
|
||||
|
||||
## Foundation and Blend Strategy
|
||||
|
||||
### Foundation
|
||||
The execution backbone is the `codex` style program model:
|
||||
|
||||
1. Contract freeze first
|
||||
2. Schema/runtime implementation in explicit phases
|
||||
3. Platform-wide dependency refit (not just feature slices)
|
||||
4. Mandatory phase gates with hard release blockers
|
||||
|
||||
### Borrowed Enhancements
|
||||
The plan imports high-value details from other proposals:
|
||||
|
||||
- `claude`: richer canonical model (`Pipeline -> Track -> Stage`), explicit transition engine, routing and live-control runtime detail
|
||||
- `glm-5`: award decision governance (`JURY_VOTE`, `AWARD_MASTER`, `ADMIN`) and explicit award track behavior options
|
||||
|
||||
## Execution Model
|
||||
|
||||
- Single destructive cutover
|
||||
- Full reseed
|
||||
- No backward-compatibility adapter layer
|
||||
- No dual-write period
|
||||
- One atomic release commit once all gates are green
|
||||
|
||||
This model is intentionally selected because infrastructure reset/rebuild is allowed and preferred for architecture quality.
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
- Competition lifecycle is stage-native, not round-pointer native.
|
||||
- Projects progress through explicit `ProjectStageState` records.
|
||||
- Special awards are first-class tracks, not bolt-on side tables.
|
||||
- Routing is rule-driven with explainability payloads.
|
||||
- Live finals are controlled by an admin cursor as the source of truth.
|
||||
- Every override and decision is reasoned, immutable, and auditable.
|
||||
|
||||
## Folder Layout
|
||||
|
||||
- `master-implementation-plan.md`: end-to-end execution map
|
||||
- `shared/`: cross-phase contracts, governance, test model, risks
|
||||
- `phase-00-contract-freeze/` to `phase-07-validation-release/`: implementation phases
|
||||
- `flowcharts/`: core control and routing diagrams
|
||||
|
||||
## How to Use This Plan
|
||||
|
||||
1. Start at `master-implementation-plan.md`.
|
||||
2. Execute phases in order.
|
||||
3. Do not start a phase unless all prior acceptance gates are complete.
|
||||
4. Attach objective evidence for every gate.
|
||||
5. Treat `phase-06-platform-dependency-refit` as mandatory release work, not cleanup.
|
||||
|
||||
## Non-Negotiable Rules
|
||||
|
||||
1. No hidden edit-only required settings.
|
||||
2. Deterministic routing and ranking tie-break behavior.
|
||||
3. Assignment coverage guarantees for eligible projects.
|
||||
4. Explicit voting window control (schedules are advisory only).
|
||||
5. No legacy orchestration contract references at release.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
# Dependency Refit Map
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Schema Contracts] --> B[Router Refit]
|
||||
A --> C[Service Refit]
|
||||
B --> D[Admin UI Refit]
|
||||
B --> E[Jury/Applicant/Public Refit]
|
||||
C --> E
|
||||
D --> F[Reporting/Exports]
|
||||
E --> F
|
||||
F --> G[Integration Consumer Validation]
|
||||
G --> H[Legacy Symbol Sweep]
|
||||
H --> I[Release Ready]
|
||||
```
|
||||
# Dependency Refit Map
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Schema Contracts] --> B[Router Refit]
|
||||
A --> C[Service Refit]
|
||||
B --> D[Admin UI Refit]
|
||||
B --> E[Jury/Applicant/Public Refit]
|
||||
C --> E
|
||||
D --> F[Reporting/Exports]
|
||||
E --> F
|
||||
F --> G[Integration Consumer Validation]
|
||||
G --> H[Legacy Symbol Sweep]
|
||||
H --> I[Release Ready]
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
# End-to-End Pipeline Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Intake Stage] --> B[Filter Stage]
|
||||
B -->|pass| C[Main Evaluation Stage]
|
||||
B -->|reject| R[Rejected with Notification]
|
||||
B -->|award rule: parallel| W1[Award Track Entry]
|
||||
B -->|award rule: exclusive| W2[Award Track Entry + Main Routed Out]
|
||||
|
||||
C --> D[Selection Stage]
|
||||
D --> E[Live Final Stage]
|
||||
E --> F[Results Stage]
|
||||
|
||||
W1 --> W3[Award Evaluation]
|
||||
W2 --> W3[Award Evaluation]
|
||||
W3 --> W4[Award Winner Decision]
|
||||
|
||||
D -->|manual override| O[Override Action + Audit]
|
||||
O --> D
|
||||
|
||||
E --> L[Live Cursor + Cohort Windows]
|
||||
L --> V[Jury and Audience Voting]
|
||||
V --> F
|
||||
```
|
||||
# End-to-End Pipeline Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Intake Stage] --> B[Filter Stage]
|
||||
B -->|pass| C[Main Evaluation Stage]
|
||||
B -->|reject| R[Rejected with Notification]
|
||||
B -->|award rule: parallel| W1[Award Track Entry]
|
||||
B -->|award rule: exclusive| W2[Award Track Entry + Main Routed Out]
|
||||
|
||||
C --> D[Selection Stage]
|
||||
D --> E[Live Final Stage]
|
||||
E --> F[Results Stage]
|
||||
|
||||
W1 --> W3[Award Evaluation]
|
||||
W2 --> W3[Award Evaluation]
|
||||
W3 --> W4[Award Winner Decision]
|
||||
|
||||
D -->|manual override| O[Override Action + Audit]
|
||||
O --> D
|
||||
|
||||
E --> L[Live Cursor + Cohort Windows]
|
||||
L --> V[Jury and Audience Voting]
|
||||
V --> F
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
# Live Stage Controller
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Admin Live Panel] --> B[Set Active Project Cursor]
|
||||
B --> C[Persist Cursor Versioned Update]
|
||||
C --> D[Broadcast Realtime Event]
|
||||
D --> E[Jury Clients Sync]
|
||||
D --> F[Audience Clients Sync]
|
||||
|
||||
A --> G[Open Cohort Window]
|
||||
A --> H[Close Cohort Window]
|
||||
G --> I[Vote Acceptance On]
|
||||
H --> J[Vote Acceptance Off]
|
||||
```
|
||||
# Live Stage Controller
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Admin Live Panel] --> B[Set Active Project Cursor]
|
||||
B --> C[Persist Cursor Versioned Update]
|
||||
C --> D[Broadcast Realtime Event]
|
||||
D --> E[Jury Clients Sync]
|
||||
D --> F[Audience Clients Sync]
|
||||
|
||||
A --> G[Open Cohort Window]
|
||||
A --> H[Close Cohort Window]
|
||||
G --> I[Vote Acceptance On]
|
||||
H --> J[Vote Acceptance Off]
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
# Main vs Award Routing
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P[Project in Filter Stage] --> Q{Routing Rule Match?}
|
||||
Q -->|No| M[Remain in Main Track]
|
||||
Q -->|Yes| Z{Routing Mode}
|
||||
|
||||
Z -->|PARALLEL| A[Create Award Stage State]
|
||||
A --> B[Keep Main State Active]
|
||||
|
||||
Z -->|EXCLUSIVE| C[Create Award Stage State]
|
||||
C --> D[Mark Main State Routed]
|
||||
|
||||
Z -->|POST_MAIN| E[Defer Route Until Gate Stage]
|
||||
E --> F[Route After Main Gate Condition]
|
||||
```
|
||||
# Main vs Award Routing
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P[Project in Filter Stage] --> Q{Routing Rule Match?}
|
||||
Q -->|No| M[Remain in Main Track]
|
||||
Q -->|Yes| Z{Routing Mode}
|
||||
|
||||
Z -->|PARALLEL| A[Create Award Stage State]
|
||||
A --> B[Keep Main State Active]
|
||||
|
||||
Z -->|EXCLUSIVE| C[Create Award Stage State]
|
||||
C --> D[Mark Main State Routed]
|
||||
|
||||
Z -->|POST_MAIN| E[Defer Route Until Gate Stage]
|
||||
E --> F[Route After Main Gate Condition]
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
# Override Audit Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Override Request] --> B{Authz + Scope Check}
|
||||
B -->|fail| X[FORBIDDEN]
|
||||
B -->|pass| C{Reason Fields Present?}
|
||||
C -->|no| Y[BAD_REQUEST]
|
||||
C -->|yes| D[Fetch Current Value Snapshot]
|
||||
D --> E[Apply Override Mutation]
|
||||
E --> F[Persist Immutable OverrideAction]
|
||||
F --> G[Append DecisionAuditLog]
|
||||
G --> H[Return Updated Entity + Audit Ref]
|
||||
```
|
||||
# Override Audit Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Override Request] --> B{Authz + Scope Check}
|
||||
B -->|fail| X[FORBIDDEN]
|
||||
B -->|pass| C{Reason Fields Present?}
|
||||
C -->|no| Y[BAD_REQUEST]
|
||||
C -->|yes| D[Fetch Current Value Snapshot]
|
||||
D --> E[Apply Override Mutation]
|
||||
E --> F[Persist Immutable OverrideAction]
|
||||
F --> G[Append DecisionAuditLog]
|
||||
G --> H[Return Updated Entity + Audit Ref]
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,95 +1,95 @@
|
|||
# Master Implementation Plan
|
||||
|
||||
## Program Objective
|
||||
Rebuild MOPC round orchestration from a round-centric model into a stage-native pipeline model that is easier to configure, more deterministic, and robust for main competition plus special awards and live finals.
|
||||
|
||||
## Program Constraints
|
||||
|
||||
- Preserve existing visual language and core UI component style.
|
||||
- Complete architecture rebuild is allowed and encouraged.
|
||||
- Delivery must be production-safe and verifiable.
|
||||
- Release requires one atomic cutover commit after full validation.
|
||||
|
||||
## Hard Invariants
|
||||
|
||||
1. Every state transition is explicit, validated, and auditable.
|
||||
2. Every override action captures `reasonCode` + `reasonText` + actor metadata.
|
||||
3. No eligible project is left unassigned unless explicitly flagged as overflow with admin visibility.
|
||||
4. Live active project state is admin-cursor driven.
|
||||
5. Award routing behavior is explicit per award (`parallel`, `exclusive`, `post_main`).
|
||||
6. Event contracts are deterministic and machine-readable.
|
||||
7. At release, no runtime dependency on legacy `roundId` orchestration semantics remains.
|
||||
|
||||
## Phase Chain
|
||||
|
||||
1. Phase 00: Contract freeze
|
||||
2. Phase 01: Schema and runtime foundation
|
||||
3. Phase 02: Backend orchestration engine
|
||||
4. Phase 03: Admin control-plane UX
|
||||
5. Phase 04: Participant journeys
|
||||
6. Phase 05: Special awards governance
|
||||
7. Phase 06: Platform dependency refit
|
||||
8. Phase 07: Validation and release
|
||||
|
||||
## Required Deliverables by Phase
|
||||
|
||||
- Phase 00: locked contracts, decision log, authz matrix, initial risk register
|
||||
- Phase 01: canonical schema spec, migration/cutover scripts, reseed spec, integrity checks
|
||||
- Phase 02: transition/routing/filtering/assignment/live runtime implementation specs
|
||||
- Phase 03: wizard IA, advanced editor spec, form behavior and safety guardrails
|
||||
- Phase 04: applicant/jury/audience runtime and UX contracts
|
||||
- Phase 05: award governance modes and decision workflow implementation
|
||||
- Phase 06: module-by-module refit completion + legacy symbol sweeps
|
||||
- Phase 07: full test evidence, performance evidence, release runbook and sign-off
|
||||
|
||||
## Entry and Exit Criteria (Program Level)
|
||||
|
||||
### Entry
|
||||
|
||||
- Shared contracts and decisions are locked.
|
||||
- Team alignment on cutover model and no-compatibility policy.
|
||||
|
||||
### Exit
|
||||
|
||||
- All phase acceptance gates complete.
|
||||
- Test matrix green for U/I/E/P suites.
|
||||
- Performance and resilience evidence approved.
|
||||
- Legacy symbol sweeps are empty.
|
||||
- Release evidence report signed by Engineering + Product + Operations.
|
||||
|
||||
## Release Blockers
|
||||
|
||||
1. Any failing acceptance gate.
|
||||
2. Any unresolved CRITICAL or HIGH risk without approved mitigation.
|
||||
3. Any missing test evidence for mandatory scenario IDs.
|
||||
4. Any legacy orchestration symbol found in runtime code paths.
|
||||
|
||||
## Timeline Model
|
||||
|
||||
- Phase 00: 2-3 days
|
||||
- Phase 01: 1-1.5 weeks
|
||||
- Phase 02: 1.5-2.5 weeks
|
||||
- Phase 03: 1-1.5 weeks
|
||||
- Phase 04: 1-1.5 weeks
|
||||
- Phase 05: 0.75-1.25 weeks
|
||||
- Phase 06: 1-1.5 weeks
|
||||
- Phase 07: 1 week
|
||||
|
||||
Total estimate: 8-11 weeks depending on test depth and refit complexity.
|
||||
|
||||
## Evidence Standards
|
||||
|
||||
Every acceptance gate requires at least one of:
|
||||
|
||||
- Unit/integration/E2E output
|
||||
- API response captures
|
||||
- deterministic symbol sweeps
|
||||
- migration integrity query output
|
||||
- performance benchmark output
|
||||
- release runbook logs
|
||||
|
||||
## Enforcement Notes
|
||||
|
||||
- No phase skipping.
|
||||
- No deferred blocker carry-forward.
|
||||
- No "ship and patch later" for contract-level gaps.
|
||||
# Master Implementation Plan
|
||||
|
||||
## Program Objective
|
||||
Rebuild MOPC round orchestration from a round-centric model into a stage-native pipeline model that is easier to configure, more deterministic, and robust for main competition plus special awards and live finals.
|
||||
|
||||
## Program Constraints
|
||||
|
||||
- Preserve existing visual language and core UI component style.
|
||||
- Complete architecture rebuild is allowed and encouraged.
|
||||
- Delivery must be production-safe and verifiable.
|
||||
- Release requires one atomic cutover commit after full validation.
|
||||
|
||||
## Hard Invariants
|
||||
|
||||
1. Every state transition is explicit, validated, and auditable.
|
||||
2. Every override action captures `reasonCode` + `reasonText` + actor metadata.
|
||||
3. No eligible project is left unassigned unless explicitly flagged as overflow with admin visibility.
|
||||
4. Live active project state is admin-cursor driven.
|
||||
5. Award routing behavior is explicit per award (`parallel`, `exclusive`, `post_main`).
|
||||
6. Event contracts are deterministic and machine-readable.
|
||||
7. At release, no runtime dependency on legacy `roundId` orchestration semantics remains.
|
||||
|
||||
## Phase Chain
|
||||
|
||||
1. Phase 00: Contract freeze
|
||||
2. Phase 01: Schema and runtime foundation
|
||||
3. Phase 02: Backend orchestration engine
|
||||
4. Phase 03: Admin control-plane UX
|
||||
5. Phase 04: Participant journeys
|
||||
6. Phase 05: Special awards governance
|
||||
7. Phase 06: Platform dependency refit
|
||||
8. Phase 07: Validation and release
|
||||
|
||||
## Required Deliverables by Phase
|
||||
|
||||
- Phase 00: locked contracts, decision log, authz matrix, initial risk register
|
||||
- Phase 01: canonical schema spec, migration/cutover scripts, reseed spec, integrity checks
|
||||
- Phase 02: transition/routing/filtering/assignment/live runtime implementation specs
|
||||
- Phase 03: wizard IA, advanced editor spec, form behavior and safety guardrails
|
||||
- Phase 04: applicant/jury/audience runtime and UX contracts
|
||||
- Phase 05: award governance modes and decision workflow implementation
|
||||
- Phase 06: module-by-module refit completion + legacy symbol sweeps
|
||||
- Phase 07: full test evidence, performance evidence, release runbook and sign-off
|
||||
|
||||
## Entry and Exit Criteria (Program Level)
|
||||
|
||||
### Entry
|
||||
|
||||
- Shared contracts and decisions are locked.
|
||||
- Team alignment on cutover model and no-compatibility policy.
|
||||
|
||||
### Exit
|
||||
|
||||
- All phase acceptance gates complete.
|
||||
- Test matrix green for U/I/E/P suites.
|
||||
- Performance and resilience evidence approved.
|
||||
- Legacy symbol sweeps are empty.
|
||||
- Release evidence report signed by Engineering + Product + Operations.
|
||||
|
||||
## Release Blockers
|
||||
|
||||
1. Any failing acceptance gate.
|
||||
2. Any unresolved CRITICAL or HIGH risk without approved mitigation.
|
||||
3. Any missing test evidence for mandatory scenario IDs.
|
||||
4. Any legacy orchestration symbol found in runtime code paths.
|
||||
|
||||
## Timeline Model
|
||||
|
||||
- Phase 00: 2-3 days
|
||||
- Phase 01: 1-1.5 weeks
|
||||
- Phase 02: 1.5-2.5 weeks
|
||||
- Phase 03: 1-1.5 weeks
|
||||
- Phase 04: 1-1.5 weeks
|
||||
- Phase 05: 0.75-1.25 weeks
|
||||
- Phase 06: 1-1.5 weeks
|
||||
- Phase 07: 1 week
|
||||
|
||||
Total estimate: 8-11 weeks depending on test depth and refit complexity.
|
||||
|
||||
## Evidence Standards
|
||||
|
||||
Every acceptance gate requires at least one of:
|
||||
|
||||
- Unit/integration/E2E output
|
||||
- API response captures
|
||||
- deterministic symbol sweeps
|
||||
- migration integrity query output
|
||||
- performance benchmark output
|
||||
- release runbook logs
|
||||
|
||||
## Enforcement Notes
|
||||
|
||||
- No phase skipping.
|
||||
- No deferred blocker carry-forward.
|
||||
- No "ship and patch later" for contract-level gaps.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
# Phase 00 Acceptance Gates
|
||||
|
||||
- [ ] G-00-1 Decision log locked (`shared/decision-log.md` signed by Eng + Product)
|
||||
- [ ] G-00-2 Domain and API contracts approved
|
||||
- [ ] G-00-3 Authz matrix approved
|
||||
- [ ] G-00-4 Test matrix approved and mapped to owners
|
||||
- [ ] G-00-5 Risk register initialized with owners and mitigation targets
|
||||
|
||||
## Required Evidence
|
||||
|
||||
- contract review notes
|
||||
- sign-off comments or approval records
|
||||
- updated risk register with owners
|
||||
# Phase 00 Acceptance Gates
|
||||
|
||||
- [ ] G-00-1 Decision log locked (`shared/decision-log.md` signed by Eng + Product)
|
||||
- [ ] G-00-2 Domain and API contracts approved
|
||||
- [ ] G-00-3 Authz matrix approved
|
||||
- [ ] G-00-4 Test matrix approved and mapped to owners
|
||||
- [ ] G-00-5 Risk register initialized with owners and mitigation targets
|
||||
|
||||
## Required Evidence
|
||||
|
||||
- contract review notes
|
||||
- sign-off comments or approval records
|
||||
- updated risk register with owners
|
||||
|
|
|
|||
|
|
@ -1,35 +1,35 @@
|
|||
# Phase 00 Overview: Contract Freeze
|
||||
|
||||
## Objective
|
||||
Lock all cross-phase contracts before implementation so the program executes with stable boundaries and no semantic drift.
|
||||
|
||||
## In Scope
|
||||
|
||||
- decision locking
|
||||
- API/type contract baseline
|
||||
- authorization baseline
|
||||
- gate and evidence baseline
|
||||
- initial risk baseline
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- schema implementation
|
||||
- runtime implementation
|
||||
- UI implementation
|
||||
|
||||
## Inputs
|
||||
|
||||
- `shared/program-charter.md`
|
||||
- `shared/decision-log.md`
|
||||
- `shared/domain-model.md`
|
||||
- `shared/api-contracts.md`
|
||||
- `shared/authz-matrix.md`
|
||||
- `shared/test-matrix.md`
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
1. Decision log marked locked with no unresolved critical decision.
|
||||
2. API/type contracts accepted by backend and frontend owners.
|
||||
3. Authz matrix accepted by security owner.
|
||||
4. Risk register initialized with owners.
|
||||
5. Phase 00 acceptance gates complete with evidence.
|
||||
# Phase 00 Overview: Contract Freeze
|
||||
|
||||
## Objective
|
||||
Lock all cross-phase contracts before implementation so the program executes with stable boundaries and no semantic drift.
|
||||
|
||||
## In Scope
|
||||
|
||||
- decision locking
|
||||
- API/type contract baseline
|
||||
- authorization baseline
|
||||
- gate and evidence baseline
|
||||
- initial risk baseline
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- schema implementation
|
||||
- runtime implementation
|
||||
- UI implementation
|
||||
|
||||
## Inputs
|
||||
|
||||
- `shared/program-charter.md`
|
||||
- `shared/decision-log.md`
|
||||
- `shared/domain-model.md`
|
||||
- `shared/api-contracts.md`
|
||||
- `shared/authz-matrix.md`
|
||||
- `shared/test-matrix.md`
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
1. Decision log marked locked with no unresolved critical decision.
|
||||
2. API/type contracts accepted by backend and frontend owners.
|
||||
3. Authz matrix accepted by security owner.
|
||||
4. Risk register initialized with owners.
|
||||
5. Phase 00 acceptance gates complete with evidence.
|
||||
|
|
|
|||
|
|
@ -1,31 +1,31 @@
|
|||
# Phase 00 Tasks
|
||||
|
||||
## Task Set A: Contract Alignment
|
||||
|
||||
- [ ] Validate `shared/domain-model.md` against current repository constraints.
|
||||
- [ ] Validate `shared/api-contracts.md` names and payload conventions with router ownership.
|
||||
- [ ] Validate event naming strategy with notification and webhook owners.
|
||||
|
||||
## Task Set B: Governance Lock
|
||||
|
||||
- [ ] Confirm `shared/decision-log.md` with Product + Engineering.
|
||||
- [ ] Confirm cutover/no-compatibility policy in writing.
|
||||
- [ ] Confirm override governance requirements and mandatory reason fields.
|
||||
|
||||
## Task Set C: Access and Security
|
||||
|
||||
- [ ] Validate `shared/authz-matrix.md` for each role.
|
||||
- [ ] Define scope enforcement standard for program-scoped admin actions.
|
||||
- [ ] Confirm audience vote abuse controls (token, rate-limit, dedupe key).
|
||||
|
||||
## Task Set D: Validation Baseline
|
||||
|
||||
- [ ] Validate `shared/test-matrix.md` coverage and practicality.
|
||||
- [ ] Map each test ID to ownership.
|
||||
- [ ] Confirm CI entry strategy for U/I/E/P layers.
|
||||
|
||||
## Task Set E: Risk Baseline
|
||||
|
||||
- [ ] Review `shared/risk-register.md` with owners.
|
||||
- [ ] Add any repository-specific risks identified during contract review.
|
||||
- [ ] Mark mitigation action owner and due phase per risk.
|
||||
# Phase 00 Tasks
|
||||
|
||||
## Task Set A: Contract Alignment
|
||||
|
||||
- [ ] Validate `shared/domain-model.md` against current repository constraints.
|
||||
- [ ] Validate `shared/api-contracts.md` names and payload conventions with router ownership.
|
||||
- [ ] Validate event naming strategy with notification and webhook owners.
|
||||
|
||||
## Task Set B: Governance Lock
|
||||
|
||||
- [ ] Confirm `shared/decision-log.md` with Product + Engineering.
|
||||
- [ ] Confirm cutover/no-compatibility policy in writing.
|
||||
- [ ] Confirm override governance requirements and mandatory reason fields.
|
||||
|
||||
## Task Set C: Access and Security
|
||||
|
||||
- [ ] Validate `shared/authz-matrix.md` for each role.
|
||||
- [ ] Define scope enforcement standard for program-scoped admin actions.
|
||||
- [ ] Confirm audience vote abuse controls (token, rate-limit, dedupe key).
|
||||
|
||||
## Task Set D: Validation Baseline
|
||||
|
||||
- [ ] Validate `shared/test-matrix.md` coverage and practicality.
|
||||
- [ ] Map each test ID to ownership.
|
||||
- [ ] Confirm CI entry strategy for U/I/E/P layers.
|
||||
|
||||
## Task Set E: Risk Baseline
|
||||
|
||||
- [ ] Review `shared/risk-register.md` with owners.
|
||||
- [ ] Add any repository-specific risks identified during contract review.
|
||||
- [ ] Mark mitigation action owner and due phase per risk.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
# Phase 01 Acceptance Gates
|
||||
|
||||
- [ ] G-01-1 `prisma generate` succeeds
|
||||
- [ ] G-01-2 reset/reseed succeeds in local and staging
|
||||
- [ ] G-01-3 integrity queries return expected zero-error results
|
||||
- [ ] G-01-4 required indexes confirmed in DB metadata
|
||||
- [ ] G-01-5 phase artifacts stored and linked
|
||||
|
||||
## Required Evidence
|
||||
|
||||
- migration command output
|
||||
- reseed logs
|
||||
- integrity query result captures
|
||||
- schema diff summary
|
||||
# Phase 01 Acceptance Gates
|
||||
|
||||
- [ ] G-01-1 `prisma generate` succeeds
|
||||
- [ ] G-01-2 reset/reseed succeeds in local and staging
|
||||
- [ ] G-01-3 integrity queries return expected zero-error results
|
||||
- [ ] G-01-4 required indexes confirmed in DB metadata
|
||||
- [ ] G-01-5 phase artifacts stored and linked
|
||||
|
||||
## Required Evidence
|
||||
|
||||
- migration command output
|
||||
- reseed logs
|
||||
- integrity query result captures
|
||||
- schema diff summary
|
||||
|
|
|
|||
|
|
@ -1,48 +1,48 @@
|
|||
# Phase 01 Migration and Cutover Plan
|
||||
|
||||
## Strategy
|
||||
Perform architecture rebuild with reset/reseed as the official path.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Finalize schema migration scripts.
|
||||
2. Run local reset/reseed rehearsal.
|
||||
3. Run staging reset/reseed rehearsal.
|
||||
4. Execute integrity verification suite.
|
||||
5. Lock schema contracts and produce baseline snapshot.
|
||||
|
||||
## Verification Script Requirements
|
||||
|
||||
- count checks for canonical entities
|
||||
- FK integrity checks
|
||||
- expected stage graph checks
|
||||
- expected project intake state checks
|
||||
|
||||
## Example Verification Queries
|
||||
|
||||
```sql
|
||||
-- orphan project stage states
|
||||
SELECT COUNT(*)
|
||||
FROM "ProjectStageState" pss
|
||||
LEFT JOIN "Project" p ON p.id = pss."projectId"
|
||||
LEFT JOIN "Stage" s ON s.id = pss."stageId"
|
||||
LEFT JOIN "Track" t ON t.id = pss."trackId"
|
||||
WHERE p.id IS NULL OR s.id IS NULL OR t.id IS NULL;
|
||||
|
||||
-- project intake state coverage
|
||||
SELECT COUNT(DISTINCT p.id) AS projects_without_intake
|
||||
FROM "Project" p
|
||||
LEFT JOIN "ProjectStageState" pss
|
||||
ON pss."projectId" = p.id
|
||||
LEFT JOIN "Stage" s
|
||||
ON s.id = pss."stageId"
|
||||
WHERE s."stageType" = 'INTAKE'
|
||||
AND pss.id IS NULL;
|
||||
```
|
||||
|
||||
## Cutover Readiness Artifacts Produced in Phase 01
|
||||
|
||||
- schema migration files
|
||||
- seed scripts
|
||||
- integrity query scripts
|
||||
- reset/reseed execution logs
|
||||
# Phase 01 Migration and Cutover Plan
|
||||
|
||||
## Strategy
|
||||
Perform architecture rebuild with reset/reseed as the official path.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Finalize schema migration scripts.
|
||||
2. Run local reset/reseed rehearsal.
|
||||
3. Run staging reset/reseed rehearsal.
|
||||
4. Execute integrity verification suite.
|
||||
5. Lock schema contracts and produce baseline snapshot.
|
||||
|
||||
## Verification Script Requirements
|
||||
|
||||
- count checks for canonical entities
|
||||
- FK integrity checks
|
||||
- expected stage graph checks
|
||||
- expected project intake state checks
|
||||
|
||||
## Example Verification Queries
|
||||
|
||||
```sql
|
||||
-- orphan project stage states
|
||||
SELECT COUNT(*)
|
||||
FROM "ProjectStageState" pss
|
||||
LEFT JOIN "Project" p ON p.id = pss."projectId"
|
||||
LEFT JOIN "Stage" s ON s.id = pss."stageId"
|
||||
LEFT JOIN "Track" t ON t.id = pss."trackId"
|
||||
WHERE p.id IS NULL OR s.id IS NULL OR t.id IS NULL;
|
||||
|
||||
-- project intake state coverage
|
||||
SELECT COUNT(DISTINCT p.id) AS projects_without_intake
|
||||
FROM "Project" p
|
||||
LEFT JOIN "ProjectStageState" pss
|
||||
ON pss."projectId" = p.id
|
||||
LEFT JOIN "Stage" s
|
||||
ON s.id = pss."stageId"
|
||||
WHERE s."stageType" = 'INTAKE'
|
||||
AND pss.id IS NULL;
|
||||
```
|
||||
|
||||
## Cutover Readiness Artifacts Produced in Phase 01
|
||||
|
||||
- schema migration files
|
||||
- seed scripts
|
||||
- integrity query scripts
|
||||
- reset/reseed execution logs
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
# Phase 01 Overview: Schema and Runtime Foundation
|
||||
|
||||
## Objective
|
||||
Implement the canonical schema and reset/reseed capability that supports stage-native orchestration with award and live runtime primitives.
|
||||
|
||||
## In Scope
|
||||
|
||||
- prisma schema rebuild for canonical entities
|
||||
- indexes and constraints for hot paths
|
||||
- reset/reseed strategy and scripts
|
||||
- data integrity verification scripts
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- end-user UI behavior
|
||||
- full router refit
|
||||
|
||||
## Key Design Choice
|
||||
|
||||
This phase uses full reset/reseed and does not attempt compatibility bridges.
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
1. Schema compiles and generates client successfully.
|
||||
2. Reset/reseed produces runnable dataset.
|
||||
3. Integrity verification passes for FK/index and state initialization rules.
|
||||
4. Phase 01 gates complete.
|
||||
# Phase 01 Overview: Schema and Runtime Foundation
|
||||
|
||||
## Objective
|
||||
Implement the canonical schema and reset/reseed capability that supports stage-native orchestration with award and live runtime primitives.
|
||||
|
||||
## In Scope
|
||||
|
||||
- prisma schema rebuild for canonical entities
|
||||
- indexes and constraints for hot paths
|
||||
- reset/reseed strategy and scripts
|
||||
- data integrity verification scripts
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- end-user UI behavior
|
||||
- full router refit
|
||||
|
||||
## Key Design Choice
|
||||
|
||||
This phase uses full reset/reseed and does not attempt compatibility bridges.
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
1. Schema compiles and generates client successfully.
|
||||
2. Reset/reseed produces runnable dataset.
|
||||
3. Integrity verification passes for FK/index and state initialization rules.
|
||||
4. Phase 01 gates complete.
|
||||
|
|
|
|||
|
|
@ -1,59 +1,59 @@
|
|||
# Phase 01 Schema Specification
|
||||
|
||||
## Summary
|
||||
Introduce the canonical orchestration entities and remove legacy dependency assumptions around single `roundId` progression.
|
||||
|
||||
## New Canonical Tables
|
||||
|
||||
1. `Pipeline`
|
||||
2. `Track`
|
||||
3. `Stage`
|
||||
4. `StageTransition`
|
||||
5. `ProjectStageState`
|
||||
6. `RoutingRule`
|
||||
7. `Cohort`
|
||||
8. `CohortProject`
|
||||
9. `LiveProgressCursor`
|
||||
10. `NotificationPolicy`
|
||||
11. `OverrideAction`
|
||||
12. `DecisionAuditLog`
|
||||
|
||||
## Award Governance Extensions
|
||||
|
||||
- Add `DecisionMode = JURY_VOTE | AWARD_MASTER | ADMIN`
|
||||
- Add award-scoped governance metadata to award track configs
|
||||
- Add award winner finalization audit event contracts
|
||||
|
||||
## Migration Model
|
||||
|
||||
- Build new schema directly as canonical target.
|
||||
- Keep migration files deterministic and replay-safe.
|
||||
- Do not implement dual-write or compatibility tables.
|
||||
|
||||
## Required Constraints
|
||||
|
||||
1. `trackId + sortOrder` unique in `Stage`
|
||||
2. `projectId + trackId + stageId` unique in `ProjectStageState`
|
||||
3. `fromStageId + toStageId` unique in `StageTransition`
|
||||
4. `cohortId + projectId` unique in `CohortProject`
|
||||
|
||||
## Required Indexes
|
||||
|
||||
- `ProjectStageState(projectId, trackId, state)`
|
||||
- `ProjectStageState(stageId, state)`
|
||||
- `RoutingRule(pipelineId, isActive, priority)`
|
||||
- `StageTransition(fromStageId, priority)`
|
||||
- `DecisionAuditLog(entityType, entityId, createdAt)`
|
||||
- `LiveProgressCursor(stageId, sessionId)`
|
||||
|
||||
## Data Initialization Rules
|
||||
|
||||
- Every seeded project must start with one intake-stage state.
|
||||
- Seed must include main track plus at least two award tracks with different routing modes.
|
||||
- Seed must include representative roles: admins, jury, applicants, observer, audience contexts.
|
||||
|
||||
## Integrity Checks
|
||||
|
||||
- No orphan states.
|
||||
- No invalid transition targets across pipelines.
|
||||
- No duplicate active state rows for same `(project, track, stage)`.
|
||||
# Phase 01 Schema Specification
|
||||
|
||||
## Summary
|
||||
Introduce the canonical orchestration entities and remove legacy dependency assumptions around single `roundId` progression.
|
||||
|
||||
## New Canonical Tables
|
||||
|
||||
1. `Pipeline`
|
||||
2. `Track`
|
||||
3. `Stage`
|
||||
4. `StageTransition`
|
||||
5. `ProjectStageState`
|
||||
6. `RoutingRule`
|
||||
7. `Cohort`
|
||||
8. `CohortProject`
|
||||
9. `LiveProgressCursor`
|
||||
10. `NotificationPolicy`
|
||||
11. `OverrideAction`
|
||||
12. `DecisionAuditLog`
|
||||
|
||||
## Award Governance Extensions
|
||||
|
||||
- Add `DecisionMode = JURY_VOTE | AWARD_MASTER | ADMIN`
|
||||
- Add award-scoped governance metadata to award track configs
|
||||
- Add award winner finalization audit event contracts
|
||||
|
||||
## Migration Model
|
||||
|
||||
- Build new schema directly as canonical target.
|
||||
- Keep migration files deterministic and replay-safe.
|
||||
- Do not implement dual-write or compatibility tables.
|
||||
|
||||
## Required Constraints
|
||||
|
||||
1. `trackId + sortOrder` unique in `Stage`
|
||||
2. `projectId + trackId + stageId` unique in `ProjectStageState`
|
||||
3. `fromStageId + toStageId` unique in `StageTransition`
|
||||
4. `cohortId + projectId` unique in `CohortProject`
|
||||
|
||||
## Required Indexes
|
||||
|
||||
- `ProjectStageState(projectId, trackId, state)`
|
||||
- `ProjectStageState(stageId, state)`
|
||||
- `RoutingRule(pipelineId, isActive, priority)`
|
||||
- `StageTransition(fromStageId, priority)`
|
||||
- `DecisionAuditLog(entityType, entityId, createdAt)`
|
||||
- `LiveProgressCursor(stageId, sessionId)`
|
||||
|
||||
## Data Initialization Rules
|
||||
|
||||
- Every seeded project must start with one intake-stage state.
|
||||
- Seed must include main track plus at least two award tracks with different routing modes.
|
||||
- Seed must include representative roles: admins, jury, applicants, observer, audience contexts.
|
||||
|
||||
## Integrity Checks
|
||||
|
||||
- No orphan states.
|
||||
- No invalid transition targets across pipelines.
|
||||
- No duplicate active state rows for same `(project, track, stage)`.
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
# Phase 01 Tasks
|
||||
|
||||
## Schema Build
|
||||
|
||||
- [ ] Implement canonical entities and enums in `prisma/schema.prisma`.
|
||||
- [ ] Add required constraints and indexes.
|
||||
- [ ] Remove or isolate legacy-only orchestration semantics from canonical paths.
|
||||
|
||||
## Seed and Fixtures
|
||||
|
||||
- [ ] Implement reseed script with realistic data volumes and edge cases.
|
||||
- [ ] Include parallel, exclusive, and post-main award routing seed examples.
|
||||
- [ ] Include live cohort seed data.
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] Implement integrity SQL scripts.
|
||||
- [ ] Implement automated verification command wrapper.
|
||||
- [ ] Record baseline output and attach to gate evidence.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] Update schema change notes.
|
||||
- [ ] Document reset/reseed assumptions.
|
||||
# Phase 01 Tasks
|
||||
|
||||
## Schema Build
|
||||
|
||||
- [ ] Implement canonical entities and enums in `prisma/schema.prisma`.
|
||||
- [ ] Add required constraints and indexes.
|
||||
- [ ] Remove or isolate legacy-only orchestration semantics from canonical paths.
|
||||
|
||||
## Seed and Fixtures
|
||||
|
||||
- [ ] Implement reseed script with realistic data volumes and edge cases.
|
||||
- [ ] Include parallel, exclusive, and post-main award routing seed examples.
|
||||
- [ ] Include live cohort seed data.
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] Implement integrity SQL scripts.
|
||||
- [ ] Implement automated verification command wrapper.
|
||||
- [ ] Record baseline output and attach to gate evidence.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] Update schema change notes.
|
||||
- [ ] Document reset/reseed assumptions.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
# Phase 02 Acceptance Gates
|
||||
|
||||
- [ ] G-02-1 transition engine tests pass
|
||||
- [ ] G-02-2 routing determinism tests pass
|
||||
- [ ] G-02-3 filtering policy tests pass
|
||||
- [ ] G-02-4 assignment guarantee tests pass
|
||||
- [ ] G-02-5 live cursor and cohort window tests pass
|
||||
- [ ] G-02-6 override/audit tests pass
|
||||
|
||||
## Required Evidence
|
||||
|
||||
- U/I test output for all mapped IDs
|
||||
- sample API responses for major mutation endpoints
|
||||
- audit payload examples for transition and override flows
|
||||
# Phase 02 Acceptance Gates
|
||||
|
||||
- [ ] G-02-1 transition engine tests pass
|
||||
- [ ] G-02-2 routing determinism tests pass
|
||||
- [ ] G-02-3 filtering policy tests pass
|
||||
- [ ] G-02-4 assignment guarantee tests pass
|
||||
- [ ] G-02-5 live cursor and cohort window tests pass
|
||||
- [ ] G-02-6 override/audit tests pass
|
||||
|
||||
## Required Evidence
|
||||
|
||||
- U/I test output for all mapped IDs
|
||||
- sample API responses for major mutation endpoints
|
||||
- audit payload examples for transition and override flows
|
||||
|
|
|
|||
|
|
@ -1,43 +1,43 @@
|
|||
# Assignment Engine Specification
|
||||
|
||||
## Objective
|
||||
Generate high-quality, fair assignments while guaranteeing eligible project coverage.
|
||||
|
||||
## Inputs
|
||||
|
||||
- stage ID
|
||||
- eligible project set
|
||||
- assignee pool
|
||||
- required reviews per project
|
||||
- assignment strategy config
|
||||
- availability and COI policies
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
1. COI exclusion
|
||||
2. role/status eligibility
|
||||
3. explicit max-load cap
|
||||
4. minimum review floor
|
||||
|
||||
## Soft Scoring Dimensions
|
||||
|
||||
- expertise overlap
|
||||
- bio/project similarity
|
||||
- availability weighting
|
||||
- workload balancing
|
||||
- optional geo diversity
|
||||
- optional prior-familiarity weighting
|
||||
|
||||
## Guarantee Rules
|
||||
|
||||
1. No eligible project left uncovered.
|
||||
2. If capacity insufficient, create overflow assignments with warning markers.
|
||||
3. Preview and execution must match constraints and scoring semantics.
|
||||
|
||||
## Output Contract
|
||||
|
||||
- assigned count
|
||||
- uncovered count (must be zero unless in explicit error mode)
|
||||
- overflow assignment list
|
||||
- conflict skips list
|
||||
- fairness metrics (median load, max load)
|
||||
# Assignment Engine Specification
|
||||
|
||||
## Objective
|
||||
Generate high-quality, fair assignments while guaranteeing eligible project coverage.
|
||||
|
||||
## Inputs
|
||||
|
||||
- stage ID
|
||||
- eligible project set
|
||||
- assignee pool
|
||||
- required reviews per project
|
||||
- assignment strategy config
|
||||
- availability and COI policies
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
1. COI exclusion
|
||||
2. role/status eligibility
|
||||
3. explicit max-load cap
|
||||
4. minimum review floor
|
||||
|
||||
## Soft Scoring Dimensions
|
||||
|
||||
- expertise overlap
|
||||
- bio/project similarity
|
||||
- availability weighting
|
||||
- workload balancing
|
||||
- optional geo diversity
|
||||
- optional prior-familiarity weighting
|
||||
|
||||
## Guarantee Rules
|
||||
|
||||
1. No eligible project left uncovered.
|
||||
2. If capacity insufficient, create overflow assignments with warning markers.
|
||||
3. Preview and execution must match constraints and scoring semantics.
|
||||
|
||||
## Output Contract
|
||||
|
||||
- assigned count
|
||||
- uncovered count (must be zero unless in explicit error mode)
|
||||
- overflow assignment list
|
||||
- conflict skips list
|
||||
- fairness metrics (median load, max load)
|
||||
|
|
|
|||
|
|
@ -1,55 +1,55 @@
|
|||
# Filtering and Routing Specification
|
||||
|
||||
## Filtering Pipeline
|
||||
|
||||
1. deterministic gates
|
||||
2. AI rubric evaluation
|
||||
3. confidence band decisioning
|
||||
4. manual queue resolution
|
||||
|
||||
## Deterministic Gates First Rule
|
||||
|
||||
AI execution is prohibited unless deterministic gates pass.
|
||||
|
||||
## AI Output Contract
|
||||
|
||||
- criteria scores
|
||||
- overall recommendation
|
||||
- confidence
|
||||
- rationale
|
||||
- risk flags
|
||||
|
||||
## Confidence Bands
|
||||
|
||||
- `high`: auto decision path
|
||||
- `medium`: manual queue
|
||||
- `low`: reject or manual based on stage policy
|
||||
|
||||
## Routing Rules
|
||||
|
||||
### Evaluation Order
|
||||
|
||||
1. stage-scoped rules
|
||||
2. track-scoped rules
|
||||
3. global rules
|
||||
4. default fallback
|
||||
|
||||
### Deterministic Tie-Break
|
||||
|
||||
- highest priority wins
|
||||
- if equal, lexical rule ID fallback
|
||||
|
||||
### Explainability Persisted
|
||||
|
||||
Each route persists:
|
||||
|
||||
- matched rule ID
|
||||
- predicate snapshot
|
||||
- mode (`AUTO|MANUAL`)
|
||||
- destination track/stage
|
||||
|
||||
## Award Routing Modes
|
||||
|
||||
- `PARALLEL`: keep main progression and add award state
|
||||
- `EXCLUSIVE`: route out of main progression into award track only
|
||||
- `POST_MAIN`: route only after configured main gate stage
|
||||
# Filtering and Routing Specification
|
||||
|
||||
## Filtering Pipeline
|
||||
|
||||
1. deterministic gates
|
||||
2. AI rubric evaluation
|
||||
3. confidence band decisioning
|
||||
4. manual queue resolution
|
||||
|
||||
## Deterministic Gates First Rule
|
||||
|
||||
AI execution is prohibited unless deterministic gates pass.
|
||||
|
||||
## AI Output Contract
|
||||
|
||||
- criteria scores
|
||||
- overall recommendation
|
||||
- confidence
|
||||
- rationale
|
||||
- risk flags
|
||||
|
||||
## Confidence Bands
|
||||
|
||||
- `high`: auto decision path
|
||||
- `medium`: manual queue
|
||||
- `low`: reject or manual based on stage policy
|
||||
|
||||
## Routing Rules
|
||||
|
||||
### Evaluation Order
|
||||
|
||||
1. stage-scoped rules
|
||||
2. track-scoped rules
|
||||
3. global rules
|
||||
4. default fallback
|
||||
|
||||
### Deterministic Tie-Break
|
||||
|
||||
- highest priority wins
|
||||
- if equal, lexical rule ID fallback
|
||||
|
||||
### Explainability Persisted
|
||||
|
||||
Each route persists:
|
||||
|
||||
- matched rule ID
|
||||
- predicate snapshot
|
||||
- mode (`AUTO|MANUAL`)
|
||||
- destination track/stage
|
||||
|
||||
## Award Routing Modes
|
||||
|
||||
- `PARALLEL`: keep main progression and add award state
|
||||
- `EXCLUSIVE`: route out of main progression into award track only
|
||||
- `POST_MAIN`: route only after configured main gate stage
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
# Live Control Specification
|
||||
|
||||
## Source of Truth
|
||||
Admin cursor state is the single source of truth for active project context during live stages.
|
||||
|
||||
## Core Controls
|
||||
|
||||
- start session
|
||||
- next/previous
|
||||
- jump to project
|
||||
- reorder queue
|
||||
- open/close cohort windows
|
||||
- pause/resume session
|
||||
|
||||
## Runtime Requirements
|
||||
|
||||
1. cursor updates are versioned
|
||||
2. race conditions return `CONFLICT` and require refresh/retry
|
||||
3. real-time propagation to jury and audience clients
|
||||
4. reconnect path converges to current cursor/window state
|
||||
|
||||
## Vote Acceptance Rules
|
||||
|
||||
- stage and cohort windows must be open
|
||||
- dedupe key policy enforced (`session/cohort/project/voter/window`)
|
||||
- closed windows reject submissions deterministically
|
||||
|
||||
## Event Contract
|
||||
|
||||
- `live.cursor.updated`
|
||||
- `cohort.window.changed`
|
||||
- `live.session.state.changed`
|
||||
# Live Control Specification
|
||||
|
||||
## Source of Truth
|
||||
Admin cursor state is the single source of truth for active project context during live stages.
|
||||
|
||||
## Core Controls
|
||||
|
||||
- start session
|
||||
- next/previous
|
||||
- jump to project
|
||||
- reorder queue
|
||||
- open/close cohort windows
|
||||
- pause/resume session
|
||||
|
||||
## Runtime Requirements
|
||||
|
||||
1. cursor updates are versioned
|
||||
2. race conditions return `CONFLICT` and require refresh/retry
|
||||
3. real-time propagation to jury and audience clients
|
||||
4. reconnect path converges to current cursor/window state
|
||||
|
||||
## Vote Acceptance Rules
|
||||
|
||||
- stage and cohort windows must be open
|
||||
- dedupe key policy enforced (`session/cohort/project/voter/window`)
|
||||
- closed windows reject submissions deterministically
|
||||
|
||||
## Event Contract
|
||||
|
||||
- `live.cursor.updated`
|
||||
- `cohort.window.changed`
|
||||
- `live.session.state.changed`
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
# Phase 02 Overview: Backend Orchestration Engine
|
||||
|
||||
## Objective
|
||||
Implement deterministic runtime behavior for stage transitions, routing, filtering, assignment, live cursor control, and notification/audit emission.
|
||||
|
||||
## In Scope
|
||||
|
||||
- transition engine
|
||||
- routing engine
|
||||
- filtering orchestration (gates + AI + manual queue)
|
||||
- assignment orchestration with coverage guarantees
|
||||
- live cursor and cohort window controls
|
||||
- event and audit emission
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- full admin UI and participant UI implementation
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
1. Runtime contracts implemented and integration-tested.
|
||||
2. Determinism and idempotency guarantees proven for critical mutations.
|
||||
3. Mandatory phase gates complete with test evidence.
|
||||
# Phase 02 Overview: Backend Orchestration Engine
|
||||
|
||||
## Objective
|
||||
Implement deterministic runtime behavior for stage transitions, routing, filtering, assignment, live cursor control, and notification/audit emission.
|
||||
|
||||
## In Scope
|
||||
|
||||
- transition engine
|
||||
- routing engine
|
||||
- filtering orchestration (gates + AI + manual queue)
|
||||
- assignment orchestration with coverage guarantees
|
||||
- live cursor and cohort window controls
|
||||
- event and audit emission
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- full admin UI and participant UI implementation
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
1. Runtime contracts implemented and integration-tested.
|
||||
2. Determinism and idempotency guarantees proven for critical mutations.
|
||||
3. Mandatory phase gates complete with test evidence.
|
||||
|
|
|
|||
|
|
@ -1,46 +1,46 @@
|
|||
# Stage Engine Specification
|
||||
|
||||
## Responsibilities
|
||||
|
||||
1. Validate transition legality.
|
||||
2. Move project states transactionally.
|
||||
3. Emit transition events and audit entries.
|
||||
4. Enforce concurrency safety.
|
||||
|
||||
## State Machine
|
||||
|
||||
`PENDING -> IN_PROGRESS -> PASSED|REJECTED -> ROUTED -> COMPLETED`
|
||||
|
||||
`WITHDRAWN` is terminal for participant-triggered withdrawal paths.
|
||||
|
||||
## Transition Guards
|
||||
|
||||
- source state row exists and is active
|
||||
- destination stage is active and in same pipeline (unless routing rule applies)
|
||||
- stage window and guard conditions satisfied
|
||||
- no concurrent conflicting transition
|
||||
|
||||
## Mutation Semantics
|
||||
|
||||
- transactional updates per batch slice
|
||||
- optimistic locking/version checks
|
||||
- per-project result collection for partial failure reporting
|
||||
|
||||
## Failure Codes
|
||||
|
||||
- `PRECONDITION_FAILED`: guard not satisfied
|
||||
- `CONFLICT`: state moved after read
|
||||
- `BAD_REQUEST`: invalid transition target
|
||||
|
||||
## Audit Contract
|
||||
|
||||
`eventType = stage.transitioned`
|
||||
|
||||
Payload includes:
|
||||
|
||||
- actor
|
||||
- source stage
|
||||
- destination stage
|
||||
- old/new state
|
||||
- reason/context
|
||||
- timestamp
|
||||
# Stage Engine Specification
|
||||
|
||||
## Responsibilities
|
||||
|
||||
1. Validate transition legality.
|
||||
2. Move project states transactionally.
|
||||
3. Emit transition events and audit entries.
|
||||
4. Enforce concurrency safety.
|
||||
|
||||
## State Machine
|
||||
|
||||
`PENDING -> IN_PROGRESS -> PASSED|REJECTED -> ROUTED -> COMPLETED`
|
||||
|
||||
`WITHDRAWN` is terminal for participant-triggered withdrawal paths.
|
||||
|
||||
## Transition Guards
|
||||
|
||||
- source state row exists and is active
|
||||
- destination stage is active and in same pipeline (unless routing rule applies)
|
||||
- stage window and guard conditions satisfied
|
||||
- no concurrent conflicting transition
|
||||
|
||||
## Mutation Semantics
|
||||
|
||||
- transactional updates per batch slice
|
||||
- optimistic locking/version checks
|
||||
- per-project result collection for partial failure reporting
|
||||
|
||||
## Failure Codes
|
||||
|
||||
- `PRECONDITION_FAILED`: guard not satisfied
|
||||
- `CONFLICT`: state moved after read
|
||||
- `BAD_REQUEST`: invalid transition target
|
||||
|
||||
## Audit Contract
|
||||
|
||||
`eventType = stage.transitioned`
|
||||
|
||||
Payload includes:
|
||||
|
||||
- actor
|
||||
- source stage
|
||||
- destination stage
|
||||
- old/new state
|
||||
- reason/context
|
||||
- timestamp
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
# Phase 02 Tasks
|
||||
|
||||
## Transition Engine
|
||||
|
||||
- [ ] Implement transition guard and mutation logic.
|
||||
- [ ] Implement optimistic concurrency handling.
|
||||
- [ ] Implement transition event and audit emission.
|
||||
|
||||
## Filtering and Routing
|
||||
|
||||
- [ ] Implement deterministic gate-first pipeline.
|
||||
- [ ] Implement confidence band decision handling.
|
||||
- [ ] Implement routing rule engine with explainability payloads.
|
||||
|
||||
## Assignment
|
||||
|
||||
- [ ] Implement assignment preview and execute parity.
|
||||
- [ ] Implement coverage guarantee and overflow semantics.
|
||||
- [ ] Implement assignment metrics output.
|
||||
|
||||
## Live Runtime
|
||||
|
||||
- [ ] Implement admin cursor operations and conflict-safe update model.
|
||||
- [ ] Implement cohort window control.
|
||||
- [ ] Implement real-time event propagation path.
|
||||
|
||||
## Notifications and Audit
|
||||
|
||||
- [ ] Implement default event producers for stage transitions and outcomes.
|
||||
- [ ] Implement immutable audit payload structure.
|
||||
# Phase 02 Tasks
|
||||
|
||||
## Transition Engine
|
||||
|
||||
- [ ] Implement transition guard and mutation logic.
|
||||
- [ ] Implement optimistic concurrency handling.
|
||||
- [ ] Implement transition event and audit emission.
|
||||
|
||||
## Filtering and Routing
|
||||
|
||||
- [ ] Implement deterministic gate-first pipeline.
|
||||
- [ ] Implement confidence band decision handling.
|
||||
- [ ] Implement routing rule engine with explainability payloads.
|
||||
|
||||
## Assignment
|
||||
|
||||
- [ ] Implement assignment preview and execute parity.
|
||||
- [ ] Implement coverage guarantee and overflow semantics.
|
||||
- [ ] Implement assignment metrics output.
|
||||
|
||||
## Live Runtime
|
||||
|
||||
- [ ] Implement admin cursor operations and conflict-safe update model.
|
||||
- [ ] Implement cohort window control.
|
||||
- [ ] Implement real-time event propagation path.
|
||||
|
||||
## Notifications and Audit
|
||||
|
||||
- [ ] Implement default event producers for stage transitions and outcomes.
|
||||
- [ ] Implement immutable audit payload structure.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
# Phase 03 Acceptance Gates
|
||||
|
||||
- [ ] G-03-1 wizard can complete full required setup (E-001)
|
||||
- [ ] G-03-2 no hidden edit-only required settings remain
|
||||
- [ ] G-03-3 advanced editor enforces graph/config guardrails
|
||||
- [ ] G-03-4 modal and form safety regressions pass
|
||||
|
||||
## Required Evidence
|
||||
|
||||
- E2E wizard completion evidence
|
||||
- parity checklist artifacts
|
||||
- targeted UI regression test output
|
||||
# Phase 03 Acceptance Gates
|
||||
|
||||
- [ ] G-03-1 wizard can complete full required setup (E-001)
|
||||
- [ ] G-03-2 no hidden edit-only required settings remain
|
||||
- [ ] G-03-3 advanced editor enforces graph/config guardrails
|
||||
- [ ] G-03-4 modal and form safety regressions pass
|
||||
|
||||
## Required Evidence
|
||||
|
||||
- E2E wizard completion evidence
|
||||
- parity checklist artifacts
|
||||
- targeted UI regression test output
|
||||
|
|
|
|||
|
|
@ -1,34 +1,34 @@
|
|||
# Advanced Editor Specification
|
||||
|
||||
## Purpose
|
||||
Provide direct manipulation of tracks, stages, transitions, and routing without polluting the default wizard path.
|
||||
|
||||
## Panels
|
||||
|
||||
1. Track/Stage List Panel
|
||||
2. Stage Config Panel
|
||||
3. Transition Graph Panel
|
||||
4. Routing Rule Inspector
|
||||
5. Simulation Panel
|
||||
|
||||
## Required Capabilities
|
||||
|
||||
- reorder stages within track
|
||||
- move valid stages across tracks
|
||||
- create/delete transitions
|
||||
- edit rule predicates and priorities
|
||||
- simulate outcomes for sample project IDs
|
||||
|
||||
## Guardrails
|
||||
|
||||
1. Block disconnected required paths.
|
||||
2. Block orphan stage deletion.
|
||||
3. Warn before destructive transition/rule removal.
|
||||
4. Enforce schema validation for stage config payloads.
|
||||
|
||||
## Save Model
|
||||
|
||||
- draft buffer
|
||||
- validation run
|
||||
- transactional persist
|
||||
- validation report artifact
|
||||
# Advanced Editor Specification
|
||||
|
||||
## Purpose
|
||||
Provide direct manipulation of tracks, stages, transitions, and routing without polluting the default wizard path.
|
||||
|
||||
## Panels
|
||||
|
||||
1. Track/Stage List Panel
|
||||
2. Stage Config Panel
|
||||
3. Transition Graph Panel
|
||||
4. Routing Rule Inspector
|
||||
5. Simulation Panel
|
||||
|
||||
## Required Capabilities
|
||||
|
||||
- reorder stages within track
|
||||
- move valid stages across tracks
|
||||
- create/delete transitions
|
||||
- edit rule predicates and priorities
|
||||
- simulate outcomes for sample project IDs
|
||||
|
||||
## Guardrails
|
||||
|
||||
1. Block disconnected required paths.
|
||||
2. Block orphan stage deletion.
|
||||
3. Warn before destructive transition/rule removal.
|
||||
4. Enforce schema validation for stage config payloads.
|
||||
|
||||
## Save Model
|
||||
|
||||
- draft buffer
|
||||
- validation run
|
||||
- transactional persist
|
||||
- validation report artifact
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
# Form Behavior and Validation Rules
|
||||
|
||||
## Universal Rules
|
||||
|
||||
1. Every required field has inline validation.
|
||||
2. Every select has deterministic default value.
|
||||
3. Save actions are idempotent and disabled while pending.
|
||||
4. Unsafe changes surface explicit impact warnings.
|
||||
|
||||
## Create/Edit Parity Requirements
|
||||
|
||||
- intake windows
|
||||
- upload policy
|
||||
- file requirements
|
||||
- assignment policy
|
||||
- filtering policy
|
||||
- routing policy
|
||||
- live policy
|
||||
|
||||
## Modal Safety Rules
|
||||
|
||||
1. Modal close must not mutate persisted state.
|
||||
2. Non-submit buttons must explicitly set `type="button"`.
|
||||
3. Escape/cancel should only dismiss local draft state.
|
||||
|
||||
## Payload Safety
|
||||
|
||||
- replace raw free-text config where structured selectors exist
|
||||
- normalize serialization format for config payloads
|
||||
- reject unknown keys in strict mode contracts
|
||||
# Form Behavior and Validation Rules
|
||||
|
||||
## Universal Rules
|
||||
|
||||
1. Every required field has inline validation.
|
||||
2. Every select has deterministic default value.
|
||||
3. Save actions are idempotent and disabled while pending.
|
||||
4. Unsafe changes surface explicit impact warnings.
|
||||
|
||||
## Create/Edit Parity Requirements
|
||||
|
||||
- intake windows
|
||||
- upload policy
|
||||
- file requirements
|
||||
- assignment policy
|
||||
- filtering policy
|
||||
- routing policy
|
||||
- live policy
|
||||
|
||||
## Modal Safety Rules
|
||||
|
||||
1. Modal close must not mutate persisted state.
|
||||
2. Non-submit buttons must explicitly set `type="button"`.
|
||||
3. Escape/cancel should only dismiss local draft state.
|
||||
|
||||
## Payload Safety
|
||||
|
||||
- replace raw free-text config where structured selectors exist
|
||||
- normalize serialization format for config payloads
|
||||
- reject unknown keys in strict mode contracts
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
# Phase 03 Overview: Admin Control-Plane UX
|
||||
|
||||
## Objective
|
||||
Deliver a wizard-first admin control plane that exposes full required configuration in create-time flow, with a safe advanced editor for power users.
|
||||
|
||||
## In Scope
|
||||
|
||||
- setup wizard IA and behavior
|
||||
- advanced stage and routing editor
|
||||
- simulation and validation panel
|
||||
- create/edit parity
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- visual redesign
|
||||
- participant-facing workflows
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
1. Full required setup possible from create flow.
|
||||
2. No hidden edit-only required fields.
|
||||
3. Validation and simulation guardrails implemented.
|
||||
4. Phase gates complete.
|
||||
# Phase 03 Overview: Admin Control-Plane UX
|
||||
|
||||
## Objective
|
||||
Deliver a wizard-first admin control plane that exposes full required configuration in create-time flow, with a safe advanced editor for power users.
|
||||
|
||||
## In Scope
|
||||
|
||||
- setup wizard IA and behavior
|
||||
- advanced stage and routing editor
|
||||
- simulation and validation panel
|
||||
- create/edit parity
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- visual redesign
|
||||
- participant-facing workflows
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
1. Full required setup possible from create flow.
|
||||
2. No hidden edit-only required fields.
|
||||
3. Validation and simulation guardrails implemented.
|
||||
4. Phase gates complete.
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
# Phase 03 Tasks
|
||||
|
||||
## Wizard
|
||||
|
||||
- [ ] Implement 8-step setup flow.
|
||||
- [ ] Implement step-level validation and progress state.
|
||||
- [ ] Implement review/publish summary and blockers.
|
||||
|
||||
## Advanced Editor
|
||||
|
||||
- [ ] Implement stage/transition/routing editing surfaces.
|
||||
- [ ] Implement simulation runner and result panel.
|
||||
- [ ] Implement destructive action confirmations.
|
||||
|
||||
## Behavior and Safety
|
||||
|
||||
- [ ] Enforce create/edit parity checklist.
|
||||
- [ ] Enforce modal safety rules.
|
||||
- [ ] Enforce strict payload validation.
|
||||
# Phase 03 Tasks
|
||||
|
||||
## Wizard
|
||||
|
||||
- [ ] Implement 8-step setup flow.
|
||||
- [ ] Implement step-level validation and progress state.
|
||||
- [ ] Implement review/publish summary and blockers.
|
||||
|
||||
## Advanced Editor
|
||||
|
||||
- [ ] Implement stage/transition/routing editing surfaces.
|
||||
- [ ] Implement simulation runner and result panel.
|
||||
- [ ] Implement destructive action confirmations.
|
||||
|
||||
## Behavior and Safety
|
||||
|
||||
- [ ] Enforce create/edit parity checklist.
|
||||
- [ ] Enforce modal safety rules.
|
||||
- [ ] Enforce strict payload validation.
|
||||
|
|
|
|||
|
|
@ -1,78 +1,78 @@
|
|||
# Admin Wizard IA
|
||||
|
||||
## Step Sequence
|
||||
|
||||
1. Intake Setup
|
||||
2. Main Track Stage Setup
|
||||
3. Filtering Strategy
|
||||
4. Assignment Strategy
|
||||
5. Special Awards
|
||||
6. Live Finals Configuration
|
||||
7. Notifications and Overrides
|
||||
8. Review + Publish
|
||||
|
||||
## Step Details
|
||||
|
||||
### 1) Intake Setup
|
||||
|
||||
- submission windows
|
||||
- late policy
|
||||
- file requirements
|
||||
- MIME/size constraints
|
||||
- applicant communication policy
|
||||
|
||||
### 2) Main Track Stage Setup
|
||||
|
||||
- stage list and ordering
|
||||
- stage type assignment
|
||||
- status defaults
|
||||
- selection stage presets
|
||||
|
||||
### 3) Filtering Strategy
|
||||
|
||||
- deterministic gate definition
|
||||
- AI rubric configuration
|
||||
- confidence thresholds
|
||||
- manual queue owners
|
||||
|
||||
### 4) Assignment Strategy
|
||||
|
||||
- required reviews
|
||||
- max/min load settings
|
||||
- availability weighting
|
||||
- overflow handling policy
|
||||
|
||||
### 5) Special Awards
|
||||
|
||||
- award track enablement
|
||||
- routing mode per award
|
||||
- decision mode per award
|
||||
- award jury restrictions
|
||||
|
||||
### 6) Live Finals
|
||||
|
||||
- cursor control mode
|
||||
- jury vote config
|
||||
- audience vote config
|
||||
- cohort setup
|
||||
- reveal policy
|
||||
|
||||
### 7) Notifications and Overrides
|
||||
|
||||
- default-on event toggles
|
||||
- template overrides
|
||||
- override governance policy
|
||||
|
||||
### 8) Review + Publish
|
||||
|
||||
- summary diff
|
||||
- warnings/blockers
|
||||
- simulation output
|
||||
- publish action
|
||||
|
||||
## UX Requirements
|
||||
|
||||
- mobile-safe interaction and layout
|
||||
- explicit required field indicators
|
||||
- deterministic defaults for every select
|
||||
- inline validation without hidden blockers
|
||||
# Admin Wizard IA
|
||||
|
||||
## Step Sequence
|
||||
|
||||
1. Intake Setup
|
||||
2. Main Track Stage Setup
|
||||
3. Filtering Strategy
|
||||
4. Assignment Strategy
|
||||
5. Special Awards
|
||||
6. Live Finals Configuration
|
||||
7. Notifications and Overrides
|
||||
8. Review + Publish
|
||||
|
||||
## Step Details
|
||||
|
||||
### 1) Intake Setup
|
||||
|
||||
- submission windows
|
||||
- late policy
|
||||
- file requirements
|
||||
- MIME/size constraints
|
||||
- applicant communication policy
|
||||
|
||||
### 2) Main Track Stage Setup
|
||||
|
||||
- stage list and ordering
|
||||
- stage type assignment
|
||||
- status defaults
|
||||
- selection stage presets
|
||||
|
||||
### 3) Filtering Strategy
|
||||
|
||||
- deterministic gate definition
|
||||
- AI rubric configuration
|
||||
- confidence thresholds
|
||||
- manual queue owners
|
||||
|
||||
### 4) Assignment Strategy
|
||||
|
||||
- required reviews
|
||||
- max/min load settings
|
||||
- availability weighting
|
||||
- overflow handling policy
|
||||
|
||||
### 5) Special Awards
|
||||
|
||||
- award track enablement
|
||||
- routing mode per award
|
||||
- decision mode per award
|
||||
- award jury restrictions
|
||||
|
||||
### 6) Live Finals
|
||||
|
||||
- cursor control mode
|
||||
- jury vote config
|
||||
- audience vote config
|
||||
- cohort setup
|
||||
- reveal policy
|
||||
|
||||
### 7) Notifications and Overrides
|
||||
|
||||
- default-on event toggles
|
||||
- template overrides
|
||||
- override governance policy
|
||||
|
||||
### 8) Review + Publish
|
||||
|
||||
- summary diff
|
||||
- warnings/blockers
|
||||
- simulation output
|
||||
- publish action
|
||||
|
||||
## UX Requirements
|
||||
|
||||
- mobile-safe interaction and layout
|
||||
- explicit required field indicators
|
||||
- deterministic defaults for every select
|
||||
- inline validation without hidden blockers
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
# Phase 04 Acceptance Gates
|
||||
|
||||
- [ ] G-04-1 applicant flow tests pass (E-002)
|
||||
- [ ] G-04-2 jury flow tests pass (E-004)
|
||||
- [ ] G-04-3 live audience tests pass (E-006/E-007)
|
||||
- [ ] G-04-4 reconnect and realtime resilience evidence passes (P-004)
|
||||
|
||||
## Required Evidence
|
||||
|
||||
- E2E artifacts for applicant/jury/audience scenarios
|
||||
- realtime and reconnect test captures
|
||||
# Phase 04 Acceptance Gates
|
||||
|
||||
- [ ] G-04-1 applicant flow tests pass (E-002)
|
||||
- [ ] G-04-2 jury flow tests pass (E-004)
|
||||
- [ ] G-04-3 live audience tests pass (E-006/E-007)
|
||||
- [ ] G-04-4 reconnect and realtime resilience evidence passes (P-004)
|
||||
|
||||
## Required Evidence
|
||||
|
||||
- E2E artifacts for applicant/jury/audience scenarios
|
||||
- realtime and reconnect test captures
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
# Applicant Experience Specification
|
||||
|
||||
## Required Views
|
||||
|
||||
1. current stage and timeline
|
||||
2. stage-specific requirements
|
||||
3. deadlines and late policy status
|
||||
4. team invite/account status
|
||||
5. decision history (policy-scoped)
|
||||
|
||||
## Behavior Requirements
|
||||
|
||||
- requirement upload slots are stage-aware
|
||||
- accepted MIME/size and deadline checks enforced at submit time
|
||||
- timeline updates reflect transition and decision events quickly
|
||||
- role-scoped team collaboration controls enforced
|
||||
|
||||
## Error States
|
||||
|
||||
- missing requirement definition
|
||||
- expired upload window
|
||||
- invalid MIME/size
|
||||
- stale session/permission mismatch
|
||||
|
||||
## Notification Expectations
|
||||
|
||||
- intake submitted confirmation
|
||||
- advanced/rejected updates
|
||||
- additional requirement requests when policy allows
|
||||
# Applicant Experience Specification
|
||||
|
||||
## Required Views
|
||||
|
||||
1. current stage and timeline
|
||||
2. stage-specific requirements
|
||||
3. deadlines and late policy status
|
||||
4. team invite/account status
|
||||
5. decision history (policy-scoped)
|
||||
|
||||
## Behavior Requirements
|
||||
|
||||
- requirement upload slots are stage-aware
|
||||
- accepted MIME/size and deadline checks enforced at submit time
|
||||
- timeline updates reflect transition and decision events quickly
|
||||
- role-scoped team collaboration controls enforced
|
||||
|
||||
## Error States
|
||||
|
||||
- missing requirement definition
|
||||
- expired upload window
|
||||
- invalid MIME/size
|
||||
- stale session/permission mismatch
|
||||
|
||||
## Notification Expectations
|
||||
|
||||
- intake submitted confirmation
|
||||
- advanced/rejected updates
|
||||
- additional requirement requests when policy allows
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
# Audience Live Vote Specification
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. Audience sees only projects within active cohort/window policy.
|
||||
2. Vote submission requires valid session eligibility and dedupe key check.
|
||||
3. Closed windows reject submissions with typed error.
|
||||
|
||||
## Voting Modes
|
||||
|
||||
- per-project window
|
||||
- per-cohort window
|
||||
- optional criteria mode or simple score mode
|
||||
|
||||
## Safety and Abuse Controls
|
||||
|
||||
- tokenized access policy
|
||||
- optional identity requirement
|
||||
- rate-limit and dedupe enforcement
|
||||
|
||||
## Realtime Requirements
|
||||
|
||||
- active project state and window state sync in near real-time
|
||||
- reconnect path restores current eligible ballot context
|
||||
# Audience Live Vote Specification
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. Audience sees only projects within active cohort/window policy.
|
||||
2. Vote submission requires valid session eligibility and dedupe key check.
|
||||
3. Closed windows reject submissions with typed error.
|
||||
|
||||
## Voting Modes
|
||||
|
||||
- per-project window
|
||||
- per-cohort window
|
||||
- optional criteria mode or simple score mode
|
||||
|
||||
## Safety and Abuse Controls
|
||||
|
||||
- tokenized access policy
|
||||
- optional identity requirement
|
||||
- rate-limit and dedupe enforcement
|
||||
|
||||
## Realtime Requirements
|
||||
|
||||
- active project state and window state sync in near real-time
|
||||
- reconnect path restores current eligible ballot context
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
# Jury Experience Specification
|
||||
|
||||
## Assignment View
|
||||
|
||||
- grouped by stage
|
||||
- explicit open/close window indicators
|
||||
- progress and completion states
|
||||
|
||||
## Evaluation View
|
||||
|
||||
- criteria loaded from stage config
|
||||
- required criteria enforcement
|
||||
- draft autosave and submit lock behavior
|
||||
- COI declaration flow integrated
|
||||
|
||||
## Access Rules
|
||||
|
||||
- only assigned projects visible
|
||||
- voting restricted to open windows
|
||||
- prior-stage material visibility policy respected
|
||||
|
||||
## Live Jury Behavior
|
||||
|
||||
- active project context sync via realtime updates
|
||||
- vote actions gated by cursor and window state
|
||||
- reconnect restores current live context
|
||||
# Jury Experience Specification
|
||||
|
||||
## Assignment View
|
||||
|
||||
- grouped by stage
|
||||
- explicit open/close window indicators
|
||||
- progress and completion states
|
||||
|
||||
## Evaluation View
|
||||
|
||||
- criteria loaded from stage config
|
||||
- required criteria enforcement
|
||||
- draft autosave and submit lock behavior
|
||||
- COI declaration flow integrated
|
||||
|
||||
## Access Rules
|
||||
|
||||
- only assigned projects visible
|
||||
- voting restricted to open windows
|
||||
- prior-stage material visibility policy respected
|
||||
|
||||
## Live Jury Behavior
|
||||
|
||||
- active project context sync via realtime updates
|
||||
- vote actions gated by cursor and window state
|
||||
- reconnect restores current live context
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
# Phase 04 Overview: Participant Journeys
|
||||
|
||||
## Objective
|
||||
Refit applicant, jury, observer, and audience experiences to stage-native contracts with correct realtime behavior.
|
||||
|
||||
## In Scope
|
||||
|
||||
- applicant intake/status flows
|
||||
- jury assignment/evaluation/live flows
|
||||
- audience voting and live score flows
|
||||
- observer read-only reporting alignment
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- admin config internals
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
1. End-to-end participant paths pass mandatory E2E tests.
|
||||
2. Realtime behavior converges under reconnect and window changes.
|
||||
3. Policy enforcement matches authz and stage contracts.
|
||||
# Phase 04 Overview: Participant Journeys
|
||||
|
||||
## Objective
|
||||
Refit applicant, jury, observer, and audience experiences to stage-native contracts with correct realtime behavior.
|
||||
|
||||
## In Scope
|
||||
|
||||
- applicant intake/status flows
|
||||
- jury assignment/evaluation/live flows
|
||||
- audience voting and live score flows
|
||||
- observer read-only reporting alignment
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- admin config internals
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
1. End-to-end participant paths pass mandatory E2E tests.
|
||||
2. Realtime behavior converges under reconnect and window changes.
|
||||
3. Policy enforcement matches authz and stage contracts.
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
# Phase 04 Tasks
|
||||
|
||||
## Applicant
|
||||
|
||||
- [ ] Implement stage-native timeline and requirement resolver.
|
||||
- [ ] Implement strict upload gating and policy enforcement.
|
||||
|
||||
## Jury
|
||||
|
||||
- [ ] Implement stage-scoped assignment and evaluation surfaces.
|
||||
- [ ] Implement live jury context sync and voting constraints.
|
||||
|
||||
## Audience and Observer
|
||||
|
||||
- [ ] Implement cohort-scoped audience ballot visibility.
|
||||
- [ ] Implement observer read-only stage/track reporting alignment.
|
||||
|
||||
## Realtime and Resilience
|
||||
|
||||
- [ ] Implement reconnect-state convergence behavior.
|
||||
- [ ] Validate realtime event consistency under cursor updates.
|
||||
# Phase 04 Tasks
|
||||
|
||||
## Applicant
|
||||
|
||||
- [ ] Implement stage-native timeline and requirement resolver.
|
||||
- [ ] Implement strict upload gating and policy enforcement.
|
||||
|
||||
## Jury
|
||||
|
||||
- [ ] Implement stage-scoped assignment and evaluation surfaces.
|
||||
- [ ] Implement live jury context sync and voting constraints.
|
||||
|
||||
## Audience and Observer
|
||||
|
||||
- [ ] Implement cohort-scoped audience ballot visibility.
|
||||
- [ ] Implement observer read-only stage/track reporting alignment.
|
||||
|
||||
## Realtime and Resilience
|
||||
|
||||
- [ ] Implement reconnect-state convergence behavior.
|
||||
- [ ] Validate realtime event consistency under cursor updates.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
# Phase 05 Acceptance Gates
|
||||
|
||||
- [ ] G-05-1 routing mode behavior validated (`parallel`, `exclusive`, `post_main`)
|
||||
- [ ] G-05-2 governance auth tests pass (`JURY_VOTE`, `AWARD_MASTER`, `ADMIN`)
|
||||
- [ ] G-05-3 winner decision timeline and audit output validated
|
||||
|
||||
## Required Evidence
|
||||
|
||||
- integration test outputs for routing and governance
|
||||
- audit timeline payload captures
|
||||
# Phase 05 Acceptance Gates
|
||||
|
||||
- [ ] G-05-1 routing mode behavior validated (`parallel`, `exclusive`, `post_main`)
|
||||
- [ ] G-05-2 governance auth tests pass (`JURY_VOTE`, `AWARD_MASTER`, `ADMIN`)
|
||||
- [ ] G-05-3 winner decision timeline and audit output validated
|
||||
|
||||
## Required Evidence
|
||||
|
||||
- integration test outputs for routing and governance
|
||||
- audit timeline payload captures
|
||||
|
|
|
|||
|
|
@ -1,39 +1,39 @@
|
|||
# Award Track and Governance Specification
|
||||
|
||||
## Award Track Principle
|
||||
Awards share the same orchestration engine as the main competition; they are tracks, not detached side workflows.
|
||||
|
||||
## Routing Modes
|
||||
|
||||
- `PARALLEL`: award path runs while main path continues
|
||||
- `EXCLUSIVE`: project exits main continuation path and runs award-only
|
||||
- `POST_MAIN`: award route starts after configured main gate
|
||||
|
||||
## Governance Modes
|
||||
|
||||
- `JURY_VOTE`: assigned award jurors vote
|
||||
- `AWARD_MASTER`: designated award owner decides within scope
|
||||
- `ADMIN`: program/super admin decides
|
||||
|
||||
## Decision Requirements
|
||||
|
||||
- every winner/finalist decision emits audit entry
|
||||
- manual overrides require reason code and text
|
||||
- tie-break policy explicit and deterministic
|
||||
|
||||
## Permission Enforcement
|
||||
|
||||
- governance mode checked server-side on every decision mutation
|
||||
- unauthorized attempts return `FORBIDDEN`
|
||||
|
||||
## Representative Decision Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"awardId": "award_123",
|
||||
"decisionMode": "AWARD_MASTER",
|
||||
"winnerProjectId": "project_789",
|
||||
"reasonCode": "SPONSOR_DECISION",
|
||||
"reasonText": "Award sponsor selected based on category fit"
|
||||
}
|
||||
```
|
||||
# Award Track and Governance Specification
|
||||
|
||||
## Award Track Principle
|
||||
Awards share the same orchestration engine as the main competition; they are tracks, not detached side workflows.
|
||||
|
||||
## Routing Modes
|
||||
|
||||
- `PARALLEL`: award path runs while main path continues
|
||||
- `EXCLUSIVE`: project exits main continuation path and runs award-only
|
||||
- `POST_MAIN`: award route starts after configured main gate
|
||||
|
||||
## Governance Modes
|
||||
|
||||
- `JURY_VOTE`: assigned award jurors vote
|
||||
- `AWARD_MASTER`: designated award owner decides within scope
|
||||
- `ADMIN`: program/super admin decides
|
||||
|
||||
## Decision Requirements
|
||||
|
||||
- every winner/finalist decision emits audit entry
|
||||
- manual overrides require reason code and text
|
||||
- tie-break policy explicit and deterministic
|
||||
|
||||
## Permission Enforcement
|
||||
|
||||
- governance mode checked server-side on every decision mutation
|
||||
- unauthorized attempts return `FORBIDDEN`
|
||||
|
||||
## Representative Decision Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"awardId": "award_123",
|
||||
"decisionMode": "AWARD_MASTER",
|
||||
"winnerProjectId": "project_789",
|
||||
"reasonCode": "SPONSOR_DECISION",
|
||||
"reasonText": "Award sponsor selected based on category fit"
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
# Phase 05 Overview: Special Awards and Governance
|
||||
|
||||
## Objective
|
||||
Implement special awards as first-class tracks with explicit routing and governance modes, including `AWARD_MASTER`.
|
||||
|
||||
## In Scope
|
||||
|
||||
- award track lifecycle
|
||||
- routing semantics
|
||||
- governance modes and permissions
|
||||
- award decision and winner finalization workflows
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- sponsor legal/contract process documentation
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
1. Mixed award modes run without collision in a single edition.
|
||||
2. Governance modes enforce correct permissions server-side.
|
||||
3. Winner decision audit trails are complete and immutable.
|
||||
# Phase 05 Overview: Special Awards and Governance
|
||||
|
||||
## Objective
|
||||
Implement special awards as first-class tracks with explicit routing and governance modes, including `AWARD_MASTER`.
|
||||
|
||||
## In Scope
|
||||
|
||||
- award track lifecycle
|
||||
- routing semantics
|
||||
- governance modes and permissions
|
||||
- award decision and winner finalization workflows
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- sponsor legal/contract process documentation
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
1. Mixed award modes run without collision in a single edition.
|
||||
2. Governance modes enforce correct permissions server-side.
|
||||
3. Winner decision audit trails are complete and immutable.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Phase 05 Tasks
|
||||
|
||||
- [ ] Implement award track CRUD on canonical contracts.
|
||||
- [ ] Implement award routing mode behaviors and edge-case handling.
|
||||
- [ ] Implement governance mode permission checks.
|
||||
- [ ] Implement winner finalization and audit timeline entries.
|
||||
- [ ] Implement award-specific reporting outputs.
|
||||
# Phase 05 Tasks
|
||||
|
||||
- [ ] Implement award track CRUD on canonical contracts.
|
||||
- [ ] Implement award routing mode behaviors and edge-case handling.
|
||||
- [ ] Implement governance mode permission checks.
|
||||
- [ ] Implement winner finalization and audit timeline entries.
|
||||
- [ ] Implement award-specific reporting outputs.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
# Phase 06 Acceptance Gates
|
||||
|
||||
- [ ] G-06-1 dependency refit inventory fully signed off
|
||||
- [ ] G-06-2 symbol sweeps clean (no runtime legacy hits)
|
||||
- [ ] G-06-3 integration consumer payload checks pass
|
||||
- [ ] G-06-4 cross-role smoke tests pass
|
||||
|
||||
## Required Evidence
|
||||
|
||||
- module sign-off checklist with owners
|
||||
- sweep outputs
|
||||
- webhook/export consumer validation logs
|
||||
# Phase 06 Acceptance Gates
|
||||
|
||||
- [ ] G-06-1 dependency refit inventory fully signed off
|
||||
- [ ] G-06-2 symbol sweeps clean (no runtime legacy hits)
|
||||
- [ ] G-06-3 integration consumer payload checks pass
|
||||
- [ ] G-06-4 cross-role smoke tests pass
|
||||
|
||||
## Required Evidence
|
||||
|
||||
- module sign-off checklist with owners
|
||||
- sweep outputs
|
||||
- webhook/export consumer validation logs
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
# Module Refit Map
|
||||
|
||||
## Router Layer Actions
|
||||
|
||||
- Rewrite orchestration endpoints to `pipeline/stage/routing` contracts.
|
||||
- Refactor filtering, assignment, and live endpoints to stage-scoped semantics.
|
||||
- Replace award detached flows with award-track-native contracts.
|
||||
|
||||
## Service Layer Actions
|
||||
|
||||
- Refactor AI filtering context to stage-native payloads.
|
||||
- Refactor assignment engine to consume stage eligibility and availability.
|
||||
- Refactor notification producers to new event taxonomy.
|
||||
- Refactor reminders and summaries to stage references.
|
||||
|
||||
## UI Layer Actions
|
||||
|
||||
- Admin round pages become pipeline/stage control-plane pages.
|
||||
- Jury and applicant pages consume stage timeline and stage requirement contracts.
|
||||
- Public vote/live pages consume cohort and live cursor state.
|
||||
|
||||
## Reporting and Export Actions
|
||||
|
||||
- Replace round-grouped aggregations with stage/track aggregations.
|
||||
- Update CSV/PDF payload field names to new contracts.
|
||||
- Update observer dashboards and chart dimensions.
|
||||
# Module Refit Map
|
||||
|
||||
## Router Layer Actions
|
||||
|
||||
- Rewrite orchestration endpoints to `pipeline/stage/routing` contracts.
|
||||
- Refactor filtering, assignment, and live endpoints to stage-scoped semantics.
|
||||
- Replace award detached flows with award-track-native contracts.
|
||||
|
||||
## Service Layer Actions
|
||||
|
||||
- Refactor AI filtering context to stage-native payloads.
|
||||
- Refactor assignment engine to consume stage eligibility and availability.
|
||||
- Refactor notification producers to new event taxonomy.
|
||||
- Refactor reminders and summaries to stage references.
|
||||
|
||||
## UI Layer Actions
|
||||
|
||||
- Admin round pages become pipeline/stage control-plane pages.
|
||||
- Jury and applicant pages consume stage timeline and stage requirement contracts.
|
||||
- Public vote/live pages consume cohort and live cursor state.
|
||||
|
||||
## Reporting and Export Actions
|
||||
|
||||
- Replace round-grouped aggregations with stage/track aggregations.
|
||||
- Update CSV/PDF payload field names to new contracts.
|
||||
- Update observer dashboards and chart dimensions.
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
# Phase 06 Overview: Platform Dependency Refit
|
||||
|
||||
## Objective
|
||||
Refit every platform dependency from legacy round semantics to canonical stage contracts.
|
||||
|
||||
## In Scope
|
||||
|
||||
- module-by-module refit execution
|
||||
- stale symbol removal
|
||||
- integration payload consumer updates
|
||||
- cross-role smoke validation
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- new feature expansion not required for contract migration
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
1. dependency checklist complete
|
||||
2. legacy symbol sweeps clean
|
||||
3. external integration consumers validated
|
||||
# Phase 06 Overview: Platform Dependency Refit
|
||||
|
||||
## Objective
|
||||
Refit every platform dependency from legacy round semantics to canonical stage contracts.
|
||||
|
||||
## In Scope
|
||||
|
||||
- module-by-module refit execution
|
||||
- stale symbol removal
|
||||
- integration payload consumer updates
|
||||
- cross-role smoke validation
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- new feature expansion not required for contract migration
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
1. dependency checklist complete
|
||||
2. legacy symbol sweeps clean
|
||||
3. external integration consumers validated
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
# Symbol Sweep Checklist
|
||||
|
||||
All commands must return zero actionable runtime hits.
|
||||
|
||||
- [ ] `rg "trpc\.round" src`
|
||||
- [ ] `rg "\broundId\b" src/server src/components src/app`
|
||||
- [ ] `rg "round\.settingsJson|roundType" src/server src/components src/app`
|
||||
- [ ] `rg "model Round|enum RoundType" prisma/schema.prisma`
|
||||
|
||||
## Exceptions
|
||||
|
||||
- documentation-only references may be allowed with explicit annotation
|
||||
- any code-path exception is release-blocking unless approved
|
||||
# Symbol Sweep Checklist
|
||||
|
||||
All commands must return zero actionable runtime hits.
|
||||
|
||||
- [ ] `rg "trpc\.round" src`
|
||||
- [ ] `rg "\broundId\b" src/server src/components src/app`
|
||||
- [ ] `rg "round\.settingsJson|roundType" src/server src/components src/app`
|
||||
- [ ] `rg "model Round|enum RoundType" prisma/schema.prisma`
|
||||
|
||||
## Exceptions
|
||||
|
||||
- documentation-only references may be allowed with explicit annotation
|
||||
- any code-path exception is release-blocking unless approved
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Phase 06 Tasks
|
||||
|
||||
- [ ] Execute router-layer refit checklist.
|
||||
- [ ] Execute service-layer refit checklist.
|
||||
- [ ] Execute UI-layer refit checklist.
|
||||
- [ ] Execute reporting/export integration checklist.
|
||||
- [ ] Run and document legacy symbol sweeps.
|
||||
- [ ] Resolve all remaining contract drift findings.
|
||||
# Phase 06 Tasks
|
||||
|
||||
- [ ] Execute router-layer refit checklist.
|
||||
- [ ] Execute service-layer refit checklist.
|
||||
- [ ] Execute UI-layer refit checklist.
|
||||
- [ ] Execute reporting/export integration checklist.
|
||||
- [ ] Run and document legacy symbol sweeps.
|
||||
- [ ] Resolve all remaining contract drift findings.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
# Phase 07 Acceptance Gates
|
||||
|
||||
- [ ] G-07-1 U/I/E/P matrix all green
|
||||
- [ ] G-07-2 performance and resilience evidence accepted
|
||||
- [ ] G-07-3 release evidence report complete and signed
|
||||
- [ ] G-07-4 atomic cutover executed with successful post-checks
|
||||
|
||||
## Required Evidence
|
||||
|
||||
- consolidated test reports
|
||||
- benchmark output captures
|
||||
- signed release evidence report
|
||||
- runbook execution logs
|
||||
# Phase 07 Acceptance Gates
|
||||
|
||||
- [ ] G-07-1 U/I/E/P matrix all green
|
||||
- [ ] G-07-2 performance and resilience evidence accepted
|
||||
- [ ] G-07-3 release evidence report complete and signed
|
||||
- [ ] G-07-4 atomic cutover executed with successful post-checks
|
||||
|
||||
## Required Evidence
|
||||
|
||||
- consolidated test reports
|
||||
- benchmark output captures
|
||||
- signed release evidence report
|
||||
- runbook execution logs
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
# Phase 07 Overview: Validation and Release
|
||||
|
||||
## Objective
|
||||
Execute complete validation suite, run final reset/reseed rehearsal, and perform atomic release cutover.
|
||||
|
||||
## In Scope
|
||||
|
||||
- full U/I/E/P test execution
|
||||
- release evidence collation
|
||||
- performance and resilience validation
|
||||
- atomic release runbook execution
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- post-release enhancements
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
1. full test matrix green
|
||||
2. release evidence signed
|
||||
3. atomic cutover and post-cutover smoke checks complete
|
||||
# Phase 07 Overview: Validation and Release
|
||||
|
||||
## Objective
|
||||
Execute complete validation suite, run final reset/reseed rehearsal, and perform atomic release cutover.
|
||||
|
||||
## In Scope
|
||||
|
||||
- full U/I/E/P test execution
|
||||
- release evidence collation
|
||||
- performance and resilience validation
|
||||
- atomic release runbook execution
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- post-release enhancements
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
1. full test matrix green
|
||||
2. release evidence signed
|
||||
3. atomic cutover and post-cutover smoke checks complete
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
# Performance and Resilience Plan
|
||||
|
||||
## Scenarios
|
||||
|
||||
### Assignment Throughput
|
||||
|
||||
- workload: 1000+ eligible projects
|
||||
- metrics: runtime, coverage latency, overload count
|
||||
|
||||
### Filtering Throughput
|
||||
|
||||
- workload: high-volume gate + AI queue
|
||||
- metrics: gate throughput, queue completion, retry/error rate
|
||||
|
||||
### Live Voting Burst
|
||||
|
||||
- workload: peak audience voting during active cursor changes
|
||||
- metrics: vote latency p50/p95/p99, event drop count, cursor propagation delay
|
||||
|
||||
### Reconnect Recovery
|
||||
|
||||
- workload: intentional network interruptions
|
||||
- metrics: time to state convergence, stale cursor mismatch rate
|
||||
|
||||
## Acceptance Policy
|
||||
|
||||
Thresholds set before run and documented in release evidence.
|
||||
# Performance and Resilience Plan
|
||||
|
||||
## Scenarios
|
||||
|
||||
### Assignment Throughput
|
||||
|
||||
- workload: 1000+ eligible projects
|
||||
- metrics: runtime, coverage latency, overload count
|
||||
|
||||
### Filtering Throughput
|
||||
|
||||
- workload: high-volume gate + AI queue
|
||||
- metrics: gate throughput, queue completion, retry/error rate
|
||||
|
||||
### Live Voting Burst
|
||||
|
||||
- workload: peak audience voting during active cursor changes
|
||||
- metrics: vote latency p50/p95/p99, event drop count, cursor propagation delay
|
||||
|
||||
### Reconnect Recovery
|
||||
|
||||
- workload: intentional network interruptions
|
||||
- metrics: time to state convergence, stale cursor mismatch rate
|
||||
|
||||
## Acceptance Policy
|
||||
|
||||
Thresholds set before run and documented in release evidence.
|
||||
|
|
|
|||
|
|
@ -1,39 +1,39 @@
|
|||
# Release Runbook (Atomic Cutover)
|
||||
|
||||
## Preconditions
|
||||
|
||||
- all prior phase gates complete
|
||||
- signed release checklist
|
||||
- rollback owner and communication owner assigned
|
||||
|
||||
## Cutover Sequence
|
||||
|
||||
1. freeze non-release writes and announce maintenance window
|
||||
2. execute final backup snapshot
|
||||
3. deploy release candidate build
|
||||
4. run reset/reseed as planned for production state model
|
||||
5. run post-deploy integrity and smoke checks
|
||||
6. run mandatory critical-path E2E subset
|
||||
7. publish completion and monitor
|
||||
|
||||
## Immediate Post-Cutover Checks
|
||||
|
||||
- auth and role gating paths
|
||||
- transition mutation sanity
|
||||
- assignment preview/execute path
|
||||
- live cursor operations
|
||||
- audience vote acceptance and dedupe
|
||||
- reporting endpoint correctness
|
||||
|
||||
## Rollback Trigger Conditions
|
||||
|
||||
- integrity check failures
|
||||
- critical mutation path failure
|
||||
- unacceptable error-rate spike
|
||||
|
||||
## Rollback Plan (High Level)
|
||||
|
||||
- restore backup snapshot
|
||||
- redeploy previous stable build
|
||||
- validate critical-path smoke tests
|
||||
- issue incident communication and postmortem schedule
|
||||
# Release Runbook (Atomic Cutover)
|
||||
|
||||
## Preconditions
|
||||
|
||||
- all prior phase gates complete
|
||||
- signed release checklist
|
||||
- rollback owner and communication owner assigned
|
||||
|
||||
## Cutover Sequence
|
||||
|
||||
1. freeze non-release writes and announce maintenance window
|
||||
2. execute final backup snapshot
|
||||
3. deploy release candidate build
|
||||
4. run reset/reseed as planned for production state model
|
||||
5. run post-deploy integrity and smoke checks
|
||||
6. run mandatory critical-path E2E subset
|
||||
7. publish completion and monitor
|
||||
|
||||
## Immediate Post-Cutover Checks
|
||||
|
||||
- auth and role gating paths
|
||||
- transition mutation sanity
|
||||
- assignment preview/execute path
|
||||
- live cursor operations
|
||||
- audience vote acceptance and dedupe
|
||||
- reporting endpoint correctness
|
||||
|
||||
## Rollback Trigger Conditions
|
||||
|
||||
- integrity check failures
|
||||
- critical mutation path failure
|
||||
- unacceptable error-rate spike
|
||||
|
||||
## Rollback Plan (High Level)
|
||||
|
||||
- restore backup snapshot
|
||||
- redeploy previous stable build
|
||||
- validate critical-path smoke tests
|
||||
- issue incident communication and postmortem schedule
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Phase 07 Tasks
|
||||
|
||||
- [ ] Execute full test matrix and store artifacts.
|
||||
- [ ] Execute performance and resilience scenarios.
|
||||
- [ ] Complete release evidence report.
|
||||
- [ ] Run atomic cutover rehearsal and production runbook.
|
||||
- [ ] Complete post-cutover smoke suite.
|
||||
# Phase 07 Tasks
|
||||
|
||||
- [ ] Execute full test matrix and store artifacts.
|
||||
- [ ] Execute performance and resilience scenarios.
|
||||
- [ ] Complete release evidence report.
|
||||
- [ ] Run atomic cutover rehearsal and production runbook.
|
||||
- [ ] Complete post-cutover smoke suite.
|
||||
|
|
|
|||
|
|
@ -1,97 +1,97 @@
|
|||
# API Contracts
|
||||
|
||||
## Contract Conventions
|
||||
|
||||
- All mutations return typed `errorCode` and machine-readable `details` on failure.
|
||||
- All state-changing operations emit deterministic audit events.
|
||||
- All response shapes include stable identifiers for client cache invalidation.
|
||||
|
||||
## Router Families
|
||||
|
||||
### `pipeline`
|
||||
|
||||
- `pipeline.create`
|
||||
- `pipeline.update`
|
||||
- `pipeline.simulate`
|
||||
- `pipeline.publish`
|
||||
- `pipeline.getSummary`
|
||||
|
||||
### `stage`
|
||||
|
||||
- `stage.create`
|
||||
- `stage.updateConfig`
|
||||
- `stage.list`
|
||||
- `stage.transition`
|
||||
- `stage.openWindow`
|
||||
- `stage.closeWindow`
|
||||
|
||||
### `routing`
|
||||
|
||||
- `routing.preview`
|
||||
- `routing.execute`
|
||||
- `routing.listRules`
|
||||
- `routing.upsertRule`
|
||||
- `routing.toggleRule`
|
||||
|
||||
### `filtering`
|
||||
|
||||
- `filtering.previewBatch`
|
||||
- `filtering.runStageFiltering`
|
||||
- `filtering.getManualQueue`
|
||||
- `filtering.resolveManualDecision`
|
||||
|
||||
### `assignment`
|
||||
|
||||
- `assignment.previewStageProjects`
|
||||
- `assignment.assignStageProjects`
|
||||
- `assignment.getCoverageReport`
|
||||
- `assignment.rebalance`
|
||||
|
||||
### `cohort`
|
||||
|
||||
- `cohort.create`
|
||||
- `cohort.assignProjects`
|
||||
- `cohort.openVoting`
|
||||
- `cohort.closeVoting`
|
||||
|
||||
### `live`
|
||||
|
||||
- `live.start`
|
||||
- `live.setActiveProject`
|
||||
- `live.jump`
|
||||
- `live.reorder`
|
||||
- `live.pause`
|
||||
- `live.resume`
|
||||
|
||||
### `decision`
|
||||
|
||||
- `decision.override`
|
||||
- `decision.auditTimeline`
|
||||
|
||||
### `award`
|
||||
|
||||
- `award.createTrack`
|
||||
- `award.configureGovernance`
|
||||
- `award.routeProjects`
|
||||
- `award.finalizeWinners`
|
||||
|
||||
## Error Contract
|
||||
|
||||
- `BAD_REQUEST`
|
||||
- `UNAUTHORIZED`
|
||||
- `FORBIDDEN`
|
||||
- `NOT_FOUND`
|
||||
- `CONFLICT`
|
||||
- `PRECONDITION_FAILED`
|
||||
- `INTERNAL_SERVER_ERROR`
|
||||
|
||||
## Event Contract (Representative)
|
||||
|
||||
- `stage.transitioned`
|
||||
- `routing.executed`
|
||||
- `filtering.completed`
|
||||
- `assignment.generated`
|
||||
- `live.cursor.updated`
|
||||
- `cohort.window.changed`
|
||||
- `decision.overridden`
|
||||
- `award.winner.finalized`
|
||||
# API Contracts
|
||||
|
||||
## Contract Conventions
|
||||
|
||||
- All mutations return typed `errorCode` and machine-readable `details` on failure.
|
||||
- All state-changing operations emit deterministic audit events.
|
||||
- All response shapes include stable identifiers for client cache invalidation.
|
||||
|
||||
## Router Families
|
||||
|
||||
### `pipeline`
|
||||
|
||||
- `pipeline.create`
|
||||
- `pipeline.update`
|
||||
- `pipeline.simulate`
|
||||
- `pipeline.publish`
|
||||
- `pipeline.getSummary`
|
||||
|
||||
### `stage`
|
||||
|
||||
- `stage.create`
|
||||
- `stage.updateConfig`
|
||||
- `stage.list`
|
||||
- `stage.transition`
|
||||
- `stage.openWindow`
|
||||
- `stage.closeWindow`
|
||||
|
||||
### `routing`
|
||||
|
||||
- `routing.preview`
|
||||
- `routing.execute`
|
||||
- `routing.listRules`
|
||||
- `routing.upsertRule`
|
||||
- `routing.toggleRule`
|
||||
|
||||
### `filtering`
|
||||
|
||||
- `filtering.previewBatch`
|
||||
- `filtering.runStageFiltering`
|
||||
- `filtering.getManualQueue`
|
||||
- `filtering.resolveManualDecision`
|
||||
|
||||
### `assignment`
|
||||
|
||||
- `assignment.previewStageProjects`
|
||||
- `assignment.assignStageProjects`
|
||||
- `assignment.getCoverageReport`
|
||||
- `assignment.rebalance`
|
||||
|
||||
### `cohort`
|
||||
|
||||
- `cohort.create`
|
||||
- `cohort.assignProjects`
|
||||
- `cohort.openVoting`
|
||||
- `cohort.closeVoting`
|
||||
|
||||
### `live`
|
||||
|
||||
- `live.start`
|
||||
- `live.setActiveProject`
|
||||
- `live.jump`
|
||||
- `live.reorder`
|
||||
- `live.pause`
|
||||
- `live.resume`
|
||||
|
||||
### `decision`
|
||||
|
||||
- `decision.override`
|
||||
- `decision.auditTimeline`
|
||||
|
||||
### `award`
|
||||
|
||||
- `award.createTrack`
|
||||
- `award.configureGovernance`
|
||||
- `award.routeProjects`
|
||||
- `award.finalizeWinners`
|
||||
|
||||
## Error Contract
|
||||
|
||||
- `BAD_REQUEST`
|
||||
- `UNAUTHORIZED`
|
||||
- `FORBIDDEN`
|
||||
- `NOT_FOUND`
|
||||
- `CONFLICT`
|
||||
- `PRECONDITION_FAILED`
|
||||
- `INTERNAL_SERVER_ERROR`
|
||||
|
||||
## Event Contract (Representative)
|
||||
|
||||
- `stage.transitioned`
|
||||
- `routing.executed`
|
||||
- `filtering.completed`
|
||||
- `assignment.generated`
|
||||
- `live.cursor.updated`
|
||||
- `cohort.window.changed`
|
||||
- `decision.overridden`
|
||||
- `award.winner.finalized`
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
# Authorization Matrix
|
||||
|
||||
Roles:
|
||||
|
||||
- `SUPER_ADMIN`
|
||||
- `PROGRAM_ADMIN`
|
||||
- `AWARD_MASTER`
|
||||
- `JURY_MEMBER`
|
||||
- `APPLICANT`
|
||||
- `OBSERVER`
|
||||
- `AUDIENCE` (public voting context)
|
||||
|
||||
| Capability | Super Admin | Program Admin | Award Master | Jury | Applicant | Observer | Audience |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Create/Edit Pipeline | Yes | Yes (scoped) | No | No | No | No | No |
|
||||
| Publish Pipeline | Yes | Yes (scoped) | No | No | No | No | No |
|
||||
| Configure Stage Rules | Yes | Yes (scoped) | No | No | No | No | No |
|
||||
| Execute Manual Transition | Yes | Yes (scoped) | Limited (award scoped) | No | No | No | No |
|
||||
| Override Decision | Yes | Yes (scoped) | Limited (award scoped) | No | No | No | No |
|
||||
| View Audit Timeline | Yes | Yes (scoped) | Award scoped | Own actions | No | Read-only scoped | No |
|
||||
| Assign Jurors | Yes | Yes (scoped) | Award scoped | No | No | No | No |
|
||||
| Submit Evaluation | No | No | Optional (if configured) | Yes (assigned only) | No | No | No |
|
||||
| Upload Intake Docs | No | No | No | No | Yes | No | No |
|
||||
| Control Live Cursor | Yes | Yes (scoped) | No | No | No | No | No |
|
||||
| Cast Audience Vote | No | No | No | No | Optional | No | Yes |
|
||||
|
||||
## Policy Notes
|
||||
|
||||
1. Program scoping applies to all admin operations.
|
||||
2. `AWARD_MASTER` permissions are explicitly award-scoped and only active when governance mode allows it.
|
||||
3. Jury endpoints always enforce assignment ownership and window constraints.
|
||||
4. Audience endpoints enforce cohort membership + window state + dedupe key policy.
|
||||
# Authorization Matrix
|
||||
|
||||
Roles:
|
||||
|
||||
- `SUPER_ADMIN`
|
||||
- `PROGRAM_ADMIN`
|
||||
- `AWARD_MASTER`
|
||||
- `JURY_MEMBER`
|
||||
- `APPLICANT`
|
||||
- `OBSERVER`
|
||||
- `AUDIENCE` (public voting context)
|
||||
|
||||
| Capability | Super Admin | Program Admin | Award Master | Jury | Applicant | Observer | Audience |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Create/Edit Pipeline | Yes | Yes (scoped) | No | No | No | No | No |
|
||||
| Publish Pipeline | Yes | Yes (scoped) | No | No | No | No | No |
|
||||
| Configure Stage Rules | Yes | Yes (scoped) | No | No | No | No | No |
|
||||
| Execute Manual Transition | Yes | Yes (scoped) | Limited (award scoped) | No | No | No | No |
|
||||
| Override Decision | Yes | Yes (scoped) | Limited (award scoped) | No | No | No | No |
|
||||
| View Audit Timeline | Yes | Yes (scoped) | Award scoped | Own actions | No | Read-only scoped | No |
|
||||
| Assign Jurors | Yes | Yes (scoped) | Award scoped | No | No | No | No |
|
||||
| Submit Evaluation | No | No | Optional (if configured) | Yes (assigned only) | No | No | No |
|
||||
| Upload Intake Docs | No | No | No | No | Yes | No | No |
|
||||
| Control Live Cursor | Yes | Yes (scoped) | No | No | No | No | No |
|
||||
| Cast Audience Vote | No | No | No | No | Optional | No | Yes |
|
||||
|
||||
## Policy Notes
|
||||
|
||||
1. Program scoping applies to all admin operations.
|
||||
2. `AWARD_MASTER` permissions are explicitly award-scoped and only active when governance mode allows it.
|
||||
3. Jury endpoints always enforce assignment ownership and window constraints.
|
||||
4. Audience endpoints enforce cohort membership + window state + dedupe key policy.
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
# Decision Log (Locked)
|
||||
|
||||
| ID | Decision | Status | Rationale | Impacted Phases |
|
||||
|---|---|---|---|---|
|
||||
| MX-001 | Canonical model is `Pipeline -> Track -> Stage` | Locked | Supports multi-track orchestration cleanly | 01-07 |
|
||||
| MX-002 | Project progression stored in `ProjectStageState` records | Locked | Replaces brittle single-pointer round state | 01-07 |
|
||||
| MX-003 | Intake is stage-native (`INTAKE`) rather than implicit pre-round behavior | Locked | Removes hidden workflow behavior | 01-04 |
|
||||
| MX-004 | Full-cutover delivery with no compatibility bridge | Locked | Faster convergence to clean runtime | 00-07 |
|
||||
| MX-005 | Special awards are first-class `Track` entities | Locked | Prevents duplicated orchestration logic | 01-06 |
|
||||
| MX-006 | Award routing modes are `parallel`, `exclusive`, `post_main` | Locked | Supports real sponsor policy diversity | 02,05 |
|
||||
| MX-007 | Award governance modes include `JURY_VOTE`, `AWARD_MASTER`, `ADMIN` | Locked | Explicit and policy-aligned control surfaces | 05 |
|
||||
| MX-008 | Live progression source of truth is admin cursor | Locked | Needed for non-linear live event control | 02,04 |
|
||||
| MX-009 | Voting windows are explicit open/close operations | Locked | Schedules alone are insufficient during live operations | 02,04 |
|
||||
| MX-010 | Assignment engine guarantees eligible project coverage | Locked | Operational fairness and delivery reliability | 02,04 |
|
||||
| MX-011 | Overrides require reason and immutable audit entries | Locked | Governance and explainability | 02,05,07 |
|
||||
| MX-012 | Release is blocked by legacy symbol sweep failures | Locked | Prevents half-migrated runtime behavior | 06,07 |
|
||||
# Decision Log (Locked)
|
||||
|
||||
| ID | Decision | Status | Rationale | Impacted Phases |
|
||||
|---|---|---|---|---|
|
||||
| MX-001 | Canonical model is `Pipeline -> Track -> Stage` | Locked | Supports multi-track orchestration cleanly | 01-07 |
|
||||
| MX-002 | Project progression stored in `ProjectStageState` records | Locked | Replaces brittle single-pointer round state | 01-07 |
|
||||
| MX-003 | Intake is stage-native (`INTAKE`) rather than implicit pre-round behavior | Locked | Removes hidden workflow behavior | 01-04 |
|
||||
| MX-004 | Full-cutover delivery with no compatibility bridge | Locked | Faster convergence to clean runtime | 00-07 |
|
||||
| MX-005 | Special awards are first-class `Track` entities | Locked | Prevents duplicated orchestration logic | 01-06 |
|
||||
| MX-006 | Award routing modes are `parallel`, `exclusive`, `post_main` | Locked | Supports real sponsor policy diversity | 02,05 |
|
||||
| MX-007 | Award governance modes include `JURY_VOTE`, `AWARD_MASTER`, `ADMIN` | Locked | Explicit and policy-aligned control surfaces | 05 |
|
||||
| MX-008 | Live progression source of truth is admin cursor | Locked | Needed for non-linear live event control | 02,04 |
|
||||
| MX-009 | Voting windows are explicit open/close operations | Locked | Schedules alone are insufficient during live operations | 02,04 |
|
||||
| MX-010 | Assignment engine guarantees eligible project coverage | Locked | Operational fairness and delivery reliability | 02,04 |
|
||||
| MX-011 | Overrides require reason and immutable audit entries | Locked | Governance and explainability | 02,05,07 |
|
||||
| MX-012 | Release is blocked by legacy symbol sweep failures | Locked | Prevents half-migrated runtime behavior | 06,07 |
|
||||
|
|
|
|||
|
|
@ -1,75 +1,75 @@
|
|||
# Dependency Refit Inventory
|
||||
|
||||
This inventory is release-blocking. Every listed module must be validated against the new contracts.
|
||||
|
||||
## Backend Routers
|
||||
|
||||
- `src/server/routers/_app.ts`
|
||||
- `src/server/routers/round.ts`
|
||||
- `src/server/routers/filtering.ts`
|
||||
- `src/server/routers/live-voting.ts`
|
||||
- `src/server/routers/specialAward.ts`
|
||||
- `src/server/routers/assignment.ts`
|
||||
- `src/server/routers/evaluation.ts`
|
||||
- `src/server/routers/file.ts`
|
||||
- `src/server/routers/project.ts`
|
||||
- `src/server/routers/project-pool.ts`
|
||||
- `src/server/routers/application.ts`
|
||||
- `src/server/routers/applicant.ts`
|
||||
- `src/server/routers/export.ts`
|
||||
- `src/server/routers/analytics.ts`
|
||||
- `src/server/routers/program.ts`
|
||||
- `src/server/routers/roundTemplate.ts`
|
||||
- `src/server/routers/gracePeriod.ts`
|
||||
- `src/server/routers/webhook.ts`
|
||||
|
||||
## Backend Services
|
||||
|
||||
- `src/server/services/smart-assignment.ts`
|
||||
- `src/server/services/ai-filtering.ts`
|
||||
- `src/server/services/ai-evaluation-summary.ts`
|
||||
- `src/server/services/evaluation-reminders.ts`
|
||||
- `src/server/services/in-app-notification.ts`
|
||||
- `src/server/services/award-eligibility-job.ts`
|
||||
- `src/server/services/webhook-dispatcher.ts`
|
||||
|
||||
## Admin Surfaces
|
||||
|
||||
- `src/app/(admin)/admin/rounds/**`
|
||||
- `src/app/(admin)/admin/awards/**`
|
||||
- `src/app/(admin)/admin/reports/page.tsx`
|
||||
- `src/components/admin/round-pipeline.tsx`
|
||||
- `src/components/admin/assign-projects-dialog.tsx`
|
||||
- `src/components/admin/advance-projects-dialog.tsx`
|
||||
- `src/components/admin/remove-projects-dialog.tsx`
|
||||
- `src/components/admin/file-requirements-editor.tsx`
|
||||
- `src/components/forms/round-type-settings.tsx`
|
||||
|
||||
## Jury, Applicant, Public
|
||||
|
||||
- `src/app/(jury)/jury/**`
|
||||
- `src/components/jury/**`
|
||||
- `src/app/(applicant)/applicant/**`
|
||||
- `src/app/(public)/apply/**`
|
||||
- `src/app/(public)/my-submission/**`
|
||||
- `src/app/(public)/vote/**`
|
||||
- `src/app/(public)/live-scores/**`
|
||||
|
||||
## Reporting and Exports
|
||||
|
||||
- chart and observer modules under `src/components/charts/**` and `src/components/observer/**`
|
||||
- export and PDF paths under `src/components/shared/export-pdf-button.tsx`, `src/components/admin/pdf-report.tsx`, `src/server/routers/export.ts`
|
||||
|
||||
## Schema and Seed Paths
|
||||
|
||||
- `prisma/schema.prisma`
|
||||
- relevant migrations and seed scripts under `prisma/`
|
||||
|
||||
## Mandatory Legacy Sweep Queries (Release Blockers)
|
||||
|
||||
1. `rg "trpc\.round" src`
|
||||
2. `rg "\broundId\b" src/server src/components src/app`
|
||||
3. `rg "round\.settingsJson|roundType" src/server src/components src/app`
|
||||
4. `rg "model Round|enum RoundType" prisma/schema.prisma`
|
||||
|
||||
Allowlist exceptions (if any) must be explicit and approved in Phase 06 gates.
|
||||
# Dependency Refit Inventory
|
||||
|
||||
This inventory is release-blocking. Every listed module must be validated against the new contracts.
|
||||
|
||||
## Backend Routers
|
||||
|
||||
- `src/server/routers/_app.ts`
|
||||
- `src/server/routers/round.ts`
|
||||
- `src/server/routers/filtering.ts`
|
||||
- `src/server/routers/live-voting.ts`
|
||||
- `src/server/routers/specialAward.ts`
|
||||
- `src/server/routers/assignment.ts`
|
||||
- `src/server/routers/evaluation.ts`
|
||||
- `src/server/routers/file.ts`
|
||||
- `src/server/routers/project.ts`
|
||||
- `src/server/routers/project-pool.ts`
|
||||
- `src/server/routers/application.ts`
|
||||
- `src/server/routers/applicant.ts`
|
||||
- `src/server/routers/export.ts`
|
||||
- `src/server/routers/analytics.ts`
|
||||
- `src/server/routers/program.ts`
|
||||
- `src/server/routers/roundTemplate.ts`
|
||||
- `src/server/routers/gracePeriod.ts`
|
||||
- `src/server/routers/webhook.ts`
|
||||
|
||||
## Backend Services
|
||||
|
||||
- `src/server/services/smart-assignment.ts`
|
||||
- `src/server/services/ai-filtering.ts`
|
||||
- `src/server/services/ai-evaluation-summary.ts`
|
||||
- `src/server/services/evaluation-reminders.ts`
|
||||
- `src/server/services/in-app-notification.ts`
|
||||
- `src/server/services/award-eligibility-job.ts`
|
||||
- `src/server/services/webhook-dispatcher.ts`
|
||||
|
||||
## Admin Surfaces
|
||||
|
||||
- `src/app/(admin)/admin/rounds/**`
|
||||
- `src/app/(admin)/admin/awards/**`
|
||||
- `src/app/(admin)/admin/reports/page.tsx`
|
||||
- `src/components/admin/round-pipeline.tsx`
|
||||
- `src/components/admin/assign-projects-dialog.tsx`
|
||||
- `src/components/admin/advance-projects-dialog.tsx`
|
||||
- `src/components/admin/remove-projects-dialog.tsx`
|
||||
- `src/components/admin/file-requirements-editor.tsx`
|
||||
- `src/components/forms/round-type-settings.tsx`
|
||||
|
||||
## Jury, Applicant, Public
|
||||
|
||||
- `src/app/(jury)/jury/**`
|
||||
- `src/components/jury/**`
|
||||
- `src/app/(applicant)/applicant/**`
|
||||
- `src/app/(public)/apply/**`
|
||||
- `src/app/(public)/my-submission/**`
|
||||
- `src/app/(public)/vote/**`
|
||||
- `src/app/(public)/live-scores/**`
|
||||
|
||||
## Reporting and Exports
|
||||
|
||||
- chart and observer modules under `src/components/charts/**` and `src/components/observer/**`
|
||||
- export and PDF paths under `src/components/shared/export-pdf-button.tsx`, `src/components/admin/pdf-report.tsx`, `src/server/routers/export.ts`
|
||||
|
||||
## Schema and Seed Paths
|
||||
|
||||
- `prisma/schema.prisma`
|
||||
- relevant migrations and seed scripts under `prisma/`
|
||||
|
||||
## Mandatory Legacy Sweep Queries (Release Blockers)
|
||||
|
||||
1. `rg "trpc\.round" src`
|
||||
2. `rg "\broundId\b" src/server src/components src/app`
|
||||
3. `rg "round\.settingsJson|roundType" src/server src/components src/app`
|
||||
4. `rg "model Round|enum RoundType" prisma/schema.prisma`
|
||||
|
||||
Allowlist exceptions (if any) must be explicit and approved in Phase 06 gates.
|
||||
|
|
|
|||
|
|
@ -1,156 +1,156 @@
|
|||
# Domain Model and Contracts
|
||||
|
||||
## Canonical Enums
|
||||
|
||||
- `StageType = INTAKE | FILTER | EVALUATION | SELECTION | LIVE_FINAL | RESULTS`
|
||||
- `TrackKind = MAIN | AWARD | SHOWCASE`
|
||||
- `RoutingMode = PARALLEL | EXCLUSIVE | POST_MAIN`
|
||||
- `StageStatus = DRAFT | ACTIVE | CLOSED | ARCHIVED`
|
||||
- `ProjectStageStateValue = PENDING | IN_PROGRESS | PASSED | REJECTED | ROUTED | COMPLETED | WITHDRAWN`
|
||||
- `DecisionMode = JURY_VOTE | AWARD_MASTER | ADMIN`
|
||||
- `OverrideReasonCode = DATA_CORRECTION | POLICY_EXCEPTION | JURY_CONFLICT | SPONSOR_DECISION | ADMIN_DISCRETION`
|
||||
|
||||
## Core Entities
|
||||
|
||||
### Pipeline
|
||||
|
||||
- `id`
|
||||
- `programId`
|
||||
- `name`
|
||||
- `slug`
|
||||
- `status`
|
||||
- `settingsJson`
|
||||
- `createdAt`, `updatedAt`
|
||||
|
||||
### Track
|
||||
|
||||
- `id`
|
||||
- `pipelineId`
|
||||
- `kind`
|
||||
- `specialAwardId?`
|
||||
- `name`
|
||||
- `slug`
|
||||
- `sortOrder`
|
||||
- `routingModeDefault?`
|
||||
- `decisionMode?`
|
||||
|
||||
### Stage
|
||||
|
||||
- `id`
|
||||
- `trackId`
|
||||
- `stageType`
|
||||
- `name`
|
||||
- `slug`
|
||||
- `sortOrder`
|
||||
- `status`
|
||||
- `configVersion`
|
||||
- `configJson`
|
||||
- `windowOpenAt?`, `windowCloseAt?`
|
||||
|
||||
### StageTransition
|
||||
|
||||
- `id`
|
||||
- `fromStageId`
|
||||
- `toStageId`
|
||||
- `priority`
|
||||
- `isDefault`
|
||||
- `guardJson`
|
||||
- `actionJson`
|
||||
|
||||
### ProjectStageState
|
||||
|
||||
- `id`
|
||||
- `projectId`
|
||||
- `trackId`
|
||||
- `stageId`
|
||||
- `state`
|
||||
- `enteredAt`, `exitedAt`
|
||||
- `decisionRef?`
|
||||
- `outcomeJson`
|
||||
|
||||
### RoutingRule
|
||||
|
||||
- `id`
|
||||
- `pipelineId`
|
||||
- `scope` (`GLOBAL|TRACK|STAGE`)
|
||||
- `predicateJson`
|
||||
- `destinationTrackId`
|
||||
- `destinationStageId?`
|
||||
- `priority`
|
||||
- `isActive`
|
||||
|
||||
### Cohort and Live Runtime
|
||||
|
||||
- `Cohort(id, stageId, name, votingMode, isOpen, windowOpenAt?, windowCloseAt?)`
|
||||
- `CohortProject(cohortId, projectId, sortOrder)`
|
||||
- `LiveProgressCursor(id, stageId, sessionId, activeProjectId?, activeOrderIndex?, updatedBy, updatedAt)`
|
||||
|
||||
### Governance Entities
|
||||
|
||||
- `OverrideAction(id, entityType, entityId, oldValueJson, newValueJson, reasonCode, reasonText, actedBy, actedAt)`
|
||||
- `DecisionAuditLog(id, entityType, entityId, eventType, payloadJson, actorId?, createdAt)`
|
||||
|
||||
## Stage Config Union Contracts
|
||||
|
||||
### IntakeConfig
|
||||
|
||||
- file requirements
|
||||
- accepted MIME and size constraints
|
||||
- deadline and late policy
|
||||
- team invite policy
|
||||
|
||||
### FilterConfig
|
||||
|
||||
- deterministic gates
|
||||
- AI rubric
|
||||
- confidence thresholds
|
||||
- manual queue policy
|
||||
- rejection notification policy
|
||||
|
||||
### EvaluationConfig
|
||||
|
||||
- criteria schema
|
||||
- assignment strategy
|
||||
- review thresholds
|
||||
- COI policy
|
||||
- visibility rules
|
||||
|
||||
### SelectionConfig
|
||||
|
||||
- ranking source
|
||||
- finalist target
|
||||
- override permissions
|
||||
- promotion mode (`auto_top_n`, `hybrid`, `manual`)
|
||||
|
||||
### LiveFinalConfig
|
||||
|
||||
- session behavior
|
||||
- jury voting config
|
||||
- audience voting config
|
||||
- cohort policy
|
||||
- reveal policy
|
||||
- schedule hints (advisory)
|
||||
|
||||
### ResultsConfig
|
||||
|
||||
- ranking weight rules
|
||||
- publication policy
|
||||
- winner override rules
|
||||
|
||||
## Constraint Rules
|
||||
|
||||
1. Stage ordering unique per track (`trackId + sortOrder`).
|
||||
2. `ProjectStageState` unique on (`projectId`, `trackId`, `stageId`).
|
||||
3. `StageTransition` unique on (`fromStageId`, `toStageId`).
|
||||
4. Transition destination must remain in same pipeline unless explicit routing rule applies.
|
||||
5. Override records immutable after insert.
|
||||
6. Decision audit log append-only.
|
||||
|
||||
## Index Priorities
|
||||
|
||||
1. `ProjectStageState(projectId, trackId, state)`
|
||||
2. `ProjectStageState(stageId, state)`
|
||||
3. `RoutingRule(pipelineId, isActive, priority)`
|
||||
4. `StageTransition(fromStageId, priority)`
|
||||
5. `LiveProgressCursor(stageId, sessionId)`
|
||||
6. `DecisionAuditLog(entityType, entityId, createdAt)`
|
||||
# Domain Model and Contracts
|
||||
|
||||
## Canonical Enums
|
||||
|
||||
- `StageType = INTAKE | FILTER | EVALUATION | SELECTION | LIVE_FINAL | RESULTS`
|
||||
- `TrackKind = MAIN | AWARD | SHOWCASE`
|
||||
- `RoutingMode = PARALLEL | EXCLUSIVE | POST_MAIN`
|
||||
- `StageStatus = DRAFT | ACTIVE | CLOSED | ARCHIVED`
|
||||
- `ProjectStageStateValue = PENDING | IN_PROGRESS | PASSED | REJECTED | ROUTED | COMPLETED | WITHDRAWN`
|
||||
- `DecisionMode = JURY_VOTE | AWARD_MASTER | ADMIN`
|
||||
- `OverrideReasonCode = DATA_CORRECTION | POLICY_EXCEPTION | JURY_CONFLICT | SPONSOR_DECISION | ADMIN_DISCRETION`
|
||||
|
||||
## Core Entities
|
||||
|
||||
### Pipeline
|
||||
|
||||
- `id`
|
||||
- `programId`
|
||||
- `name`
|
||||
- `slug`
|
||||
- `status`
|
||||
- `settingsJson`
|
||||
- `createdAt`, `updatedAt`
|
||||
|
||||
### Track
|
||||
|
||||
- `id`
|
||||
- `pipelineId`
|
||||
- `kind`
|
||||
- `specialAwardId?`
|
||||
- `name`
|
||||
- `slug`
|
||||
- `sortOrder`
|
||||
- `routingModeDefault?`
|
||||
- `decisionMode?`
|
||||
|
||||
### Stage
|
||||
|
||||
- `id`
|
||||
- `trackId`
|
||||
- `stageType`
|
||||
- `name`
|
||||
- `slug`
|
||||
- `sortOrder`
|
||||
- `status`
|
||||
- `configVersion`
|
||||
- `configJson`
|
||||
- `windowOpenAt?`, `windowCloseAt?`
|
||||
|
||||
### StageTransition
|
||||
|
||||
- `id`
|
||||
- `fromStageId`
|
||||
- `toStageId`
|
||||
- `priority`
|
||||
- `isDefault`
|
||||
- `guardJson`
|
||||
- `actionJson`
|
||||
|
||||
### ProjectStageState
|
||||
|
||||
- `id`
|
||||
- `projectId`
|
||||
- `trackId`
|
||||
- `stageId`
|
||||
- `state`
|
||||
- `enteredAt`, `exitedAt`
|
||||
- `decisionRef?`
|
||||
- `outcomeJson`
|
||||
|
||||
### RoutingRule
|
||||
|
||||
- `id`
|
||||
- `pipelineId`
|
||||
- `scope` (`GLOBAL|TRACK|STAGE`)
|
||||
- `predicateJson`
|
||||
- `destinationTrackId`
|
||||
- `destinationStageId?`
|
||||
- `priority`
|
||||
- `isActive`
|
||||
|
||||
### Cohort and Live Runtime
|
||||
|
||||
- `Cohort(id, stageId, name, votingMode, isOpen, windowOpenAt?, windowCloseAt?)`
|
||||
- `CohortProject(cohortId, projectId, sortOrder)`
|
||||
- `LiveProgressCursor(id, stageId, sessionId, activeProjectId?, activeOrderIndex?, updatedBy, updatedAt)`
|
||||
|
||||
### Governance Entities
|
||||
|
||||
- `OverrideAction(id, entityType, entityId, oldValueJson, newValueJson, reasonCode, reasonText, actedBy, actedAt)`
|
||||
- `DecisionAuditLog(id, entityType, entityId, eventType, payloadJson, actorId?, createdAt)`
|
||||
|
||||
## Stage Config Union Contracts
|
||||
|
||||
### IntakeConfig
|
||||
|
||||
- file requirements
|
||||
- accepted MIME and size constraints
|
||||
- deadline and late policy
|
||||
- team invite policy
|
||||
|
||||
### FilterConfig
|
||||
|
||||
- deterministic gates
|
||||
- AI rubric
|
||||
- confidence thresholds
|
||||
- manual queue policy
|
||||
- rejection notification policy
|
||||
|
||||
### EvaluationConfig
|
||||
|
||||
- criteria schema
|
||||
- assignment strategy
|
||||
- review thresholds
|
||||
- COI policy
|
||||
- visibility rules
|
||||
|
||||
### SelectionConfig
|
||||
|
||||
- ranking source
|
||||
- finalist target
|
||||
- override permissions
|
||||
- promotion mode (`auto_top_n`, `hybrid`, `manual`)
|
||||
|
||||
### LiveFinalConfig
|
||||
|
||||
- session behavior
|
||||
- jury voting config
|
||||
- audience voting config
|
||||
- cohort policy
|
||||
- reveal policy
|
||||
- schedule hints (advisory)
|
||||
|
||||
### ResultsConfig
|
||||
|
||||
- ranking weight rules
|
||||
- publication policy
|
||||
- winner override rules
|
||||
|
||||
## Constraint Rules
|
||||
|
||||
1. Stage ordering unique per track (`trackId + sortOrder`).
|
||||
2. `ProjectStageState` unique on (`projectId`, `trackId`, `stageId`).
|
||||
3. `StageTransition` unique on (`fromStageId`, `toStageId`).
|
||||
4. Transition destination must remain in same pipeline unless explicit routing rule applies.
|
||||
5. Override records immutable after insert.
|
||||
6. Decision audit log append-only.
|
||||
|
||||
## Index Priorities
|
||||
|
||||
1. `ProjectStageState(projectId, trackId, state)`
|
||||
2. `ProjectStageState(stageId, state)`
|
||||
3. `RoutingRule(pipelineId, isActive, priority)`
|
||||
4. `StageTransition(fromStageId, priority)`
|
||||
5. `LiveProgressCursor(stageId, sessionId)`
|
||||
6. `DecisionAuditLog(entityType, entityId, createdAt)`
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
# Phase Gate Traceability
|
||||
|
||||
| Phase | Gate ID | Evidence Required | Test IDs / Checks | Blocking |
|
||||
|---|---|---|---|---|
|
||||
| 00 | G-00-1 | decision lock snapshot | decision-log review | Yes |
|
||||
| 00 | G-00-2 | contract alignment review | API/type contract diff | Yes |
|
||||
| 01 | G-01-1 | schema compile output | `prisma generate` | Yes |
|
||||
| 01 | G-01-2 | reset/reseed output | seed logs + integrity queries | Yes |
|
||||
| 01 | G-01-3 | index and FK evidence | SQL verification scripts | Yes |
|
||||
| 02 | G-02-1 | transition runtime proof | U-001/U-002/I-001 | Yes |
|
||||
| 02 | G-02-2 | routing determinism proof | U-003/I-003/I-004 | Yes |
|
||||
| 02 | G-02-3 | filtering policy proof | U-004/U-005/E-003 | Yes |
|
||||
| 02 | G-02-4 | assignment guarantees proof | U-006/U-007/I-005 | Yes |
|
||||
| 02 | G-02-5 | audit/override proof | U-008/I-008 | Yes |
|
||||
| 03 | G-03-1 | create/edit parity proof | parity checklist | Yes |
|
||||
| 03 | G-03-2 | wizard completion proof | E-001 | Yes |
|
||||
| 03 | G-03-3 | modal safety proof | targeted UI regressions | Yes |
|
||||
| 04 | G-04-1 | applicant flow proof | E-002 | Yes |
|
||||
| 04 | G-04-2 | jury flow proof | E-004 | Yes |
|
||||
| 04 | G-04-3 | live audience proof | E-006/E-007/I-006/I-007 | Yes |
|
||||
| 05 | G-05-1 | award routing proof | I-003/I-004 | Yes |
|
||||
| 05 | G-05-2 | governance auth proof | U-010 + auth tests | Yes |
|
||||
| 05 | G-05-3 | winner and audit proof | E-008 + I-008 | Yes |
|
||||
| 06 | G-06-1 | dependency checklist complete | module sign-off evidence | Yes |
|
||||
| 06 | G-06-2 | legacy sweeps clean | mandatory rg sweeps | Yes |
|
||||
| 06 | G-06-3 | external consumer validation | webhook/export checks | Yes |
|
||||
| 07 | G-07-1 | full test report | full matrix results | Yes |
|
||||
| 07 | G-07-2 | performance report | P-001..P-004 evidence | Yes |
|
||||
| 07 | G-07-3 | release evidence package | signed report template | Yes |
|
||||
| 07 | G-07-4 | atomic cutover proof | release runbook logs | Yes |
|
||||
|
||||
Rule: no phase closes until all gates are complete with linked artifacts.
|
||||
# Phase Gate Traceability
|
||||
|
||||
| Phase | Gate ID | Evidence Required | Test IDs / Checks | Blocking |
|
||||
|---|---|---|---|---|
|
||||
| 00 | G-00-1 | decision lock snapshot | decision-log review | Yes |
|
||||
| 00 | G-00-2 | contract alignment review | API/type contract diff | Yes |
|
||||
| 01 | G-01-1 | schema compile output | `prisma generate` | Yes |
|
||||
| 01 | G-01-2 | reset/reseed output | seed logs + integrity queries | Yes |
|
||||
| 01 | G-01-3 | index and FK evidence | SQL verification scripts | Yes |
|
||||
| 02 | G-02-1 | transition runtime proof | U-001/U-002/I-001 | Yes |
|
||||
| 02 | G-02-2 | routing determinism proof | U-003/I-003/I-004 | Yes |
|
||||
| 02 | G-02-3 | filtering policy proof | U-004/U-005/E-003 | Yes |
|
||||
| 02 | G-02-4 | assignment guarantees proof | U-006/U-007/I-005 | Yes |
|
||||
| 02 | G-02-5 | audit/override proof | U-008/I-008 | Yes |
|
||||
| 03 | G-03-1 | create/edit parity proof | parity checklist | Yes |
|
||||
| 03 | G-03-2 | wizard completion proof | E-001 | Yes |
|
||||
| 03 | G-03-3 | modal safety proof | targeted UI regressions | Yes |
|
||||
| 04 | G-04-1 | applicant flow proof | E-002 | Yes |
|
||||
| 04 | G-04-2 | jury flow proof | E-004 | Yes |
|
||||
| 04 | G-04-3 | live audience proof | E-006/E-007/I-006/I-007 | Yes |
|
||||
| 05 | G-05-1 | award routing proof | I-003/I-004 | Yes |
|
||||
| 05 | G-05-2 | governance auth proof | U-010 + auth tests | Yes |
|
||||
| 05 | G-05-3 | winner and audit proof | E-008 + I-008 | Yes |
|
||||
| 06 | G-06-1 | dependency checklist complete | module sign-off evidence | Yes |
|
||||
| 06 | G-06-2 | legacy sweeps clean | mandatory rg sweeps | Yes |
|
||||
| 06 | G-06-3 | external consumer validation | webhook/export checks | Yes |
|
||||
| 07 | G-07-1 | full test report | full matrix results | Yes |
|
||||
| 07 | G-07-2 | performance report | P-001..P-004 evidence | Yes |
|
||||
| 07 | G-07-3 | release evidence package | signed report template | Yes |
|
||||
| 07 | G-07-4 | atomic cutover proof | release runbook logs | Yes |
|
||||
|
||||
Rule: no phase closes until all gates are complete with linked artifacts.
|
||||
|
|
|
|||
|
|
@ -1,43 +1,43 @@
|
|||
# Program Charter
|
||||
|
||||
## Mission
|
||||
Deliver a complete, stage-native orchestration platform for MOPC that supports:
|
||||
|
||||
- edition-scoped intake and progression
|
||||
- deterministic filtering and assignment
|
||||
- parallel and exclusive award flows
|
||||
- admin-driven live finals operations
|
||||
- full auditability and release-grade validation
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- Canonical data model rebuild around pipeline/track/stage.
|
||||
- Backend orchestration engine (transition, routing, filtering, assignment, live, notifications, audit).
|
||||
- Admin setup and control-plane UX refit.
|
||||
- Applicant, jury, observer, and audience flow refit to new contracts.
|
||||
- Special award governance modes including `AWARD_MASTER`.
|
||||
- Platform-wide dependency refit of schema/runtime consumers.
|
||||
- Full validation and atomic release process.
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Legacy contract compatibility bridges.
|
||||
- Cosmetic redesign or major brand refresh.
|
||||
- Non-orchestration feature expansion unrelated to competition lifecycle.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. Admin setup can fully configure required competition behavior in create-time flow.
|
||||
2. Stage progression and routing are deterministic and explainable.
|
||||
3. Award tracks run without ad hoc side logic.
|
||||
4. Live event operations are resilient under reconnect and burst traffic.
|
||||
5. All platform dependencies are migrated and verified before release.
|
||||
|
||||
## Quality Bar
|
||||
|
||||
- Typed contracts at schema, API, and UI boundaries.
|
||||
- Idempotent mutation semantics for high-risk operations.
|
||||
- Strong audit trails for every governance-sensitive action.
|
||||
- Mobile-safe interaction quality for live audience and jury experiences.
|
||||
# Program Charter
|
||||
|
||||
## Mission
|
||||
Deliver a complete, stage-native orchestration platform for MOPC that supports:
|
||||
|
||||
- edition-scoped intake and progression
|
||||
- deterministic filtering and assignment
|
||||
- parallel and exclusive award flows
|
||||
- admin-driven live finals operations
|
||||
- full auditability and release-grade validation
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- Canonical data model rebuild around pipeline/track/stage.
|
||||
- Backend orchestration engine (transition, routing, filtering, assignment, live, notifications, audit).
|
||||
- Admin setup and control-plane UX refit.
|
||||
- Applicant, jury, observer, and audience flow refit to new contracts.
|
||||
- Special award governance modes including `AWARD_MASTER`.
|
||||
- Platform-wide dependency refit of schema/runtime consumers.
|
||||
- Full validation and atomic release process.
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Legacy contract compatibility bridges.
|
||||
- Cosmetic redesign or major brand refresh.
|
||||
- Non-orchestration feature expansion unrelated to competition lifecycle.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. Admin setup can fully configure required competition behavior in create-time flow.
|
||||
2. Stage progression and routing are deterministic and explainable.
|
||||
3. Award tracks run without ad hoc side logic.
|
||||
4. Live event operations are resilient under reconnect and burst traffic.
|
||||
5. All platform dependencies are migrated and verified before release.
|
||||
|
||||
## Quality Bar
|
||||
|
||||
- Typed contracts at schema, API, and UI boundaries.
|
||||
- Idempotent mutation semantics for high-risk operations.
|
||||
- Strong audit trails for every governance-sensitive action.
|
||||
- Mobile-safe interaction quality for live audience and jury experiences.
|
||||
|
|
|
|||
|
|
@ -1,65 +1,65 @@
|
|||
# Release Evidence Report Template
|
||||
|
||||
## Build Metadata
|
||||
|
||||
- Date:
|
||||
- Commit SHA:
|
||||
- Environment:
|
||||
- Operator:
|
||||
|
||||
## Phase Completion Summary
|
||||
|
||||
- Phase 00:
|
||||
- Phase 01:
|
||||
- Phase 02:
|
||||
- Phase 03:
|
||||
- Phase 04:
|
||||
- Phase 05:
|
||||
- Phase 06:
|
||||
- Phase 07:
|
||||
|
||||
## Test Summary
|
||||
|
||||
- Unit: pass/fail counts
|
||||
- Integration: pass/fail counts
|
||||
- E2E: pass/fail counts
|
||||
- Performance: pass/fail counts
|
||||
|
||||
## Mandatory Scenario Results
|
||||
|
||||
| ID | Result | Evidence Link | Notes |
|
||||
|---|---|---|---|
|
||||
| E-001 | | | |
|
||||
| E-002 | | | |
|
||||
| E-003 | | | |
|
||||
| E-004 | | | |
|
||||
| E-005 | | | |
|
||||
| E-006 | | | |
|
||||
| E-007 | | | |
|
||||
| E-008 | | | |
|
||||
|
||||
## Performance Results
|
||||
|
||||
| ID | Result | Evidence Link | Notes |
|
||||
|---|---|---|---|
|
||||
| P-001 | | | |
|
||||
| P-002 | | | |
|
||||
| P-003 | | | |
|
||||
| P-004 | | | |
|
||||
|
||||
## Legacy Sweep Results
|
||||
|
||||
- `trpc.round` references:
|
||||
- `roundId` orchestration references:
|
||||
- `round.settingsJson` behavior references:
|
||||
- schema `Round` references:
|
||||
|
||||
## Known Issues
|
||||
|
||||
- None / list with severity and owner
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- Engineering:
|
||||
- Product:
|
||||
- Operations:
|
||||
# Release Evidence Report Template
|
||||
|
||||
## Build Metadata
|
||||
|
||||
- Date:
|
||||
- Commit SHA:
|
||||
- Environment:
|
||||
- Operator:
|
||||
|
||||
## Phase Completion Summary
|
||||
|
||||
- Phase 00:
|
||||
- Phase 01:
|
||||
- Phase 02:
|
||||
- Phase 03:
|
||||
- Phase 04:
|
||||
- Phase 05:
|
||||
- Phase 06:
|
||||
- Phase 07:
|
||||
|
||||
## Test Summary
|
||||
|
||||
- Unit: pass/fail counts
|
||||
- Integration: pass/fail counts
|
||||
- E2E: pass/fail counts
|
||||
- Performance: pass/fail counts
|
||||
|
||||
## Mandatory Scenario Results
|
||||
|
||||
| ID | Result | Evidence Link | Notes |
|
||||
|---|---|---|---|
|
||||
| E-001 | | | |
|
||||
| E-002 | | | |
|
||||
| E-003 | | | |
|
||||
| E-004 | | | |
|
||||
| E-005 | | | |
|
||||
| E-006 | | | |
|
||||
| E-007 | | | |
|
||||
| E-008 | | | |
|
||||
|
||||
## Performance Results
|
||||
|
||||
| ID | Result | Evidence Link | Notes |
|
||||
|---|---|---|---|
|
||||
| P-001 | | | |
|
||||
| P-002 | | | |
|
||||
| P-003 | | | |
|
||||
| P-004 | | | |
|
||||
|
||||
## Legacy Sweep Results
|
||||
|
||||
- `trpc.round` references:
|
||||
- `roundId` orchestration references:
|
||||
- `round.settingsJson` behavior references:
|
||||
- schema `Round` references:
|
||||
|
||||
## Known Issues
|
||||
|
||||
- None / list with severity and owner
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- Engineering:
|
||||
- Product:
|
||||
- Operations:
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
# Risk Register
|
||||
|
||||
| ID | Risk | Probability | Impact | Mitigation | Owner | Status |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R-001 | Hidden legacy coupling after schema rebuild | Medium | High | Mandatory symbol sweeps + module refit gates | Eng Lead | Open |
|
||||
| R-002 | Assignment coverage edge-case failures at scale | Medium | High | Hard overflow policy + P-001 load tests | Backend Lead | Open |
|
||||
| R-003 | Award governance permission drift | Low | High | explicit authz tests for each decision mode | Security Lead | Open |
|
||||
| R-004 | Live cursor race conditions during events | Medium | High | optimistic lock + replay-safe event handling | Realtime Lead | Open |
|
||||
| R-005 | Audience vote dedupe regressions | Medium | Medium | dedupe key contract + E-007 + I-007 tests | Backend Lead | Open |
|
||||
| R-006 | Migration/reseed script incompleteness | Low | High | repeatable reset/reseed rehearsals | DB Owner | Open |
|
||||
| R-007 | Reporting consumers break on contract shift | Medium | Medium | phase-06 consumer validation checklist | Data Lead | Open |
|
||||
| R-008 | Scope creep during dependency refit | High | Medium | strict out-of-scope policy, defer noncritical features | PM | Open |
|
||||
|
||||
## Risk Handling Policy
|
||||
|
||||
- `High impact` items require explicit mitigation evidence before phase close.
|
||||
- `Open` high/high risks block release in Phase 07.
|
||||
# Risk Register
|
||||
|
||||
| ID | Risk | Probability | Impact | Mitigation | Owner | Status |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R-001 | Hidden legacy coupling after schema rebuild | Medium | High | Mandatory symbol sweeps + module refit gates | Eng Lead | Open |
|
||||
| R-002 | Assignment coverage edge-case failures at scale | Medium | High | Hard overflow policy + P-001 load tests | Backend Lead | Open |
|
||||
| R-003 | Award governance permission drift | Low | High | explicit authz tests for each decision mode | Security Lead | Open |
|
||||
| R-004 | Live cursor race conditions during events | Medium | High | optimistic lock + replay-safe event handling | Realtime Lead | Open |
|
||||
| R-005 | Audience vote dedupe regressions | Medium | Medium | dedupe key contract + E-007 + I-007 tests | Backend Lead | Open |
|
||||
| R-006 | Migration/reseed script incompleteness | Low | High | repeatable reset/reseed rehearsals | DB Owner | Open |
|
||||
| R-007 | Reporting consumers break on contract shift | Medium | Medium | phase-06 consumer validation checklist | Data Lead | Open |
|
||||
| R-008 | Scope creep during dependency refit | High | Medium | strict out-of-scope policy, defer noncritical features | PM | Open |
|
||||
|
||||
## Risk Handling Policy
|
||||
|
||||
- `High impact` items require explicit mitigation evidence before phase close.
|
||||
- `Open` high/high risks block release in Phase 07.
|
||||
|
|
|
|||
|
|
@ -1,57 +1,57 @@
|
|||
# Test Matrix
|
||||
|
||||
All IDs are mandatory unless explicitly marked non-blocking with sign-off.
|
||||
|
||||
## Unit Tests
|
||||
|
||||
| ID | Area | Scenario | Expected |
|
||||
|---|---|---|---|
|
||||
| U-001 | Transition Engine | legal transition | persisted with audit event |
|
||||
| U-002 | Transition Engine | illegal transition | typed validation error |
|
||||
| U-003 | Routing | multiple rule match | deterministic priority winner |
|
||||
| U-004 | Filtering Gates | missing required docs | blocked before AI pass |
|
||||
| U-005 | AI Banding | uncertain confidence band | routed to manual queue |
|
||||
| U-006 | Assignment | COI conflict | excluded from pool |
|
||||
| U-007 | Assignment | insufficient capacity | overflow flagged + coverage preserved |
|
||||
| U-008 | Override | missing reason fields | mutation rejected |
|
||||
| U-009 | Live Cursor | concurrent cursor update | conflict handled and retried |
|
||||
| U-010 | Award Governance | `AWARD_MASTER` on unauthorized award | forbidden |
|
||||
|
||||
## Integration Tests
|
||||
|
||||
| ID | Area | Scenario | Expected |
|
||||
|---|---|---|---|
|
||||
| I-001 | Pipeline CRUD | create/update/publish | graph integrity maintained |
|
||||
| I-002 | Stage Config | invalid config schema | rejected |
|
||||
| I-003 | Transition + Routing | filter pass to main + award parallel | dual states created |
|
||||
| I-004 | Award Exclusive Routing | exclusive route | removed from main continuation |
|
||||
| I-005 | Assignment API | preview vs execute parity | same constraints and outcomes |
|
||||
| I-006 | Live Runtime | jump + reorder + open/close windows | consistent cursor state |
|
||||
| I-007 | Cohort Voting | closed window submit | vote rejected |
|
||||
| I-008 | Decision Audit | override applied | complete immutable timeline |
|
||||
|
||||
## End-to-End Tests
|
||||
|
||||
| ID | Persona | Scenario | Expected |
|
||||
|---|---|---|---|
|
||||
| E-001 | Admin | complete setup via wizard | no hidden edit-only blockers |
|
||||
| E-002 | Applicant | upload intake requirements | status and deadlines enforced |
|
||||
| E-003 | Admin | run filtering stage | gates + AI + manual queue behave |
|
||||
| E-004 | Jury | complete evaluation workflow | criteria and lock policy enforced |
|
||||
| E-005 | Admin | selection + override | finalists and audit aligned |
|
||||
| E-006 | Live Admin | advance/back/jump + reorder | jury and audience sync realtime |
|
||||
| E-007 | Audience | vote by cohort on mobile | visibility and dedupe enforced |
|
||||
| E-008 | Admin | finalize results | ranking and publish outputs valid |
|
||||
|
||||
## Performance and Resilience
|
||||
|
||||
| ID | Area | Scenario | Threshold |
|
||||
|---|---|---|---|
|
||||
| P-001 | Assignment | 1000+ project batch | under agreed SLA |
|
||||
| P-002 | Filtering | large AI queue | deterministic retry, no dropped jobs |
|
||||
| P-003 | Live Voting | peak audience burst | acceptable p95 and no data loss |
|
||||
| P-004 | Reconnect | disconnect/reconnect | state converges quickly |
|
||||
|
||||
## Release Block Rule
|
||||
|
||||
Any failing `U-*`, `I-*`, `E-*`, or `P-*` is release-blocking unless signed waiver exists.
|
||||
# Test Matrix
|
||||
|
||||
All IDs are mandatory unless explicitly marked non-blocking with sign-off.
|
||||
|
||||
## Unit Tests
|
||||
|
||||
| ID | Area | Scenario | Expected |
|
||||
|---|---|---|---|
|
||||
| U-001 | Transition Engine | legal transition | persisted with audit event |
|
||||
| U-002 | Transition Engine | illegal transition | typed validation error |
|
||||
| U-003 | Routing | multiple rule match | deterministic priority winner |
|
||||
| U-004 | Filtering Gates | missing required docs | blocked before AI pass |
|
||||
| U-005 | AI Banding | uncertain confidence band | routed to manual queue |
|
||||
| U-006 | Assignment | COI conflict | excluded from pool |
|
||||
| U-007 | Assignment | insufficient capacity | overflow flagged + coverage preserved |
|
||||
| U-008 | Override | missing reason fields | mutation rejected |
|
||||
| U-009 | Live Cursor | concurrent cursor update | conflict handled and retried |
|
||||
| U-010 | Award Governance | `AWARD_MASTER` on unauthorized award | forbidden |
|
||||
|
||||
## Integration Tests
|
||||
|
||||
| ID | Area | Scenario | Expected |
|
||||
|---|---|---|---|
|
||||
| I-001 | Pipeline CRUD | create/update/publish | graph integrity maintained |
|
||||
| I-002 | Stage Config | invalid config schema | rejected |
|
||||
| I-003 | Transition + Routing | filter pass to main + award parallel | dual states created |
|
||||
| I-004 | Award Exclusive Routing | exclusive route | removed from main continuation |
|
||||
| I-005 | Assignment API | preview vs execute parity | same constraints and outcomes |
|
||||
| I-006 | Live Runtime | jump + reorder + open/close windows | consistent cursor state |
|
||||
| I-007 | Cohort Voting | closed window submit | vote rejected |
|
||||
| I-008 | Decision Audit | override applied | complete immutable timeline |
|
||||
|
||||
## End-to-End Tests
|
||||
|
||||
| ID | Persona | Scenario | Expected |
|
||||
|---|---|---|---|
|
||||
| E-001 | Admin | complete setup via wizard | no hidden edit-only blockers |
|
||||
| E-002 | Applicant | upload intake requirements | status and deadlines enforced |
|
||||
| E-003 | Admin | run filtering stage | gates + AI + manual queue behave |
|
||||
| E-004 | Jury | complete evaluation workflow | criteria and lock policy enforced |
|
||||
| E-005 | Admin | selection + override | finalists and audit aligned |
|
||||
| E-006 | Live Admin | advance/back/jump + reorder | jury and audience sync realtime |
|
||||
| E-007 | Audience | vote by cohort on mobile | visibility and dedupe enforced |
|
||||
| E-008 | Admin | finalize results | ranking and publish outputs valid |
|
||||
|
||||
## Performance and Resilience
|
||||
|
||||
| ID | Area | Scenario | Threshold |
|
||||
|---|---|---|---|
|
||||
| P-001 | Assignment | 1000+ project batch | under agreed SLA |
|
||||
| P-002 | Filtering | large AI queue | deterministic retry, no dropped jobs |
|
||||
| P-003 | Live Voting | peak audience burst | acceptable p95 and no data loss |
|
||||
| P-004 | Reconnect | disconnect/reconnect | state converges quickly |
|
||||
|
||||
## Release Block Rule
|
||||
|
||||
Any failing `U-*`, `I-*`, `E-*`, or `P-*` is release-blocking unless signed waiver exists.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,20 +1,20 @@
|
|||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
typedRoutes: true,
|
||||
serverExternalPackages: ['@prisma/client', 'minio'],
|
||||
experimental: {
|
||||
optimizePackageImports: ['lucide-react'],
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.minio.local',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
typedRoutes: true,
|
||||
serverExternalPackages: ['@prisma/client', 'minio'],
|
||||
experimental: {
|
||||
optimizePackageImports: ['lucide-react'],
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.minio.local',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
238
package.json
238
package.json
|
|
@ -1,119 +1,119 @@
|
|||
{
|
||||
"name": "mopc-platform",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:migrate:deploy": "prisma migrate deploy",
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:seed:candidatures": "tsx prisma/seed-candidatures.ts",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.7.4",
|
||||
"@blocknote/core": "^0.46.2",
|
||||
"@blocknote/mantine": "^0.46.2",
|
||||
"@blocknote/react": "^0.46.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@mantine/core": "^8.3.13",
|
||||
"@mantine/hooks": "^8.3.13",
|
||||
"@notionhq/client": "^2.3.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.4",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.2",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slider": "^1.2.2",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@trpc/client": "^11.0.0-rc.678",
|
||||
"@trpc/react-query": "^11.0.0-rc.678",
|
||||
"@trpc/server": "^11.0.0-rc.678",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.4",
|
||||
"csv-parse": "^6.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.1.0",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.563.0",
|
||||
"minio": "^8.0.2",
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.1.0",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^7.0.7",
|
||||
"openai": "^6.16.0",
|
||||
"papaparse": "^5.4.1",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-phone-number-input": "^3.4.14",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"superjson": "^2.2.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"use-debounce": "^10.0.4",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-next": "^15.1.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"prisma": "^6.19.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "mopc-platform",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:migrate:deploy": "prisma migrate deploy",
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:seed:candidatures": "tsx prisma/seed-candidatures.ts",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.7.4",
|
||||
"@blocknote/core": "^0.46.2",
|
||||
"@blocknote/mantine": "^0.46.2",
|
||||
"@blocknote/react": "^0.46.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@mantine/core": "^8.3.13",
|
||||
"@mantine/hooks": "^8.3.13",
|
||||
"@notionhq/client": "^2.3.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.4",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.2",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slider": "^1.2.2",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@trpc/client": "^11.0.0-rc.678",
|
||||
"@trpc/react-query": "^11.0.0-rc.678",
|
||||
"@trpc/server": "^11.0.0-rc.678",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.4",
|
||||
"csv-parse": "^6.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.1.0",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.563.0",
|
||||
"minio": "^8.0.2",
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.1.0",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^7.0.7",
|
||||
"openai": "^6.16.0",
|
||||
"papaparse": "^5.4.1",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-phone-number-input": "^3.4.14",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"superjson": "^2.2.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"use-debounce": "^10.0.4",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-next": "^15.1.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"prisma": "^6.19.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,246 +1,246 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
interface CheckResult {
|
||||
name: string
|
||||
passed: boolean
|
||||
details: string
|
||||
}
|
||||
|
||||
async function runChecks(): Promise<CheckResult[]> {
|
||||
const results: CheckResult[] = []
|
||||
|
||||
// 1. No orphan ProjectStageState (every PSS references valid project, track, stage)
|
||||
const orphanStates = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "ProjectStageState" pss
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Project" p WHERE p.id = pss."projectId")
|
||||
OR NOT EXISTS (SELECT 1 FROM "Track" t WHERE t.id = pss."trackId")
|
||||
OR NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = pss."stageId")
|
||||
`
|
||||
const orphanCount = Number(orphanStates[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'No orphan ProjectStageState',
|
||||
passed: orphanCount === 0,
|
||||
details: orphanCount === 0 ? 'All PSS records reference valid entities' : `Found ${orphanCount} orphan records`,
|
||||
})
|
||||
|
||||
// 2. Every project has at least one stage state
|
||||
const projectsWithoutState = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "Project" p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "ProjectStageState" pss WHERE pss."projectId" = p.id)
|
||||
`
|
||||
const noStateCount = Number(projectsWithoutState[0]?.count ?? 0)
|
||||
const totalProjects = await prisma.project.count()
|
||||
results.push({
|
||||
name: 'Every project has at least one stage state',
|
||||
passed: noStateCount === 0,
|
||||
details: noStateCount === 0
|
||||
? `All ${totalProjects} projects have stage states`
|
||||
: `${noStateCount} projects missing stage states`,
|
||||
})
|
||||
|
||||
// 3. No duplicate active states per (project, track, stage)
|
||||
const duplicateStates = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM (
|
||||
SELECT "projectId", "trackId", "stageId", COUNT(*) as cnt
|
||||
FROM "ProjectStageState"
|
||||
WHERE "exitedAt" IS NULL
|
||||
GROUP BY "projectId", "trackId", "stageId"
|
||||
HAVING COUNT(*) > 1
|
||||
) dupes
|
||||
`
|
||||
const dupeCount = Number(duplicateStates[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'No duplicate active states per (project, track, stage)',
|
||||
passed: dupeCount === 0,
|
||||
details: dupeCount === 0 ? 'No duplicates found' : `Found ${dupeCount} duplicate active states`,
|
||||
})
|
||||
|
||||
// 4. All transitions stay within same pipeline
|
||||
const crossPipelineTransitions = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "StageTransition" st
|
||||
JOIN "Stage" sf ON sf.id = st."fromStageId"
|
||||
JOIN "Track" tf ON tf.id = sf."trackId"
|
||||
JOIN "Stage" sto ON sto.id = st."toStageId"
|
||||
JOIN "Track" tt ON tt.id = sto."trackId"
|
||||
WHERE tf."pipelineId" != tt."pipelineId"
|
||||
`
|
||||
const crossCount = Number(crossPipelineTransitions[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'All transitions stay within same pipeline',
|
||||
passed: crossCount === 0,
|
||||
details: crossCount === 0 ? 'All transitions are within pipeline' : `Found ${crossCount} cross-pipeline transitions`,
|
||||
})
|
||||
|
||||
// 5. Stage sortOrder unique per track
|
||||
const duplicateSortOrders = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM (
|
||||
SELECT "trackId", "sortOrder", COUNT(*) as cnt
|
||||
FROM "Stage"
|
||||
GROUP BY "trackId", "sortOrder"
|
||||
HAVING COUNT(*) > 1
|
||||
) dupes
|
||||
`
|
||||
const dupeSortCount = Number(duplicateSortOrders[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Stage sortOrder unique per track',
|
||||
passed: dupeSortCount === 0,
|
||||
details: dupeSortCount === 0 ? 'All sort orders unique' : `Found ${dupeSortCount} duplicate sort orders`,
|
||||
})
|
||||
|
||||
// 6. Track sortOrder unique per pipeline
|
||||
const duplicateTrackOrders = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM (
|
||||
SELECT "pipelineId", "sortOrder", COUNT(*) as cnt
|
||||
FROM "Track"
|
||||
GROUP BY "pipelineId", "sortOrder"
|
||||
HAVING COUNT(*) > 1
|
||||
) dupes
|
||||
`
|
||||
const dupeTrackCount = Number(duplicateTrackOrders[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Track sortOrder unique per pipeline',
|
||||
passed: dupeTrackCount === 0,
|
||||
details: dupeTrackCount === 0 ? 'All track orders unique' : `Found ${dupeTrackCount} duplicate track orders`,
|
||||
})
|
||||
|
||||
// 7. Every Pipeline has at least one Track; every Track has at least one Stage
|
||||
const emptyPipelines = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "Pipeline" p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Track" t WHERE t."pipelineId" = p.id)
|
||||
`
|
||||
const emptyPipelineCount = Number(emptyPipelines[0]?.count ?? 0)
|
||||
const emptyTracks = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "Track" t
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s."trackId" = t.id)
|
||||
`
|
||||
const emptyTrackCount = Number(emptyTracks[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Every Pipeline has Tracks; every Track has Stages',
|
||||
passed: emptyPipelineCount === 0 && emptyTrackCount === 0,
|
||||
details: emptyPipelineCount === 0 && emptyTrackCount === 0
|
||||
? 'All pipelines have tracks and all tracks have stages'
|
||||
: `${emptyPipelineCount} empty pipelines, ${emptyTrackCount} empty tracks`,
|
||||
})
|
||||
|
||||
// 8. RoutingRule destinations reference valid tracks in same pipeline
|
||||
const badRoutingRules = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "RoutingRule" rr
|
||||
WHERE rr."destinationTrackId" IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Track" t
|
||||
WHERE t.id = rr."destinationTrackId"
|
||||
AND t."pipelineId" = rr."pipelineId"
|
||||
)
|
||||
`
|
||||
const badRouteCount = Number(badRoutingRules[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'RoutingRule destinations reference valid tracks in same pipeline',
|
||||
passed: badRouteCount === 0,
|
||||
details: badRouteCount === 0
|
||||
? 'All routing rules reference valid destination tracks'
|
||||
: `Found ${badRouteCount} routing rules with invalid destinations`,
|
||||
})
|
||||
|
||||
// 9. LiveProgressCursor references valid stage
|
||||
const badCursors = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "LiveProgressCursor" lpc
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = lpc."stageId")
|
||||
`
|
||||
const badCursorCount = Number(badCursors[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'LiveProgressCursor references valid stage',
|
||||
passed: badCursorCount === 0,
|
||||
details: badCursorCount === 0
|
||||
? 'All cursors reference valid stages'
|
||||
: `Found ${badCursorCount} cursors with invalid stage references`,
|
||||
})
|
||||
|
||||
// 10. Cohort references valid stage
|
||||
const badCohorts = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "Cohort" c
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = c."stageId")
|
||||
`
|
||||
const badCohortCount = Number(badCohorts[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Cohort references valid stage',
|
||||
passed: badCohortCount === 0,
|
||||
details: badCohortCount === 0
|
||||
? 'All cohorts reference valid stages'
|
||||
: `Found ${badCohortCount} cohorts with invalid stage references`,
|
||||
})
|
||||
|
||||
// 11. Every EvaluationForm has a valid stageId
|
||||
const badEvalForms = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "EvaluationForm" ef
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = ef."stageId")
|
||||
`
|
||||
const badFormCount = Number(badEvalForms[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Every EvaluationForm references valid stage',
|
||||
passed: badFormCount === 0,
|
||||
details: badFormCount === 0
|
||||
? 'All evaluation forms reference valid stages'
|
||||
: `Found ${badFormCount} forms with invalid stage references`,
|
||||
})
|
||||
|
||||
// 12. Every FileRequirement has a valid stageId
|
||||
const badFileReqs = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "FileRequirement" fr
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = fr."stageId")
|
||||
`
|
||||
const badFileReqCount = Number(badFileReqs[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Every FileRequirement references valid stage',
|
||||
passed: badFileReqCount === 0,
|
||||
details: badFileReqCount === 0
|
||||
? 'All file requirements reference valid stages'
|
||||
: `Found ${badFileReqCount} file requirements with invalid stage references`,
|
||||
})
|
||||
|
||||
// 13. Count validation
|
||||
const projectCountResult = await prisma.project.count()
|
||||
const stageCount = await prisma.stage.count()
|
||||
const trackCount = await prisma.track.count()
|
||||
const pipelineCount = await prisma.pipeline.count()
|
||||
const pssCount = await prisma.projectStageState.count()
|
||||
results.push({
|
||||
name: 'Count validation',
|
||||
passed: projectCountResult > 0 && stageCount > 0 && trackCount > 0,
|
||||
details: `Pipelines: ${pipelineCount}, Tracks: ${trackCount}, Stages: ${stageCount}, Projects: ${projectCountResult}, StageStates: ${pssCount}`,
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🔍 Running integrity checks...\n')
|
||||
|
||||
const results = await runChecks()
|
||||
|
||||
let allPassed = true
|
||||
for (const result of results) {
|
||||
const icon = result.passed ? '✅' : '❌'
|
||||
console.log(`${icon} ${result.name}`)
|
||||
console.log(` ${result.details}\n`)
|
||||
if (!result.passed) allPassed = false
|
||||
}
|
||||
|
||||
console.log('='.repeat(50))
|
||||
if (allPassed) {
|
||||
console.log('✅ All integrity checks passed!')
|
||||
} else {
|
||||
console.log('❌ Some integrity checks failed!')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Integrity check failed:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
interface CheckResult {
|
||||
name: string
|
||||
passed: boolean
|
||||
details: string
|
||||
}
|
||||
|
||||
async function runChecks(): Promise<CheckResult[]> {
|
||||
const results: CheckResult[] = []
|
||||
|
||||
// 1. No orphan ProjectStageState (every PSS references valid project, track, stage)
|
||||
const orphanStates = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "ProjectStageState" pss
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Project" p WHERE p.id = pss."projectId")
|
||||
OR NOT EXISTS (SELECT 1 FROM "Track" t WHERE t.id = pss."trackId")
|
||||
OR NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = pss."stageId")
|
||||
`
|
||||
const orphanCount = Number(orphanStates[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'No orphan ProjectStageState',
|
||||
passed: orphanCount === 0,
|
||||
details: orphanCount === 0 ? 'All PSS records reference valid entities' : `Found ${orphanCount} orphan records`,
|
||||
})
|
||||
|
||||
// 2. Every project has at least one stage state
|
||||
const projectsWithoutState = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "Project" p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "ProjectStageState" pss WHERE pss."projectId" = p.id)
|
||||
`
|
||||
const noStateCount = Number(projectsWithoutState[0]?.count ?? 0)
|
||||
const totalProjects = await prisma.project.count()
|
||||
results.push({
|
||||
name: 'Every project has at least one stage state',
|
||||
passed: noStateCount === 0,
|
||||
details: noStateCount === 0
|
||||
? `All ${totalProjects} projects have stage states`
|
||||
: `${noStateCount} projects missing stage states`,
|
||||
})
|
||||
|
||||
// 3. No duplicate active states per (project, track, stage)
|
||||
const duplicateStates = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM (
|
||||
SELECT "projectId", "trackId", "stageId", COUNT(*) as cnt
|
||||
FROM "ProjectStageState"
|
||||
WHERE "exitedAt" IS NULL
|
||||
GROUP BY "projectId", "trackId", "stageId"
|
||||
HAVING COUNT(*) > 1
|
||||
) dupes
|
||||
`
|
||||
const dupeCount = Number(duplicateStates[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'No duplicate active states per (project, track, stage)',
|
||||
passed: dupeCount === 0,
|
||||
details: dupeCount === 0 ? 'No duplicates found' : `Found ${dupeCount} duplicate active states`,
|
||||
})
|
||||
|
||||
// 4. All transitions stay within same pipeline
|
||||
const crossPipelineTransitions = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "StageTransition" st
|
||||
JOIN "Stage" sf ON sf.id = st."fromStageId"
|
||||
JOIN "Track" tf ON tf.id = sf."trackId"
|
||||
JOIN "Stage" sto ON sto.id = st."toStageId"
|
||||
JOIN "Track" tt ON tt.id = sto."trackId"
|
||||
WHERE tf."pipelineId" != tt."pipelineId"
|
||||
`
|
||||
const crossCount = Number(crossPipelineTransitions[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'All transitions stay within same pipeline',
|
||||
passed: crossCount === 0,
|
||||
details: crossCount === 0 ? 'All transitions are within pipeline' : `Found ${crossCount} cross-pipeline transitions`,
|
||||
})
|
||||
|
||||
// 5. Stage sortOrder unique per track
|
||||
const duplicateSortOrders = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM (
|
||||
SELECT "trackId", "sortOrder", COUNT(*) as cnt
|
||||
FROM "Stage"
|
||||
GROUP BY "trackId", "sortOrder"
|
||||
HAVING COUNT(*) > 1
|
||||
) dupes
|
||||
`
|
||||
const dupeSortCount = Number(duplicateSortOrders[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Stage sortOrder unique per track',
|
||||
passed: dupeSortCount === 0,
|
||||
details: dupeSortCount === 0 ? 'All sort orders unique' : `Found ${dupeSortCount} duplicate sort orders`,
|
||||
})
|
||||
|
||||
// 6. Track sortOrder unique per pipeline
|
||||
const duplicateTrackOrders = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM (
|
||||
SELECT "pipelineId", "sortOrder", COUNT(*) as cnt
|
||||
FROM "Track"
|
||||
GROUP BY "pipelineId", "sortOrder"
|
||||
HAVING COUNT(*) > 1
|
||||
) dupes
|
||||
`
|
||||
const dupeTrackCount = Number(duplicateTrackOrders[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Track sortOrder unique per pipeline',
|
||||
passed: dupeTrackCount === 0,
|
||||
details: dupeTrackCount === 0 ? 'All track orders unique' : `Found ${dupeTrackCount} duplicate track orders`,
|
||||
})
|
||||
|
||||
// 7. Every Pipeline has at least one Track; every Track has at least one Stage
|
||||
const emptyPipelines = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "Pipeline" p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Track" t WHERE t."pipelineId" = p.id)
|
||||
`
|
||||
const emptyPipelineCount = Number(emptyPipelines[0]?.count ?? 0)
|
||||
const emptyTracks = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "Track" t
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s."trackId" = t.id)
|
||||
`
|
||||
const emptyTrackCount = Number(emptyTracks[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Every Pipeline has Tracks; every Track has Stages',
|
||||
passed: emptyPipelineCount === 0 && emptyTrackCount === 0,
|
||||
details: emptyPipelineCount === 0 && emptyTrackCount === 0
|
||||
? 'All pipelines have tracks and all tracks have stages'
|
||||
: `${emptyPipelineCount} empty pipelines, ${emptyTrackCount} empty tracks`,
|
||||
})
|
||||
|
||||
// 8. RoutingRule destinations reference valid tracks in same pipeline
|
||||
const badRoutingRules = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "RoutingRule" rr
|
||||
WHERE rr."destinationTrackId" IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Track" t
|
||||
WHERE t.id = rr."destinationTrackId"
|
||||
AND t."pipelineId" = rr."pipelineId"
|
||||
)
|
||||
`
|
||||
const badRouteCount = Number(badRoutingRules[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'RoutingRule destinations reference valid tracks in same pipeline',
|
||||
passed: badRouteCount === 0,
|
||||
details: badRouteCount === 0
|
||||
? 'All routing rules reference valid destination tracks'
|
||||
: `Found ${badRouteCount} routing rules with invalid destinations`,
|
||||
})
|
||||
|
||||
// 9. LiveProgressCursor references valid stage
|
||||
const badCursors = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "LiveProgressCursor" lpc
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = lpc."stageId")
|
||||
`
|
||||
const badCursorCount = Number(badCursors[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'LiveProgressCursor references valid stage',
|
||||
passed: badCursorCount === 0,
|
||||
details: badCursorCount === 0
|
||||
? 'All cursors reference valid stages'
|
||||
: `Found ${badCursorCount} cursors with invalid stage references`,
|
||||
})
|
||||
|
||||
// 10. Cohort references valid stage
|
||||
const badCohorts = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "Cohort" c
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = c."stageId")
|
||||
`
|
||||
const badCohortCount = Number(badCohorts[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Cohort references valid stage',
|
||||
passed: badCohortCount === 0,
|
||||
details: badCohortCount === 0
|
||||
? 'All cohorts reference valid stages'
|
||||
: `Found ${badCohortCount} cohorts with invalid stage references`,
|
||||
})
|
||||
|
||||
// 11. Every EvaluationForm has a valid stageId
|
||||
const badEvalForms = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "EvaluationForm" ef
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = ef."stageId")
|
||||
`
|
||||
const badFormCount = Number(badEvalForms[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Every EvaluationForm references valid stage',
|
||||
passed: badFormCount === 0,
|
||||
details: badFormCount === 0
|
||||
? 'All evaluation forms reference valid stages'
|
||||
: `Found ${badFormCount} forms with invalid stage references`,
|
||||
})
|
||||
|
||||
// 12. Every FileRequirement has a valid stageId
|
||||
const badFileReqs = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*) as count FROM "FileRequirement" fr
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = fr."stageId")
|
||||
`
|
||||
const badFileReqCount = Number(badFileReqs[0]?.count ?? 0)
|
||||
results.push({
|
||||
name: 'Every FileRequirement references valid stage',
|
||||
passed: badFileReqCount === 0,
|
||||
details: badFileReqCount === 0
|
||||
? 'All file requirements reference valid stages'
|
||||
: `Found ${badFileReqCount} file requirements with invalid stage references`,
|
||||
})
|
||||
|
||||
// 13. Count validation
|
||||
const projectCountResult = await prisma.project.count()
|
||||
const stageCount = await prisma.stage.count()
|
||||
const trackCount = await prisma.track.count()
|
||||
const pipelineCount = await prisma.pipeline.count()
|
||||
const pssCount = await prisma.projectStageState.count()
|
||||
results.push({
|
||||
name: 'Count validation',
|
||||
passed: projectCountResult > 0 && stageCount > 0 && trackCount > 0,
|
||||
details: `Pipelines: ${pipelineCount}, Tracks: ${trackCount}, Stages: ${stageCount}, Projects: ${projectCountResult}, StageStates: ${pssCount}`,
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🔍 Running integrity checks...\n')
|
||||
|
||||
const results = await runChecks()
|
||||
|
||||
let allPassed = true
|
||||
for (const result of results) {
|
||||
const icon = result.passed ? '✅' : '❌'
|
||||
console.log(`${icon} ${result.name}`)
|
||||
console.log(` ${result.details}\n`)
|
||||
if (!result.passed) allPassed = false
|
||||
}
|
||||
|
||||
console.log('='.repeat(50))
|
||||
if (allPassed) {
|
||||
console.log('✅ All integrity checks passed!')
|
||||
} else {
|
||||
console.log('❌ Some integrity checks failed!')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Integrity check failed:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,91 +1,91 @@
|
|||
-- Universal Apply Page: Make Project.roundId nullable and add programId FK
|
||||
-- This migration enables projects to be submitted to a program/edition without being assigned to a specific round
|
||||
-- NOTE: Written to be idempotent (safe to re-run if partially applied)
|
||||
|
||||
-- Step 1: Add Program.slug for edition-wide apply URLs (nullable for existing programs)
|
||||
ALTER TABLE "Program" ADD COLUMN IF NOT EXISTS "slug" TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "Program_slug_key" ON "Program"("slug");
|
||||
|
||||
-- Step 2: Add programId column (nullable initially to handle existing data)
|
||||
ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "programId" TEXT;
|
||||
|
||||
-- Step 3: Backfill programId from existing round relationships
|
||||
-- Only update rows where programId is still NULL (idempotent)
|
||||
UPDATE "Project" p
|
||||
SET "programId" = r."programId"
|
||||
FROM "Round" r
|
||||
WHERE p."roundId" = r.id
|
||||
AND p."programId" IS NULL;
|
||||
|
||||
-- Step 4: Handle orphaned projects (no roundId = no way to derive programId)
|
||||
-- Assign them to the first available program, or delete them if no program exists
|
||||
DO $$
|
||||
DECLARE
|
||||
null_count INTEGER;
|
||||
fallback_program_id TEXT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO null_count FROM "Project" WHERE "programId" IS NULL;
|
||||
IF null_count > 0 THEN
|
||||
SELECT id INTO fallback_program_id FROM "Program" ORDER BY "createdAt" ASC LIMIT 1;
|
||||
IF fallback_program_id IS NOT NULL THEN
|
||||
UPDATE "Project" SET "programId" = fallback_program_id WHERE "programId" IS NULL;
|
||||
RAISE NOTICE 'Assigned % orphaned projects to fallback program %', null_count, fallback_program_id;
|
||||
ELSE
|
||||
DELETE FROM "Project" WHERE "programId" IS NULL;
|
||||
RAISE NOTICE 'Deleted % orphaned projects (no program exists to assign them to)', null_count;
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Step 5: Make programId required (NOT NULL constraint) - safe if already NOT NULL
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'Project' AND column_name = 'programId' AND is_nullable = 'YES'
|
||||
) THEN
|
||||
ALTER TABLE "Project" ALTER COLUMN "programId" SET NOT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Step 6: Add foreign key constraint for programId (skip if already exists)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'Project_programId_fkey' AND table_name = 'Project'
|
||||
) THEN
|
||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_programId_fkey"
|
||||
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Step 7: Make roundId nullable (allow projects without round assignment)
|
||||
-- Safe: DROP NOT NULL is idempotent if already nullable
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'Project' AND column_name = 'roundId' AND is_nullable = 'NO'
|
||||
) THEN
|
||||
ALTER TABLE "Project" ALTER COLUMN "roundId" DROP NOT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Step 8: Update round FK to SET NULL on delete (instead of CASCADE)
|
||||
-- Projects should remain in the database if their round is deleted
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'Project_roundId_fkey' AND table_name = 'Project'
|
||||
) THEN
|
||||
ALTER TABLE "Project" DROP CONSTRAINT "Project_roundId_fkey";
|
||||
END IF;
|
||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_roundId_fkey"
|
||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL;
|
||||
END $$;
|
||||
|
||||
-- Step 9: Add performance indexes
|
||||
CREATE INDEX IF NOT EXISTS "Project_programId_idx" ON "Project"("programId");
|
||||
CREATE INDEX IF NOT EXISTS "Project_programId_roundId_idx" ON "Project"("programId", "roundId");
|
||||
-- Universal Apply Page: Make Project.roundId nullable and add programId FK
|
||||
-- This migration enables projects to be submitted to a program/edition without being assigned to a specific round
|
||||
-- NOTE: Written to be idempotent (safe to re-run if partially applied)
|
||||
|
||||
-- Step 1: Add Program.slug for edition-wide apply URLs (nullable for existing programs)
|
||||
ALTER TABLE "Program" ADD COLUMN IF NOT EXISTS "slug" TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "Program_slug_key" ON "Program"("slug");
|
||||
|
||||
-- Step 2: Add programId column (nullable initially to handle existing data)
|
||||
ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "programId" TEXT;
|
||||
|
||||
-- Step 3: Backfill programId from existing round relationships
|
||||
-- Only update rows where programId is still NULL (idempotent)
|
||||
UPDATE "Project" p
|
||||
SET "programId" = r."programId"
|
||||
FROM "Round" r
|
||||
WHERE p."roundId" = r.id
|
||||
AND p."programId" IS NULL;
|
||||
|
||||
-- Step 4: Handle orphaned projects (no roundId = no way to derive programId)
|
||||
-- Assign them to the first available program, or delete them if no program exists
|
||||
DO $$
|
||||
DECLARE
|
||||
null_count INTEGER;
|
||||
fallback_program_id TEXT;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO null_count FROM "Project" WHERE "programId" IS NULL;
|
||||
IF null_count > 0 THEN
|
||||
SELECT id INTO fallback_program_id FROM "Program" ORDER BY "createdAt" ASC LIMIT 1;
|
||||
IF fallback_program_id IS NOT NULL THEN
|
||||
UPDATE "Project" SET "programId" = fallback_program_id WHERE "programId" IS NULL;
|
||||
RAISE NOTICE 'Assigned % orphaned projects to fallback program %', null_count, fallback_program_id;
|
||||
ELSE
|
||||
DELETE FROM "Project" WHERE "programId" IS NULL;
|
||||
RAISE NOTICE 'Deleted % orphaned projects (no program exists to assign them to)', null_count;
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Step 5: Make programId required (NOT NULL constraint) - safe if already NOT NULL
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'Project' AND column_name = 'programId' AND is_nullable = 'YES'
|
||||
) THEN
|
||||
ALTER TABLE "Project" ALTER COLUMN "programId" SET NOT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Step 6: Add foreign key constraint for programId (skip if already exists)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'Project_programId_fkey' AND table_name = 'Project'
|
||||
) THEN
|
||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_programId_fkey"
|
||||
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Step 7: Make roundId nullable (allow projects without round assignment)
|
||||
-- Safe: DROP NOT NULL is idempotent if already nullable
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'Project' AND column_name = 'roundId' AND is_nullable = 'NO'
|
||||
) THEN
|
||||
ALTER TABLE "Project" ALTER COLUMN "roundId" DROP NOT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Step 8: Update round FK to SET NULL on delete (instead of CASCADE)
|
||||
-- Projects should remain in the database if their round is deleted
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'Project_roundId_fkey' AND table_name = 'Project'
|
||||
) THEN
|
||||
ALTER TABLE "Project" DROP CONSTRAINT "Project_roundId_fkey";
|
||||
END IF;
|
||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_roundId_fkey"
|
||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL;
|
||||
END $$;
|
||||
|
||||
-- Step 9: Add performance indexes
|
||||
CREATE INDEX IF NOT EXISTS "Project_programId_idx" ON "Project"("programId");
|
||||
CREATE INDEX IF NOT EXISTS "Project_programId_roundId_idx" ON "Project"("programId", "roundId");
|
||||
|
|
|
|||
|
|
@ -1,41 +1,41 @@
|
|||
-- Reconciliation migration: Add missing foreign keys and indexes
|
||||
-- The add_15_features migration omitted some FKs and indexes that the schema expects
|
||||
-- This migration brings the database in line with the Prisma schema
|
||||
|
||||
-- =====================================================
|
||||
-- Missing Foreign Keys
|
||||
-- =====================================================
|
||||
|
||||
-- RoundTemplate → Program
|
||||
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_programId_fkey"
|
||||
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- RoundTemplate → User (creator)
|
||||
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_createdBy_fkey"
|
||||
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- Message → Round
|
||||
ALTER TABLE "Message" ADD CONSTRAINT "Message_roundId_fkey"
|
||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- EvaluationDiscussion → Round
|
||||
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_roundId_fkey"
|
||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- ProjectFile → ProjectFile (self-relation for file versioning)
|
||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_replacedById_fkey"
|
||||
FOREIGN KEY ("replacedById") REFERENCES "ProjectFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- =====================================================
|
||||
-- Missing Indexes
|
||||
-- =====================================================
|
||||
|
||||
CREATE INDEX "RoundTemplate_roundType_idx" ON "RoundTemplate"("roundType");
|
||||
CREATE INDEX "MentorNote_authorId_idx" ON "MentorNote"("authorId");
|
||||
CREATE INDEX "MentorMilestoneCompletion_completedById_idx" ON "MentorMilestoneCompletion"("completedById");
|
||||
CREATE INDEX "Webhook_createdById_idx" ON "Webhook"("createdById");
|
||||
CREATE INDEX "WebhookDelivery_event_idx" ON "WebhookDelivery"("event");
|
||||
CREATE INDEX "Message_roundId_idx" ON "Message"("roundId");
|
||||
CREATE INDEX "EvaluationDiscussion_closedById_idx" ON "EvaluationDiscussion"("closedById");
|
||||
CREATE INDEX "DiscussionComment_discussionId_idx" ON "DiscussionComment"("discussionId");
|
||||
CREATE INDEX "DiscussionComment_userId_idx" ON "DiscussionComment"("userId");
|
||||
-- Reconciliation migration: Add missing foreign keys and indexes
|
||||
-- The add_15_features migration omitted some FKs and indexes that the schema expects
|
||||
-- This migration brings the database in line with the Prisma schema
|
||||
|
||||
-- =====================================================
|
||||
-- Missing Foreign Keys
|
||||
-- =====================================================
|
||||
|
||||
-- RoundTemplate → Program
|
||||
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_programId_fkey"
|
||||
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- RoundTemplate → User (creator)
|
||||
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_createdBy_fkey"
|
||||
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- Message → Round
|
||||
ALTER TABLE "Message" ADD CONSTRAINT "Message_roundId_fkey"
|
||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- EvaluationDiscussion → Round
|
||||
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_roundId_fkey"
|
||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- ProjectFile → ProjectFile (self-relation for file versioning)
|
||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_replacedById_fkey"
|
||||
FOREIGN KEY ("replacedById") REFERENCES "ProjectFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- =====================================================
|
||||
-- Missing Indexes
|
||||
-- =====================================================
|
||||
|
||||
CREATE INDEX "RoundTemplate_roundType_idx" ON "RoundTemplate"("roundType");
|
||||
CREATE INDEX "MentorNote_authorId_idx" ON "MentorNote"("authorId");
|
||||
CREATE INDEX "MentorMilestoneCompletion_completedById_idx" ON "MentorMilestoneCompletion"("completedById");
|
||||
CREATE INDEX "Webhook_createdById_idx" ON "Webhook"("createdById");
|
||||
CREATE INDEX "WebhookDelivery_event_idx" ON "WebhookDelivery"("event");
|
||||
CREATE INDEX "Message_roundId_idx" ON "Message"("roundId");
|
||||
CREATE INDEX "EvaluationDiscussion_closedById_idx" ON "EvaluationDiscussion"("closedById");
|
||||
CREATE INDEX "DiscussionComment_discussionId_idx" ON "DiscussionComment"("discussionId");
|
||||
CREATE INDEX "DiscussionComment_userId_idx" ON "DiscussionComment"("userId");
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
-- Fix round deletion FK constraint errors
|
||||
-- Add CASCADE on Evaluation.formId so deleting EvaluationForm cascades to Evaluations
|
||||
-- Add SET NULL on ProjectFile.roundId so deleting Round nullifies the reference
|
||||
|
||||
-- AlterTable: Evaluation.formId -> onDelete CASCADE
|
||||
ALTER TABLE "Evaluation" DROP CONSTRAINT "Evaluation_formId_fkey";
|
||||
ALTER TABLE "Evaluation" ADD CONSTRAINT "Evaluation_formId_fkey"
|
||||
FOREIGN KEY ("formId") REFERENCES "EvaluationForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AlterTable: ProjectFile.roundId -> onDelete SET NULL
|
||||
ALTER TABLE "ProjectFile" DROP CONSTRAINT "ProjectFile_roundId_fkey";
|
||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_roundId_fkey"
|
||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
-- Fix round deletion FK constraint errors
|
||||
-- Add CASCADE on Evaluation.formId so deleting EvaluationForm cascades to Evaluations
|
||||
-- Add SET NULL on ProjectFile.roundId so deleting Round nullifies the reference
|
||||
|
||||
-- AlterTable: Evaluation.formId -> onDelete CASCADE
|
||||
ALTER TABLE "Evaluation" DROP CONSTRAINT "Evaluation_formId_fkey";
|
||||
ALTER TABLE "Evaluation" ADD CONSTRAINT "Evaluation_formId_fkey"
|
||||
FOREIGN KEY ("formId") REFERENCES "EvaluationForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AlterTable: ProjectFile.roundId -> onDelete SET NULL
|
||||
ALTER TABLE "ProjectFile" DROP CONSTRAINT "ProjectFile_roundId_fkey";
|
||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_roundId_fkey"
|
||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "FileRequirement" (
|
||||
"id" TEXT NOT NULL,
|
||||
"roundId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"acceptedMimeTypes" TEXT[],
|
||||
"maxSizeMB" INTEGER,
|
||||
"isRequired" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FileRequirement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FileRequirement_roundId_idx" ON "FileRequirement"("roundId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FileRequirement" ADD CONSTRAINT "FileRequirement_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AlterTable: add requirementId to ProjectFile
|
||||
ALTER TABLE "ProjectFile" ADD COLUMN "requirementId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectFile_requirementId_idx" ON "ProjectFile"("requirementId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_requirementId_fkey" FOREIGN KEY ("requirementId") REFERENCES "FileRequirement"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
-- CreateTable
|
||||
CREATE TABLE "FileRequirement" (
|
||||
"id" TEXT NOT NULL,
|
||||
"roundId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"acceptedMimeTypes" TEXT[],
|
||||
"maxSizeMB" INTEGER,
|
||||
"isRequired" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FileRequirement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FileRequirement_roundId_idx" ON "FileRequirement"("roundId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FileRequirement" ADD CONSTRAINT "FileRequirement_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AlterTable: add requirementId to ProjectFile
|
||||
ALTER TABLE "ProjectFile" ADD COLUMN "requirementId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectFile_requirementId_idx" ON "ProjectFile"("requirementId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_requirementId_fkey" FOREIGN KEY ("requirementId") REFERENCES "FileRequirement"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
|
|
|||
|
|
@ -1,129 +1,129 @@
|
|||
-- Migration: Add all missing schema elements not covered by previous migrations
|
||||
-- This brings the database fully in line with prisma/schema.prisma
|
||||
-- Uses IF NOT EXISTS / DO $$ guards for idempotent execution
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. MISSING TABLE: WizardTemplate
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "WizardTemplate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"config" JSONB NOT NULL,
|
||||
"isGlobal" BOOLEAN NOT NULL DEFAULT false,
|
||||
"programId" TEXT,
|
||||
"createdBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WizardTemplate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "WizardTemplate_programId_idx" ON "WizardTemplate"("programId");
|
||||
CREATE INDEX IF NOT EXISTS "WizardTemplate_isGlobal_idx" ON "WizardTemplate"("isGlobal");
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "WizardTemplate" ADD CONSTRAINT "WizardTemplate_programId_fkey"
|
||||
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "WizardTemplate" ADD CONSTRAINT "WizardTemplate_createdBy_fkey"
|
||||
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. MISSING COLUMNS ON SpecialAward: eligibility job tracking fields
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobStatus" TEXT;
|
||||
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobTotal" INTEGER;
|
||||
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobDone" INTEGER;
|
||||
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobError" TEXT;
|
||||
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobStarted" TIMESTAMP(3);
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Project.referralSource: Already in init migration. No action needed.
|
||||
-- Round.slug: Already in init migration. No action needed.
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. MISSING INDEXES
|
||||
-- =============================================================================
|
||||
|
||||
-- 5a. Assignment: @@index([projectId, userId])
|
||||
CREATE INDEX IF NOT EXISTS "Assignment_projectId_userId_idx" ON "Assignment"("projectId", "userId");
|
||||
|
||||
-- 5b. AuditLog: @@index([sessionId])
|
||||
CREATE INDEX IF NOT EXISTS "AuditLog_sessionId_idx" ON "AuditLog"("sessionId");
|
||||
|
||||
-- 5c. ProjectFile: @@index([projectId, roundId])
|
||||
CREATE INDEX IF NOT EXISTS "ProjectFile_projectId_roundId_idx" ON "ProjectFile"("projectId", "roundId");
|
||||
|
||||
-- 5d. MessageRecipient: @@index([userId])
|
||||
CREATE INDEX IF NOT EXISTS "MessageRecipient_userId_idx" ON "MessageRecipient"("userId");
|
||||
|
||||
-- 5e. MessageRecipient: @@unique([messageId, userId, channel])
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "MessageRecipient_messageId_userId_channel_key" ON "MessageRecipient"("messageId", "userId", "channel");
|
||||
|
||||
-- 5f. AwardEligibility: @@index([awardId, eligible]) - composite index
|
||||
CREATE INDEX IF NOT EXISTS "AwardEligibility_awardId_eligible_idx" ON "AwardEligibility"("awardId", "eligible");
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. REMOVE STALE INDEX: Message_scheduledAt_idx
|
||||
-- The schema does NOT have @@index([scheduledAt]) on Message.
|
||||
-- The add_15_features migration created it, but the schema doesn't list it.
|
||||
-- Leaving it as-is since it's harmless and could be useful.
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. VERIFY: All models from add_15_features are present
|
||||
-- DigestLog, RoundTemplate, MentorNote, MentorMilestone,
|
||||
-- MentorMilestoneCompletion, Message, MessageTemplate, MessageRecipient,
|
||||
-- Webhook, WebhookDelivery, EvaluationDiscussion, DiscussionComment
|
||||
-- -> All confirmed created in 20260205223133_add_15_features migration.
|
||||
-- -> All FKs confirmed in add_15_features + 20260208000000_add_missing_fks_indexes.
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- 8. VERIFY: Existing tables from init and subsequent migrations
|
||||
-- All core tables (User, Account, Session, VerificationToken, Program, Round,
|
||||
-- EvaluationForm, Project, ProjectFile, Assignment, Evaluation, GracePeriod,
|
||||
-- SystemSettings, AuditLog, AIUsageLog, NotificationLog, InAppNotification,
|
||||
-- NotificationEmailSetting, LearningResource, ResourceAccess, Partner,
|
||||
-- ExpertiseTag, ProjectTag, LiveVotingSession, LiveVote, TeamMember,
|
||||
-- MentorAssignment, FilteringRule, FilteringResult, FilteringJob,
|
||||
-- AssignmentJob, TaggingJob, SpecialAward, AwardEligibility, AwardJuror,
|
||||
-- AwardVote, ReminderLog, ConflictOfInterest, EvaluationSummary,
|
||||
-- ProjectStatusHistory, MentorMessage, FileRequirement)
|
||||
-- -> All confirmed present in migrations.
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- SUMMARY OF CHANGES IN THIS MIGRATION:
|
||||
--
|
||||
-- NEW TABLE:
|
||||
-- - WizardTemplate (with programId FK, createdBy FK, indexes)
|
||||
--
|
||||
-- NEW COLUMNS:
|
||||
-- - SpecialAward.eligibilityJobStatus (TEXT, nullable)
|
||||
-- - SpecialAward.eligibilityJobTotal (INTEGER, nullable)
|
||||
-- - SpecialAward.eligibilityJobDone (INTEGER, nullable)
|
||||
-- - SpecialAward.eligibilityJobError (TEXT, nullable)
|
||||
-- - SpecialAward.eligibilityJobStarted (TIMESTAMP, nullable)
|
||||
--
|
||||
-- NEW INDEXES:
|
||||
-- - Assignment_projectId_userId_idx
|
||||
-- - AuditLog_sessionId_idx
|
||||
-- - ProjectFile_projectId_roundId_idx
|
||||
-- - MessageRecipient_userId_idx
|
||||
-- - MessageRecipient_messageId_userId_channel_key (UNIQUE)
|
||||
-- - AwardEligibility_awardId_eligible_idx
|
||||
-- - WizardTemplate_programId_idx
|
||||
-- - WizardTemplate_isGlobal_idx
|
||||
--
|
||||
-- NEW FOREIGN KEYS:
|
||||
-- - WizardTemplate_programId_fkey -> Program(id) ON DELETE CASCADE
|
||||
-- - WizardTemplate_createdBy_fkey -> User(id) ON DELETE RESTRICT
|
||||
-- =============================================================================
|
||||
-- Migration: Add all missing schema elements not covered by previous migrations
|
||||
-- This brings the database fully in line with prisma/schema.prisma
|
||||
-- Uses IF NOT EXISTS / DO $$ guards for idempotent execution
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. MISSING TABLE: WizardTemplate
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "WizardTemplate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"config" JSONB NOT NULL,
|
||||
"isGlobal" BOOLEAN NOT NULL DEFAULT false,
|
||||
"programId" TEXT,
|
||||
"createdBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WizardTemplate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "WizardTemplate_programId_idx" ON "WizardTemplate"("programId");
|
||||
CREATE INDEX IF NOT EXISTS "WizardTemplate_isGlobal_idx" ON "WizardTemplate"("isGlobal");
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "WizardTemplate" ADD CONSTRAINT "WizardTemplate_programId_fkey"
|
||||
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "WizardTemplate" ADD CONSTRAINT "WizardTemplate_createdBy_fkey"
|
||||
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. MISSING COLUMNS ON SpecialAward: eligibility job tracking fields
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobStatus" TEXT;
|
||||
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobTotal" INTEGER;
|
||||
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobDone" INTEGER;
|
||||
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobError" TEXT;
|
||||
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobStarted" TIMESTAMP(3);
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Project.referralSource: Already in init migration. No action needed.
|
||||
-- Round.slug: Already in init migration. No action needed.
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. MISSING INDEXES
|
||||
-- =============================================================================
|
||||
|
||||
-- 5a. Assignment: @@index([projectId, userId])
|
||||
CREATE INDEX IF NOT EXISTS "Assignment_projectId_userId_idx" ON "Assignment"("projectId", "userId");
|
||||
|
||||
-- 5b. AuditLog: @@index([sessionId])
|
||||
CREATE INDEX IF NOT EXISTS "AuditLog_sessionId_idx" ON "AuditLog"("sessionId");
|
||||
|
||||
-- 5c. ProjectFile: @@index([projectId, roundId])
|
||||
CREATE INDEX IF NOT EXISTS "ProjectFile_projectId_roundId_idx" ON "ProjectFile"("projectId", "roundId");
|
||||
|
||||
-- 5d. MessageRecipient: @@index([userId])
|
||||
CREATE INDEX IF NOT EXISTS "MessageRecipient_userId_idx" ON "MessageRecipient"("userId");
|
||||
|
||||
-- 5e. MessageRecipient: @@unique([messageId, userId, channel])
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "MessageRecipient_messageId_userId_channel_key" ON "MessageRecipient"("messageId", "userId", "channel");
|
||||
|
||||
-- 5f. AwardEligibility: @@index([awardId, eligible]) - composite index
|
||||
CREATE INDEX IF NOT EXISTS "AwardEligibility_awardId_eligible_idx" ON "AwardEligibility"("awardId", "eligible");
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. REMOVE STALE INDEX: Message_scheduledAt_idx
|
||||
-- The schema does NOT have @@index([scheduledAt]) on Message.
|
||||
-- The add_15_features migration created it, but the schema doesn't list it.
|
||||
-- Leaving it as-is since it's harmless and could be useful.
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. VERIFY: All models from add_15_features are present
|
||||
-- DigestLog, RoundTemplate, MentorNote, MentorMilestone,
|
||||
-- MentorMilestoneCompletion, Message, MessageTemplate, MessageRecipient,
|
||||
-- Webhook, WebhookDelivery, EvaluationDiscussion, DiscussionComment
|
||||
-- -> All confirmed created in 20260205223133_add_15_features migration.
|
||||
-- -> All FKs confirmed in add_15_features + 20260208000000_add_missing_fks_indexes.
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- 8. VERIFY: Existing tables from init and subsequent migrations
|
||||
-- All core tables (User, Account, Session, VerificationToken, Program, Round,
|
||||
-- EvaluationForm, Project, ProjectFile, Assignment, Evaluation, GracePeriod,
|
||||
-- SystemSettings, AuditLog, AIUsageLog, NotificationLog, InAppNotification,
|
||||
-- NotificationEmailSetting, LearningResource, ResourceAccess, Partner,
|
||||
-- ExpertiseTag, ProjectTag, LiveVotingSession, LiveVote, TeamMember,
|
||||
-- MentorAssignment, FilteringRule, FilteringResult, FilteringJob,
|
||||
-- AssignmentJob, TaggingJob, SpecialAward, AwardEligibility, AwardJuror,
|
||||
-- AwardVote, ReminderLog, ConflictOfInterest, EvaluationSummary,
|
||||
-- ProjectStatusHistory, MentorMessage, FileRequirement)
|
||||
-- -> All confirmed present in migrations.
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- SUMMARY OF CHANGES IN THIS MIGRATION:
|
||||
--
|
||||
-- NEW TABLE:
|
||||
-- - WizardTemplate (with programId FK, createdBy FK, indexes)
|
||||
--
|
||||
-- NEW COLUMNS:
|
||||
-- - SpecialAward.eligibilityJobStatus (TEXT, nullable)
|
||||
-- - SpecialAward.eligibilityJobTotal (INTEGER, nullable)
|
||||
-- - SpecialAward.eligibilityJobDone (INTEGER, nullable)
|
||||
-- - SpecialAward.eligibilityJobError (TEXT, nullable)
|
||||
-- - SpecialAward.eligibilityJobStarted (TIMESTAMP, nullable)
|
||||
--
|
||||
-- NEW INDEXES:
|
||||
-- - Assignment_projectId_userId_idx
|
||||
-- - AuditLog_sessionId_idx
|
||||
-- - ProjectFile_projectId_roundId_idx
|
||||
-- - MessageRecipient_userId_idx
|
||||
-- - MessageRecipient_messageId_userId_channel_key (UNIQUE)
|
||||
-- - AwardEligibility_awardId_eligible_idx
|
||||
-- - WizardTemplate_programId_idx
|
||||
-- - WizardTemplate_isGlobal_idx
|
||||
--
|
||||
-- NEW FOREIGN KEYS:
|
||||
-- - WizardTemplate_programId_fkey -> Program(id) ON DELETE CASCADE
|
||||
-- - WizardTemplate_createdBy_fkey -> User(id) ON DELETE RESTRICT
|
||||
-- =============================================================================
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
-- CreateIndex
|
||||
CREATE INDEX "AwardVote_awardId_userId_idx" ON "AwardVote"("awardId", "userId");
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AwardVote_awardId_userId_idx" ON "AwardVote"("awardId", "userId");
|
||||
|
|
|
|||
|
|
@ -1,99 +1,99 @@
|
|||
-- Migration: Add live voting enhancements (criteria voting, audience voting, AudienceVoter)
|
||||
-- Brings LiveVotingSession, LiveVote, and new AudienceVoter model in sync with schema.prisma
|
||||
-- Uses IF NOT EXISTS / DO $$ guards for idempotent execution
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. LiveVotingSession: Add criteria-based & audience voting columns
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE "LiveVotingSession" ADD COLUMN IF NOT EXISTS "votingMode" TEXT NOT NULL DEFAULT 'simple';
|
||||
ALTER TABLE "LiveVotingSession" ADD COLUMN IF NOT EXISTS "criteriaJson" JSONB;
|
||||
ALTER TABLE "LiveVotingSession" ADD COLUMN IF NOT EXISTS "audienceVotingMode" TEXT NOT NULL DEFAULT 'disabled';
|
||||
ALTER TABLE "LiveVotingSession" ADD COLUMN IF NOT EXISTS "audienceMaxFavorites" INTEGER NOT NULL DEFAULT 3;
|
||||
ALTER TABLE "LiveVotingSession" ADD COLUMN IF NOT EXISTS "audienceRequireId" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "LiveVotingSession" ADD COLUMN IF NOT EXISTS "audienceVotingDuration" INTEGER;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. LiveVote: Add criteria scores, audience voter link, make userId nullable
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE "LiveVote" ADD COLUMN IF NOT EXISTS "criterionScoresJson" JSONB;
|
||||
ALTER TABLE "LiveVote" ADD COLUMN IF NOT EXISTS "audienceVoterId" TEXT;
|
||||
|
||||
-- Make userId nullable (was NOT NULL in init migration)
|
||||
ALTER TABLE "LiveVote" ALTER COLUMN "userId" DROP NOT NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. AudienceVoter: New table for audience participation
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "AudienceVoter" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"identifier" TEXT,
|
||||
"identifierType" TEXT,
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AudienceVoter_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Unique constraint on token
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "AudienceVoter" ADD CONSTRAINT "AudienceVoter_token_key" UNIQUE ("token");
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS "AudienceVoter_sessionId_idx" ON "AudienceVoter"("sessionId");
|
||||
CREATE INDEX IF NOT EXISTS "AudienceVoter_token_idx" ON "AudienceVoter"("token");
|
||||
|
||||
-- Foreign key: AudienceVoter.sessionId -> LiveVotingSession.id
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "AudienceVoter" ADD CONSTRAINT "AudienceVoter_sessionId_fkey"
|
||||
FOREIGN KEY ("sessionId") REFERENCES "LiveVotingSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. LiveVote: Foreign key and indexes for audienceVoterId
|
||||
-- =============================================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "LiveVote_audienceVoterId_idx" ON "LiveVote"("audienceVoterId");
|
||||
|
||||
-- Foreign key: LiveVote.audienceVoterId -> AudienceVoter.id
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "LiveVote" ADD CONSTRAINT "LiveVote_audienceVoterId_fkey"
|
||||
FOREIGN KEY ("audienceVoterId") REFERENCES "AudienceVoter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- Unique constraint: sessionId + projectId + audienceVoterId
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "LiveVote" ADD CONSTRAINT "LiveVote_sessionId_projectId_audienceVoterId_key"
|
||||
UNIQUE ("sessionId", "projectId", "audienceVoterId");
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- SUMMARY:
|
||||
--
|
||||
-- LiveVotingSession new columns:
|
||||
-- - votingMode (TEXT, default 'simple')
|
||||
-- - criteriaJson (JSONB, nullable)
|
||||
-- - audienceVotingMode (TEXT, default 'disabled')
|
||||
-- - audienceMaxFavorites (INTEGER, default 3)
|
||||
-- - audienceRequireId (BOOLEAN, default false)
|
||||
-- - audienceVotingDuration (INTEGER, nullable)
|
||||
--
|
||||
-- LiveVote changes:
|
||||
-- - criterionScoresJson (JSONB, nullable) - new column
|
||||
-- - audienceVoterId (TEXT, nullable) - new column
|
||||
-- - userId changed from NOT NULL to nullable
|
||||
-- - New unique: (sessionId, projectId, audienceVoterId)
|
||||
-- - New index: audienceVoterId
|
||||
-- - New FK: audienceVoterId -> AudienceVoter(id)
|
||||
--
|
||||
-- New table: AudienceVoter
|
||||
-- - id, sessionId, token (unique), identifier, identifierType,
|
||||
-- ipAddress, userAgent, createdAt
|
||||
-- - FK: sessionId -> LiveVotingSession(id) CASCADE
|
||||
-- =============================================================================
|
||||
-- Migration: Add live voting enhancements (criteria voting, audience voting, AudienceVoter)
|
||||
-- Brings LiveVotingSession, LiveVote, and new AudienceVoter model in sync with schema.prisma
|
||||
-- Uses IF NOT EXISTS / DO $$ guards for idempotent execution
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. LiveVotingSession: Add criteria-based & audience voting columns
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE "LiveVotingSession" ADD COLUMN IF NOT EXISTS "votingMode" TEXT NOT NULL DEFAULT 'simple';
|
||||
ALTER TABLE "LiveVotingSession" ADD COLUMN IF NOT EXISTS "criteriaJson" JSONB;
|
||||
ALTER TABLE "LiveVotingSession" ADD COLUMN IF NOT EXISTS "audienceVotingMode" TEXT NOT NULL DEFAULT 'disabled';
|
||||
ALTER TABLE "LiveVotingSession" ADD COLUMN IF NOT EXISTS "audienceMaxFavorites" INTEGER NOT NULL DEFAULT 3;
|
||||
ALTER TABLE "LiveVotingSession" ADD COLUMN IF NOT EXISTS "audienceRequireId" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "LiveVotingSession" ADD COLUMN IF NOT EXISTS "audienceVotingDuration" INTEGER;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. LiveVote: Add criteria scores, audience voter link, make userId nullable
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE "LiveVote" ADD COLUMN IF NOT EXISTS "criterionScoresJson" JSONB;
|
||||
ALTER TABLE "LiveVote" ADD COLUMN IF NOT EXISTS "audienceVoterId" TEXT;
|
||||
|
||||
-- Make userId nullable (was NOT NULL in init migration)
|
||||
ALTER TABLE "LiveVote" ALTER COLUMN "userId" DROP NOT NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. AudienceVoter: New table for audience participation
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "AudienceVoter" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"identifier" TEXT,
|
||||
"identifierType" TEXT,
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AudienceVoter_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Unique constraint on token
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "AudienceVoter" ADD CONSTRAINT "AudienceVoter_token_key" UNIQUE ("token");
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS "AudienceVoter_sessionId_idx" ON "AudienceVoter"("sessionId");
|
||||
CREATE INDEX IF NOT EXISTS "AudienceVoter_token_idx" ON "AudienceVoter"("token");
|
||||
|
||||
-- Foreign key: AudienceVoter.sessionId -> LiveVotingSession.id
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "AudienceVoter" ADD CONSTRAINT "AudienceVoter_sessionId_fkey"
|
||||
FOREIGN KEY ("sessionId") REFERENCES "LiveVotingSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. LiveVote: Foreign key and indexes for audienceVoterId
|
||||
-- =============================================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "LiveVote_audienceVoterId_idx" ON "LiveVote"("audienceVoterId");
|
||||
|
||||
-- Foreign key: LiveVote.audienceVoterId -> AudienceVoter.id
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "LiveVote" ADD CONSTRAINT "LiveVote_audienceVoterId_fkey"
|
||||
FOREIGN KEY ("audienceVoterId") REFERENCES "AudienceVoter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- Unique constraint: sessionId + projectId + audienceVoterId
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "LiveVote" ADD CONSTRAINT "LiveVote_sessionId_projectId_audienceVoterId_key"
|
||||
UNIQUE ("sessionId", "projectId", "audienceVoterId");
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- SUMMARY:
|
||||
--
|
||||
-- LiveVotingSession new columns:
|
||||
-- - votingMode (TEXT, default 'simple')
|
||||
-- - criteriaJson (JSONB, nullable)
|
||||
-- - audienceVotingMode (TEXT, default 'disabled')
|
||||
-- - audienceMaxFavorites (INTEGER, default 3)
|
||||
-- - audienceRequireId (BOOLEAN, default false)
|
||||
-- - audienceVotingDuration (INTEGER, nullable)
|
||||
--
|
||||
-- LiveVote changes:
|
||||
-- - criterionScoresJson (JSONB, nullable) - new column
|
||||
-- - audienceVoterId (TEXT, nullable) - new column
|
||||
-- - userId changed from NOT NULL to nullable
|
||||
-- - New unique: (sessionId, projectId, audienceVoterId)
|
||||
-- - New index: audienceVoterId
|
||||
-- - New FK: audienceVoterId -> AudienceVoter(id)
|
||||
--
|
||||
-- New table: AudienceVoter
|
||||
-- - id, sessionId, token (unique), identifier, identifierType,
|
||||
-- ipAddress, userAgent, createdAt
|
||||
-- - FK: sessionId -> LiveVotingSession(id) CASCADE
|
||||
-- =============================================================================
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
|
|
|
|||
4200
prisma/schema.prisma
4200
prisma/schema.prisma
File diff suppressed because it is too large
Load Diff
2075
prisma/seed.ts
2075
prisma/seed.ts
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,291 +1,291 @@
|
|||
'use client'
|
||||
|
||||
import { use, useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
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 { Switch } from '@/components/ui/switch'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function EditAwardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: awardId } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId })
|
||||
const updateAward = trpc.specialAward.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.specialAward.get.invalidate({ id: awardId })
|
||||
utils.specialAward.list.invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [criteriaText, setCriteriaText] = useState('')
|
||||
const [scoringMode, setScoringMode] = useState<'PICK_WINNER' | 'RANKED' | 'SCORED'>('PICK_WINNER')
|
||||
const [useAiEligibility, setUseAiEligibility] = useState(true)
|
||||
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
|
||||
const [votingStartAt, setVotingStartAt] = useState('')
|
||||
const [votingEndAt, setVotingEndAt] = useState('')
|
||||
|
||||
// Helper to format date for datetime-local input
|
||||
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
// Format: YYYY-MM-DDTHH:mm
|
||||
return d.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
// Load existing values when award data arrives
|
||||
useEffect(() => {
|
||||
if (award) {
|
||||
setName(award.name)
|
||||
setDescription(award.description || '')
|
||||
setCriteriaText(award.criteriaText || '')
|
||||
setScoringMode(award.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED')
|
||||
setUseAiEligibility(award.useAiEligibility)
|
||||
setMaxRankedPicks(String(award.maxRankedPicks || 3))
|
||||
setVotingStartAt(formatDateForInput(award.votingStartAt))
|
||||
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
||||
}
|
||||
}, [award])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) return
|
||||
try {
|
||||
await updateAward.mutateAsync({
|
||||
id: awardId,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
criteriaText: criteriaText.trim() || undefined,
|
||||
useAiEligibility,
|
||||
scoringMode,
|
||||
maxRankedPicks: scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
|
||||
votingStartAt: votingStartAt ? new Date(votingStartAt) : undefined,
|
||||
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
||||
})
|
||||
toast.success('Award updated')
|
||||
router.push(`/admin/awards/${awardId}`)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to update award'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-[400px] w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!award) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/awards/${awardId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Award
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Edit Award
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Update award settings and eligibility criteria
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Award Details</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the award name, criteria, and scoring mode
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Award Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Mediterranean Entrepreneurship Award"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this award"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="criteria">Eligibility Criteria</Label>
|
||||
<Textarea
|
||||
id="criteria"
|
||||
value={criteriaText}
|
||||
onChange={(e) => setCriteriaText(e.target.value)}
|
||||
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This text will be used by AI to determine which projects are
|
||||
eligible for this award.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="ai-toggle">AI Eligibility</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use AI to automatically evaluate project eligibility based on the criteria above.
|
||||
Turn off for awards decided by feeling or manual selection.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="ai-toggle"
|
||||
checked={useAiEligibility}
|
||||
onCheckedChange={setUseAiEligibility}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoring">Scoring Mode</Label>
|
||||
<Select
|
||||
value={scoringMode}
|
||||
onValueChange={(v) =>
|
||||
setScoringMode(v as 'PICK_WINNER' | 'RANKED' | 'SCORED')
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="scoring">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PICK_WINNER">
|
||||
Pick Winner — Each juror picks 1
|
||||
</SelectItem>
|
||||
<SelectItem value="RANKED">
|
||||
Ranked — Each juror ranks top N
|
||||
</SelectItem>
|
||||
<SelectItem value="SCORED">
|
||||
Scored — Use evaluation form
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{scoringMode === 'RANKED' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
||||
<Input
|
||||
id="maxPicks"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={maxRankedPicks}
|
||||
onChange={(e) => setMaxRankedPicks(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Voting Window Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Voting Window</CardTitle>
|
||||
<CardDescription>
|
||||
Set the time period during which jurors can submit their votes
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="votingStart">Voting Opens</Label>
|
||||
<Input
|
||||
id="votingStart"
|
||||
type="datetime-local"
|
||||
value={votingStartAt}
|
||||
onChange={(e) => setVotingStartAt(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When jurors can start voting (leave empty to set when opening voting)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="votingEnd">Voting Closes</Label>
|
||||
<Input
|
||||
id="votingEnd"
|
||||
type="datetime-local"
|
||||
value={votingEndAt}
|
||||
onChange={(e) => setVotingEndAt(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deadline for juror votes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/awards/${awardId}`}>Cancel</Link>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={updateAward.isPending || !name.trim()}
|
||||
>
|
||||
{updateAward.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use, useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
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 { Switch } from '@/components/ui/switch'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function EditAwardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: awardId } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId })
|
||||
const updateAward = trpc.specialAward.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.specialAward.get.invalidate({ id: awardId })
|
||||
utils.specialAward.list.invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [criteriaText, setCriteriaText] = useState('')
|
||||
const [scoringMode, setScoringMode] = useState<'PICK_WINNER' | 'RANKED' | 'SCORED'>('PICK_WINNER')
|
||||
const [useAiEligibility, setUseAiEligibility] = useState(true)
|
||||
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
|
||||
const [votingStartAt, setVotingStartAt] = useState('')
|
||||
const [votingEndAt, setVotingEndAt] = useState('')
|
||||
|
||||
// Helper to format date for datetime-local input
|
||||
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
// Format: YYYY-MM-DDTHH:mm
|
||||
return d.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
// Load existing values when award data arrives
|
||||
useEffect(() => {
|
||||
if (award) {
|
||||
setName(award.name)
|
||||
setDescription(award.description || '')
|
||||
setCriteriaText(award.criteriaText || '')
|
||||
setScoringMode(award.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED')
|
||||
setUseAiEligibility(award.useAiEligibility)
|
||||
setMaxRankedPicks(String(award.maxRankedPicks || 3))
|
||||
setVotingStartAt(formatDateForInput(award.votingStartAt))
|
||||
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
||||
}
|
||||
}, [award])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) return
|
||||
try {
|
||||
await updateAward.mutateAsync({
|
||||
id: awardId,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
criteriaText: criteriaText.trim() || undefined,
|
||||
useAiEligibility,
|
||||
scoringMode,
|
||||
maxRankedPicks: scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
|
||||
votingStartAt: votingStartAt ? new Date(votingStartAt) : undefined,
|
||||
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
||||
})
|
||||
toast.success('Award updated')
|
||||
router.push(`/admin/awards/${awardId}`)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to update award'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-[400px] w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!award) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/awards/${awardId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Award
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Edit Award
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Update award settings and eligibility criteria
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Award Details</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the award name, criteria, and scoring mode
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Award Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Mediterranean Entrepreneurship Award"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this award"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="criteria">Eligibility Criteria</Label>
|
||||
<Textarea
|
||||
id="criteria"
|
||||
value={criteriaText}
|
||||
onChange={(e) => setCriteriaText(e.target.value)}
|
||||
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This text will be used by AI to determine which projects are
|
||||
eligible for this award.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="ai-toggle">AI Eligibility</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use AI to automatically evaluate project eligibility based on the criteria above.
|
||||
Turn off for awards decided by feeling or manual selection.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="ai-toggle"
|
||||
checked={useAiEligibility}
|
||||
onCheckedChange={setUseAiEligibility}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoring">Scoring Mode</Label>
|
||||
<Select
|
||||
value={scoringMode}
|
||||
onValueChange={(v) =>
|
||||
setScoringMode(v as 'PICK_WINNER' | 'RANKED' | 'SCORED')
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="scoring">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PICK_WINNER">
|
||||
Pick Winner — Each juror picks 1
|
||||
</SelectItem>
|
||||
<SelectItem value="RANKED">
|
||||
Ranked — Each juror ranks top N
|
||||
</SelectItem>
|
||||
<SelectItem value="SCORED">
|
||||
Scored — Use evaluation form
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{scoringMode === 'RANKED' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
||||
<Input
|
||||
id="maxPicks"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={maxRankedPicks}
|
||||
onChange={(e) => setMaxRankedPicks(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Voting Window Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Voting Window</CardTitle>
|
||||
<CardDescription>
|
||||
Set the time period during which jurors can submit their votes
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="votingStart">Voting Opens</Label>
|
||||
<Input
|
||||
id="votingStart"
|
||||
type="datetime-local"
|
||||
value={votingStartAt}
|
||||
onChange={(e) => setVotingStartAt(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When jurors can start voting (leave empty to set when opening voting)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="votingEnd">Voting Closes</Label>
|
||||
<Input
|
||||
id="votingEnd"
|
||||
type="datetime-local"
|
||||
value={votingEndAt}
|
||||
onChange={(e) => setVotingEndAt(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deadline for juror votes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/awards/${awardId}`}>Cancel</Link>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={updateAward.isPending || !name.trim()}
|
||||
>
|
||||
{updateAward.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,227 +1,227 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
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 { Switch } from '@/components/ui/switch'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function CreateAwardPage() {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [criteriaText, setCriteriaText] = useState('')
|
||||
const [scoringMode, setScoringMode] = useState<
|
||||
'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||
>('PICK_WINNER')
|
||||
const [useAiEligibility, setUseAiEligibility] = useState(true)
|
||||
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
|
||||
const [programId, setProgramId] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data: programs } = trpc.program.list.useQuery()
|
||||
const createAward = trpc.specialAward.create.useMutation({
|
||||
onSuccess: () => utils.specialAward.list.invalidate(),
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim() || !programId) return
|
||||
try {
|
||||
const award = await createAward.mutateAsync({
|
||||
programId,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
criteriaText: criteriaText.trim() || undefined,
|
||||
useAiEligibility,
|
||||
scoringMode,
|
||||
maxRankedPicks:
|
||||
scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
|
||||
})
|
||||
toast.success('Award created')
|
||||
router.push(`/admin/awards/${award.id}`)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to create award'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/awards">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Awards
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Create Special Award
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Define a new award with eligibility criteria and voting rules
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Award Details</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the award name, criteria, and scoring mode
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="program">Edition</Label>
|
||||
<Select value={programId} onValueChange={setProgramId}>
|
||||
<SelectTrigger id="program">
|
||||
<SelectValue placeholder="Select an edition" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.year} Edition
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Award Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Mediterranean Entrepreneurship Award"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this award"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="criteria">Eligibility Criteria</Label>
|
||||
<Textarea
|
||||
id="criteria"
|
||||
value={criteriaText}
|
||||
onChange={(e) => setCriteriaText(e.target.value)}
|
||||
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This text will be used by AI to determine which projects are
|
||||
eligible for this award.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="ai-toggle">AI Eligibility</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use AI to automatically evaluate project eligibility based on the criteria above.
|
||||
Turn off for awards decided by feeling or manual selection.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="ai-toggle"
|
||||
checked={useAiEligibility}
|
||||
onCheckedChange={setUseAiEligibility}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoring">Scoring Mode</Label>
|
||||
<Select
|
||||
value={scoringMode}
|
||||
onValueChange={(v) =>
|
||||
setScoringMode(
|
||||
v as 'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="scoring">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PICK_WINNER">
|
||||
Pick Winner — Each juror picks 1
|
||||
</SelectItem>
|
||||
<SelectItem value="RANKED">
|
||||
Ranked — Each juror ranks top N
|
||||
</SelectItem>
|
||||
<SelectItem value="SCORED">
|
||||
Scored — Use evaluation form
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{scoringMode === 'RANKED' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
||||
<Input
|
||||
id="maxPicks"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={maxRankedPicks}
|
||||
onChange={(e) => setMaxRankedPicks(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/awards">Cancel</Link>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={createAward.isPending || !name.trim() || !programId}
|
||||
>
|
||||
{createAward.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Create Award
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
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 { Switch } from '@/components/ui/switch'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function CreateAwardPage() {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [criteriaText, setCriteriaText] = useState('')
|
||||
const [scoringMode, setScoringMode] = useState<
|
||||
'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||
>('PICK_WINNER')
|
||||
const [useAiEligibility, setUseAiEligibility] = useState(true)
|
||||
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
|
||||
const [programId, setProgramId] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data: programs } = trpc.program.list.useQuery()
|
||||
const createAward = trpc.specialAward.create.useMutation({
|
||||
onSuccess: () => utils.specialAward.list.invalidate(),
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim() || !programId) return
|
||||
try {
|
||||
const award = await createAward.mutateAsync({
|
||||
programId,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
criteriaText: criteriaText.trim() || undefined,
|
||||
useAiEligibility,
|
||||
scoringMode,
|
||||
maxRankedPicks:
|
||||
scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
|
||||
})
|
||||
toast.success('Award created')
|
||||
router.push(`/admin/awards/${award.id}`)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to create award'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/awards">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Awards
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Create Special Award
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Define a new award with eligibility criteria and voting rules
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Award Details</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the award name, criteria, and scoring mode
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="program">Edition</Label>
|
||||
<Select value={programId} onValueChange={setProgramId}>
|
||||
<SelectTrigger id="program">
|
||||
<SelectValue placeholder="Select an edition" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.year} Edition
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Award Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Mediterranean Entrepreneurship Award"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this award"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="criteria">Eligibility Criteria</Label>
|
||||
<Textarea
|
||||
id="criteria"
|
||||
value={criteriaText}
|
||||
onChange={(e) => setCriteriaText(e.target.value)}
|
||||
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This text will be used by AI to determine which projects are
|
||||
eligible for this award.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="ai-toggle">AI Eligibility</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use AI to automatically evaluate project eligibility based on the criteria above.
|
||||
Turn off for awards decided by feeling or manual selection.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="ai-toggle"
|
||||
checked={useAiEligibility}
|
||||
onCheckedChange={setUseAiEligibility}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoring">Scoring Mode</Label>
|
||||
<Select
|
||||
value={scoringMode}
|
||||
onValueChange={(v) =>
|
||||
setScoringMode(
|
||||
v as 'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="scoring">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PICK_WINNER">
|
||||
Pick Winner — Each juror picks 1
|
||||
</SelectItem>
|
||||
<SelectItem value="RANKED">
|
||||
Ranked — Each juror ranks top N
|
||||
</SelectItem>
|
||||
<SelectItem value="SCORED">
|
||||
Scored — Use evaluation form
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{scoringMode === 'RANKED' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
||||
<Input
|
||||
id="maxPicks"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={maxRankedPicks}
|
||||
onChange={(e) => setMaxRankedPicks(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/awards">Cancel</Link>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={createAward.isPending || !name.trim() || !programId}
|
||||
>
|
||||
{createAward.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Create Award
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,238 +1,238 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
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 { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Trophy, Users, CheckCircle2, Search } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
DRAFT: 'secondary',
|
||||
NOMINATIONS_OPEN: 'default',
|
||||
VOTING_OPEN: 'default',
|
||||
CLOSED: 'outline',
|
||||
ARCHIVED: 'secondary',
|
||||
}
|
||||
|
||||
const SCORING_LABELS: Record<string, string> = {
|
||||
PICK_WINNER: 'Pick Winner',
|
||||
RANKED: 'Ranked',
|
||||
SCORED: 'Scored',
|
||||
}
|
||||
|
||||
export default function AwardsListPage() {
|
||||
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({})
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [scoringFilter, setScoringFilter] = useState('all')
|
||||
|
||||
const filteredAwards = useMemo(() => {
|
||||
if (!awards) return []
|
||||
return awards.filter((award) => {
|
||||
const matchesSearch =
|
||||
!debouncedSearch ||
|
||||
award.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
award.description?.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
const matchesStatus = statusFilter === 'all' || award.status === statusFilter
|
||||
const matchesScoring = scoringFilter === 'all' || award.scoringMode === scoringFilter
|
||||
return matchesSearch && matchesStatus && matchesScoring
|
||||
})
|
||||
}, [awards, debouncedSearch, statusFilter, scoringFilter])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Skeleton className="h-10 flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 w-[180px]" />
|
||||
<Skeleton className="h-10 w-[160px]" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Cards skeleton */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-48 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Special Awards
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage named awards with eligibility criteria and jury voting
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/admin/awards/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Award
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search awards..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="DRAFT">Draft</SelectItem>
|
||||
<SelectItem value="NOMINATIONS_OPEN">Nominations Open</SelectItem>
|
||||
<SelectItem value="VOTING_OPEN">Voting Open</SelectItem>
|
||||
<SelectItem value="CLOSED">Closed</SelectItem>
|
||||
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={scoringFilter} onValueChange={setScoringFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All scoring" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All scoring</SelectItem>
|
||||
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
|
||||
<SelectItem value="RANKED">Ranked</SelectItem>
|
||||
<SelectItem value="SCORED">Scored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
{awards && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredAwards.length} of {awards.length} awards
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Awards Grid */}
|
||||
{filteredAwards.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredAwards.map((award, index) => (
|
||||
<AnimatedCard key={award.id} index={index}>
|
||||
<Link href={`/admin/awards/${award.id}`}>
|
||||
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md cursor-pointer h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-amber-500" />
|
||||
{award.name}
|
||||
</CardTitle>
|
||||
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
||||
{award.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{award.description && (
|
||||
<CardDescription className="line-clamp-2">
|
||||
{award.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{award._count.eligibilities} eligible
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{award._count.jurors} jurors
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{SCORING_LABELS[award.scoringMode] || award.scoringMode}
|
||||
</Badge>
|
||||
</div>
|
||||
{award.winnerProject && (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">Winner:</span>{' '}
|
||||
<span className="font-medium">
|
||||
{award.winnerProject.title}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</AnimatedCard>
|
||||
))}
|
||||
</div>
|
||||
) : awards && awards.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No awards match your filters
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/40" />
|
||||
<h3 className="mt-3 text-lg font-medium">No awards yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||
Create special awards with eligibility criteria and jury voting for outstanding projects.
|
||||
</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/admin/awards/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Award
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
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 { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Trophy, Users, CheckCircle2, Search } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
DRAFT: 'secondary',
|
||||
NOMINATIONS_OPEN: 'default',
|
||||
VOTING_OPEN: 'default',
|
||||
CLOSED: 'outline',
|
||||
ARCHIVED: 'secondary',
|
||||
}
|
||||
|
||||
const SCORING_LABELS: Record<string, string> = {
|
||||
PICK_WINNER: 'Pick Winner',
|
||||
RANKED: 'Ranked',
|
||||
SCORED: 'Scored',
|
||||
}
|
||||
|
||||
export default function AwardsListPage() {
|
||||
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({})
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [scoringFilter, setScoringFilter] = useState('all')
|
||||
|
||||
const filteredAwards = useMemo(() => {
|
||||
if (!awards) return []
|
||||
return awards.filter((award) => {
|
||||
const matchesSearch =
|
||||
!debouncedSearch ||
|
||||
award.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
award.description?.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
const matchesStatus = statusFilter === 'all' || award.status === statusFilter
|
||||
const matchesScoring = scoringFilter === 'all' || award.scoringMode === scoringFilter
|
||||
return matchesSearch && matchesStatus && matchesScoring
|
||||
})
|
||||
}, [awards, debouncedSearch, statusFilter, scoringFilter])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Skeleton className="h-10 flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 w-[180px]" />
|
||||
<Skeleton className="h-10 w-[160px]" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Cards skeleton */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-48 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Special Awards
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage named awards with eligibility criteria and jury voting
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/admin/awards/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Award
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search awards..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="DRAFT">Draft</SelectItem>
|
||||
<SelectItem value="NOMINATIONS_OPEN">Nominations Open</SelectItem>
|
||||
<SelectItem value="VOTING_OPEN">Voting Open</SelectItem>
|
||||
<SelectItem value="CLOSED">Closed</SelectItem>
|
||||
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={scoringFilter} onValueChange={setScoringFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All scoring" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All scoring</SelectItem>
|
||||
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
|
||||
<SelectItem value="RANKED">Ranked</SelectItem>
|
||||
<SelectItem value="SCORED">Scored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
{awards && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredAwards.length} of {awards.length} awards
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Awards Grid */}
|
||||
{filteredAwards.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredAwards.map((award, index) => (
|
||||
<AnimatedCard key={award.id} index={index}>
|
||||
<Link href={`/admin/awards/${award.id}`}>
|
||||
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md cursor-pointer h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-amber-500" />
|
||||
{award.name}
|
||||
</CardTitle>
|
||||
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
||||
{award.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{award.description && (
|
||||
<CardDescription className="line-clamp-2">
|
||||
{award.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{award._count.eligibilities} eligible
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{award._count.jurors} jurors
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{SCORING_LABELS[award.scoringMode] || award.scoringMode}
|
||||
</Badge>
|
||||
</div>
|
||||
{award.winnerProject && (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">Winner:</span>{' '}
|
||||
<span className="font-medium">
|
||||
{award.winnerProject.title}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</AnimatedCard>
|
||||
))}
|
||||
</div>
|
||||
) : awards && awards.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No awards match your filters
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/40" />
|
||||
<h3 className="mt-3 text-lg font-medium">No awards yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||
Create special awards with eligibility criteria and jury voting for outstanding projects.
|
||||
</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/admin/awards/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Award
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,481 +1,481 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Loader2,
|
||||
FileText,
|
||||
Video,
|
||||
Link as LinkIcon,
|
||||
File,
|
||||
Trash2,
|
||||
Eye,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Dynamically import BlockEditor to avoid SSR issues
|
||||
const BlockEditor = dynamic(
|
||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
const resourceTypeOptions = [
|
||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
||||
{ value: 'OTHER', label: 'Other', icon: File },
|
||||
]
|
||||
|
||||
const cohortOptions = [
|
||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
||||
]
|
||||
|
||||
export default function EditLearningResourcePage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const resourceId = params.id as string
|
||||
|
||||
// Fetch resource
|
||||
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||
const { data: stats } = trpc.learningResource.getStats.useQuery({ id: resourceId })
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [contentJson, setContentJson] = useState<string>('')
|
||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
||||
const [externalUrl, setExternalUrl] = useState('')
|
||||
const [isPublished, setIsPublished] = useState(false)
|
||||
const [programId, setProgramId] = useState<string | null>(null)
|
||||
|
||||
// API
|
||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||
const utils = trpc.useUtils()
|
||||
const updateResource = trpc.learningResource.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.learningResource.get.invalidate({ id: resourceId })
|
||||
utils.learningResource.list.invalidate()
|
||||
},
|
||||
})
|
||||
const deleteResource = trpc.learningResource.delete.useMutation({
|
||||
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||
})
|
||||
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
|
||||
|
||||
// Populate form when resource loads
|
||||
useEffect(() => {
|
||||
if (resource) {
|
||||
setTitle(resource.title)
|
||||
setDescription(resource.description || '')
|
||||
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
|
||||
setResourceType(resource.resourceType)
|
||||
setCohortLevel(resource.cohortLevel)
|
||||
setExternalUrl(resource.externalUrl || '')
|
||||
setIsPublished(resource.isPublished)
|
||||
setProgramId(resource.programId)
|
||||
}
|
||||
}, [resource])
|
||||
|
||||
// Handle file upload for BlockNote
|
||||
const handleUploadFile = async (file: File): Promise<string> => {
|
||||
try {
|
||||
const { url, bucket, objectKey } = await getUploadUrl.mutateAsync({
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
})
|
||||
|
||||
await fetch(url, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
})
|
||||
|
||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload file')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
toast.error('Please enter a title')
|
||||
return
|
||||
}
|
||||
|
||||
if (resourceType === 'LINK' && !externalUrl) {
|
||||
toast.error('Please enter an external URL')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateResource.mutateAsync({
|
||||
id: resourceId,
|
||||
title,
|
||||
description: description || undefined,
|
||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
||||
externalUrl: externalUrl || null,
|
||||
isPublished,
|
||||
})
|
||||
|
||||
toast.success('Resource updated successfully')
|
||||
router.push('/admin/learning')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteResource.mutateAsync({ id: resourceId })
|
||||
toast.success('Resource deleted successfully')
|
||||
router.push('/admin/learning')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !resource) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Resource not found</AlertTitle>
|
||||
<AlertDescription>
|
||||
The resource you're looking for does not exist.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/admin/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Update this learning resource
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<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">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resource Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about this resource
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Ocean Conservation Best Practices"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Short Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this resource"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Resource Type</Label>
|
||||
<Select value={resourceType} onValueChange={setResourceType}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{resourceTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<option.icon className="h-4 w-4" />
|
||||
{option.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cohort">Access Level</Label>
|
||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
||||
<SelectTrigger id="cohort">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cohortOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resourceType === 'LINK' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url">External URL *</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="url"
|
||||
value={externalUrl}
|
||||
onChange={(e) => setExternalUrl(e.target.value)}
|
||||
placeholder="https://example.com/resource"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
<Switch
|
||||
id="published"
|
||||
checked={isPublished}
|
||||
onCheckedChange={setIsPublished}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<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 */}
|
||||
{stats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Eye className="h-5 w-5" />
|
||||
Statistics
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
||||
<p className="text-sm text-muted-foreground">Total views</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
||||
<p className="text-sm text-muted-foreground">Unique users</p>
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Loader2,
|
||||
FileText,
|
||||
Video,
|
||||
Link as LinkIcon,
|
||||
File,
|
||||
Trash2,
|
||||
Eye,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Dynamically import BlockEditor to avoid SSR issues
|
||||
const BlockEditor = dynamic(
|
||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
const resourceTypeOptions = [
|
||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
||||
{ value: 'OTHER', label: 'Other', icon: File },
|
||||
]
|
||||
|
||||
const cohortOptions = [
|
||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
||||
]
|
||||
|
||||
export default function EditLearningResourcePage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const resourceId = params.id as string
|
||||
|
||||
// Fetch resource
|
||||
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||
const { data: stats } = trpc.learningResource.getStats.useQuery({ id: resourceId })
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [contentJson, setContentJson] = useState<string>('')
|
||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
||||
const [externalUrl, setExternalUrl] = useState('')
|
||||
const [isPublished, setIsPublished] = useState(false)
|
||||
const [programId, setProgramId] = useState<string | null>(null)
|
||||
|
||||
// API
|
||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||
const utils = trpc.useUtils()
|
||||
const updateResource = trpc.learningResource.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.learningResource.get.invalidate({ id: resourceId })
|
||||
utils.learningResource.list.invalidate()
|
||||
},
|
||||
})
|
||||
const deleteResource = trpc.learningResource.delete.useMutation({
|
||||
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||
})
|
||||
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
|
||||
|
||||
// Populate form when resource loads
|
||||
useEffect(() => {
|
||||
if (resource) {
|
||||
setTitle(resource.title)
|
||||
setDescription(resource.description || '')
|
||||
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
|
||||
setResourceType(resource.resourceType)
|
||||
setCohortLevel(resource.cohortLevel)
|
||||
setExternalUrl(resource.externalUrl || '')
|
||||
setIsPublished(resource.isPublished)
|
||||
setProgramId(resource.programId)
|
||||
}
|
||||
}, [resource])
|
||||
|
||||
// Handle file upload for BlockNote
|
||||
const handleUploadFile = async (file: File): Promise<string> => {
|
||||
try {
|
||||
const { url, bucket, objectKey } = await getUploadUrl.mutateAsync({
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
})
|
||||
|
||||
await fetch(url, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
})
|
||||
|
||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload file')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
toast.error('Please enter a title')
|
||||
return
|
||||
}
|
||||
|
||||
if (resourceType === 'LINK' && !externalUrl) {
|
||||
toast.error('Please enter an external URL')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateResource.mutateAsync({
|
||||
id: resourceId,
|
||||
title,
|
||||
description: description || undefined,
|
||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
||||
externalUrl: externalUrl || null,
|
||||
isPublished,
|
||||
})
|
||||
|
||||
toast.success('Resource updated successfully')
|
||||
router.push('/admin/learning')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteResource.mutateAsync({ id: resourceId })
|
||||
toast.success('Resource deleted successfully')
|
||||
router.push('/admin/learning')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !resource) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Resource not found</AlertTitle>
|
||||
<AlertDescription>
|
||||
The resource you're looking for does not exist.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/admin/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Update this learning resource
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<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">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resource Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about this resource
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Ocean Conservation Best Practices"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Short Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this resource"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Resource Type</Label>
|
||||
<Select value={resourceType} onValueChange={setResourceType}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{resourceTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<option.icon className="h-4 w-4" />
|
||||
{option.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cohort">Access Level</Label>
|
||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
||||
<SelectTrigger id="cohort">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cohortOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resourceType === 'LINK' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url">External URL *</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="url"
|
||||
value={externalUrl}
|
||||
onChange={(e) => setExternalUrl(e.target.value)}
|
||||
placeholder="https://example.com/resource"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
<Switch
|
||||
id="published"
|
||||
checked={isPublished}
|
||||
onCheckedChange={setIsPublished}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<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 */}
|
||||
{stats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Eye className="h-5 w-5" />
|
||||
Statistics
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
||||
<p className="text-sm text-muted-foreground">Total views</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
||||
<p className="text-sm text-muted-foreground">Unique users</p>
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,327 +1,327 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeft, Save, Loader2, FileText, Video, Link as LinkIcon, File } from 'lucide-react'
|
||||
|
||||
// Dynamically import BlockEditor to avoid SSR issues
|
||||
const BlockEditor = dynamic(
|
||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
const resourceTypeOptions = [
|
||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
||||
{ value: 'OTHER', label: 'Other', icon: File },
|
||||
]
|
||||
|
||||
const cohortOptions = [
|
||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
||||
]
|
||||
|
||||
export default function NewLearningResourcePage() {
|
||||
const router = useRouter()
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [contentJson, setContentJson] = useState<string>('')
|
||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
||||
const [externalUrl, setExternalUrl] = useState('')
|
||||
const [isPublished, setIsPublished] = useState(false)
|
||||
|
||||
// API
|
||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||
const [programId, setProgramId] = useState<string | null>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const createResource = trpc.learningResource.create.useMutation({
|
||||
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||
})
|
||||
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
|
||||
|
||||
// Handle file upload for BlockNote
|
||||
const handleUploadFile = async (file: File): Promise<string> => {
|
||||
try {
|
||||
const { url, bucket, objectKey } = await getUploadUrl.mutateAsync({
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
})
|
||||
|
||||
// Upload to MinIO
|
||||
await fetch(url, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
})
|
||||
|
||||
// Return the MinIO URL
|
||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload file')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
toast.error('Please enter a title')
|
||||
return
|
||||
}
|
||||
|
||||
if (resourceType === 'LINK' && !externalUrl) {
|
||||
toast.error('Please enter an external URL')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createResource.mutateAsync({
|
||||
programId,
|
||||
title,
|
||||
description: description || undefined,
|
||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
||||
externalUrl: externalUrl || undefined,
|
||||
isPublished,
|
||||
})
|
||||
|
||||
toast.success('Resource created successfully')
|
||||
router.push('/admin/learning')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create a new learning resource for jury members
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resource Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about this resource
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Ocean Conservation Best Practices"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Short Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this resource"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Resource Type</Label>
|
||||
<Select value={resourceType} onValueChange={setResourceType}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{resourceTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<option.icon className="h-4 w-4" />
|
||||
{option.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cohort">Access Level</Label>
|
||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
||||
<SelectTrigger id="cohort">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cohortOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resourceType === 'LINK' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url">External URL *</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="url"
|
||||
value={externalUrl}
|
||||
onChange={(e) => setExternalUrl(e.target.value)}
|
||||
placeholder="https://example.com/resource"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
<Switch
|
||||
id="published"
|
||||
checked={isPublished}
|
||||
onCheckedChange={setIsPublished}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeft, Save, Loader2, FileText, Video, Link as LinkIcon, File } from 'lucide-react'
|
||||
|
||||
// Dynamically import BlockEditor to avoid SSR issues
|
||||
const BlockEditor = dynamic(
|
||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
const resourceTypeOptions = [
|
||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
||||
{ value: 'OTHER', label: 'Other', icon: File },
|
||||
]
|
||||
|
||||
const cohortOptions = [
|
||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
||||
]
|
||||
|
||||
export default function NewLearningResourcePage() {
|
||||
const router = useRouter()
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [contentJson, setContentJson] = useState<string>('')
|
||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
||||
const [externalUrl, setExternalUrl] = useState('')
|
||||
const [isPublished, setIsPublished] = useState(false)
|
||||
|
||||
// API
|
||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||
const [programId, setProgramId] = useState<string | null>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const createResource = trpc.learningResource.create.useMutation({
|
||||
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||
})
|
||||
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
|
||||
|
||||
// Handle file upload for BlockNote
|
||||
const handleUploadFile = async (file: File): Promise<string> => {
|
||||
try {
|
||||
const { url, bucket, objectKey } = await getUploadUrl.mutateAsync({
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
})
|
||||
|
||||
// Upload to MinIO
|
||||
await fetch(url, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
})
|
||||
|
||||
// Return the MinIO URL
|
||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload file')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
toast.error('Please enter a title')
|
||||
return
|
||||
}
|
||||
|
||||
if (resourceType === 'LINK' && !externalUrl) {
|
||||
toast.error('Please enter an external URL')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createResource.mutateAsync({
|
||||
programId,
|
||||
title,
|
||||
description: description || undefined,
|
||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
||||
externalUrl: externalUrl || undefined,
|
||||
isPublished,
|
||||
})
|
||||
|
||||
toast.success('Resource created successfully')
|
||||
router.push('/admin/learning')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create a new learning resource for jury members
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resource Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about this resource
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Ocean Conservation Best Practices"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Short Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this resource"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Resource Type</Label>
|
||||
<Select value={resourceType} onValueChange={setResourceType}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{resourceTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<option.icon className="h-4 w-4" />
|
||||
{option.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cohort">Access Level</Label>
|
||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
||||
<SelectTrigger id="cohort">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cohortOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resourceType === 'LINK' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url">External URL *</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="url"
|
||||
value={externalUrl}
|
||||
onChange={(e) => setExternalUrl(e.target.value)}
|
||||
placeholder="https://example.com/resource"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
<Switch
|
||||
id="published"
|
||||
checked={isPublished}
|
||||
onCheckedChange={setIsPublished}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,247 +1,247 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Plus,
|
||||
FileText,
|
||||
Video,
|
||||
Link as LinkIcon,
|
||||
File,
|
||||
Pencil,
|
||||
ExternalLink,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
const resourceTypeIcons = {
|
||||
PDF: FileText,
|
||||
VIDEO: Video,
|
||||
DOCUMENT: File,
|
||||
LINK: LinkIcon,
|
||||
OTHER: File,
|
||||
}
|
||||
|
||||
const cohortColors: Record<string, string> = {
|
||||
ALL: 'bg-gray-100 text-gray-800',
|
||||
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
||||
FINALIST: 'bg-purple-100 text-purple-800',
|
||||
}
|
||||
|
||||
export default function LearningHubPage() {
|
||||
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 50 })
|
||||
const resources = data?.data
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [cohortFilter, setCohortFilter] = useState('all')
|
||||
|
||||
const filteredResources = useMemo(() => {
|
||||
if (!resources) return []
|
||||
return resources.filter((resource) => {
|
||||
const matchesSearch =
|
||||
!debouncedSearch ||
|
||||
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
const matchesType = typeFilter === 'all' || resource.resourceType === typeFilter
|
||||
const matchesCohort = cohortFilter === 'all' || resource.cohortLevel === cohortFilter
|
||||
return matchesSearch && matchesType && matchesCohort
|
||||
})
|
||||
}, [resources, debouncedSearch, typeFilter, cohortFilter])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<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]" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Resource list skeleton */}
|
||||
<div className="grid gap-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Learning Hub</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage educational resources for jury members
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/learning/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Resource
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search resources..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="PDF">PDF</SelectItem>
|
||||
<SelectItem value="VIDEO">Video</SelectItem>
|
||||
<SelectItem value="DOCUMENT">Document</SelectItem>
|
||||
<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>
|
||||
|
||||
{/* Results count */}
|
||||
{resources && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredResources.length} of {resources.length} resources
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Resource List */}
|
||||
{filteredResources.length > 0 ? (
|
||||
<div className="grid gap-4">
|
||||
{filteredResources.map((resource) => {
|
||||
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
|
||||
return (
|
||||
<Card key={resource.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<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">Draft</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge className={cohortColors[resource.cohortLevel] || ''} variant="outline">
|
||||
{resource.cohortLevel}
|
||||
</Badge>
|
||||
<span>{resource.resourceType}</span>
|
||||
<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>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No resources match your filters
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/40" />
|
||||
<h3 className="mt-3 text-lg font-medium">No resources yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||
Add learning materials like videos, documents, and links for program participants.
|
||||
</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/admin/learning/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Resource
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Plus,
|
||||
FileText,
|
||||
Video,
|
||||
Link as LinkIcon,
|
||||
File,
|
||||
Pencil,
|
||||
ExternalLink,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
const resourceTypeIcons = {
|
||||
PDF: FileText,
|
||||
VIDEO: Video,
|
||||
DOCUMENT: File,
|
||||
LINK: LinkIcon,
|
||||
OTHER: File,
|
||||
}
|
||||
|
||||
const cohortColors: Record<string, string> = {
|
||||
ALL: 'bg-gray-100 text-gray-800',
|
||||
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
||||
FINALIST: 'bg-purple-100 text-purple-800',
|
||||
}
|
||||
|
||||
export default function LearningHubPage() {
|
||||
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 50 })
|
||||
const resources = data?.data
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [cohortFilter, setCohortFilter] = useState('all')
|
||||
|
||||
const filteredResources = useMemo(() => {
|
||||
if (!resources) return []
|
||||
return resources.filter((resource) => {
|
||||
const matchesSearch =
|
||||
!debouncedSearch ||
|
||||
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
const matchesType = typeFilter === 'all' || resource.resourceType === typeFilter
|
||||
const matchesCohort = cohortFilter === 'all' || resource.cohortLevel === cohortFilter
|
||||
return matchesSearch && matchesType && matchesCohort
|
||||
})
|
||||
}, [resources, debouncedSearch, typeFilter, cohortFilter])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<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]" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Resource list skeleton */}
|
||||
<div className="grid gap-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Learning Hub</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage educational resources for jury members
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/learning/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Resource
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search resources..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="PDF">PDF</SelectItem>
|
||||
<SelectItem value="VIDEO">Video</SelectItem>
|
||||
<SelectItem value="DOCUMENT">Document</SelectItem>
|
||||
<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>
|
||||
|
||||
{/* Results count */}
|
||||
{resources && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredResources.length} of {resources.length} resources
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Resource List */}
|
||||
{filteredResources.length > 0 ? (
|
||||
<div className="grid gap-4">
|
||||
{filteredResources.map((resource) => {
|
||||
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
|
||||
return (
|
||||
<Card key={resource.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<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">Draft</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge className={cohortColors[resource.cohortLevel] || ''} variant="outline">
|
||||
{resource.cohortLevel}
|
||||
</Badge>
|
||||
<span>{resource.resourceType}</span>
|
||||
<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>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No resources match your filters
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/40" />
|
||||
<h3 className="mt-3 text-lg font-medium">No resources yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||
Add learning materials like videos, documents, and links for program participants.
|
||||
</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/admin/learning/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Resource
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,6 @@ export default function MemberDetailPage() {
|
|||
)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState<string>('JURY_MEMBER')
|
||||
const [status, setStatus] = useState<string>('NONE')
|
||||
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
||||
|
|
@ -84,7 +83,6 @@ export default function MemberDetailPage() {
|
|||
useEffect(() => {
|
||||
if (user) {
|
||||
setName(user.name || '')
|
||||
setEmail(user.email || '')
|
||||
setRole(user.role)
|
||||
setStatus(user.status)
|
||||
setExpertiseTags(user.expertiseTags || [])
|
||||
|
|
@ -96,7 +94,6 @@ export default function MemberDetailPage() {
|
|||
try {
|
||||
await updateUser.mutateAsync({
|
||||
id: userId,
|
||||
email: email || undefined,
|
||||
name: name || null,
|
||||
role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN',
|
||||
status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED',
|
||||
|
|
@ -215,12 +212,7 @@ export default function MemberDetailPage() {
|
|||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input id="email" value={user.email} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,472 +1,472 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
LayoutTemplate,
|
||||
Eye,
|
||||
Variable,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const AVAILABLE_VARIABLES = [
|
||||
{ name: '{{projectName}}', desc: 'Project title' },
|
||||
{ name: '{{userName}}', desc: "Recipient's name" },
|
||||
{ name: '{{deadline}}', desc: 'Deadline date' },
|
||||
{ name: '{{roundName}}', desc: 'Round name' },
|
||||
{ name: '{{programName}}', desc: 'Program name' },
|
||||
]
|
||||
|
||||
interface TemplateFormData {
|
||||
name: string
|
||||
category: string
|
||||
subject: string
|
||||
body: string
|
||||
variables: string[]
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const defaultForm: TemplateFormData = {
|
||||
name: '',
|
||||
category: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
variables: [],
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
export default function MessageTemplatesPage() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState<TemplateFormData>(defaultForm)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: templates, isLoading } = trpc.message.listTemplates.useQuery()
|
||||
|
||||
const createMutation = trpc.message.createTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.message.listTemplates.invalidate()
|
||||
toast.success('Template created')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const updateMutation = trpc.message.updateTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.message.listTemplates.invalidate()
|
||||
toast.success('Template updated')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const deleteMutation = trpc.message.deleteTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.message.listTemplates.invalidate()
|
||||
toast.success('Template deleted')
|
||||
setDeleteId(null)
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogOpen(false)
|
||||
setEditingId(null)
|
||||
setFormData(defaultForm)
|
||||
setShowPreview(false)
|
||||
}
|
||||
|
||||
const openEdit = (template: Record<string, unknown>) => {
|
||||
setEditingId(String(template.id))
|
||||
setFormData({
|
||||
name: String(template.name || ''),
|
||||
category: String(template.category || ''),
|
||||
subject: String(template.subject || ''),
|
||||
body: String(template.body || ''),
|
||||
variables: Array.isArray(template.variables) ? template.variables.map(String) : [],
|
||||
isActive: template.isActive !== false,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const insertVariable = (variable: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
body: prev.body + variable,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.name.trim() || !formData.subject.trim()) {
|
||||
toast.error('Name and subject are required')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: formData.name.trim(),
|
||||
category: formData.category.trim() || 'General',
|
||||
subject: formData.subject.trim(),
|
||||
body: formData.body.trim(),
|
||||
variables: formData.variables.length > 0 ? formData.variables : undefined,
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, ...payload, isActive: formData.isActive })
|
||||
} else {
|
||||
createMutation.mutate(payload)
|
||||
}
|
||||
}
|
||||
|
||||
const getPreviewText = (text: string): string => {
|
||||
return text
|
||||
.replace(/\{\{userName\}\}/g, 'John Doe')
|
||||
.replace(/\{\{projectName\}\}/g, 'Ocean Cleanup Initiative')
|
||||
.replace(/\{\{roundName\}\}/g, 'Round 1 - Semi-Finals')
|
||||
.replace(/\{\{programName\}\}/g, 'MOPC 2026')
|
||||
.replace(/\{\{deadline\}\}/g, 'March 15, 2026')
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/messages">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Messages
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Message Templates</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create and manage reusable message templates
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => { setFormData(defaultForm); setEditingId(null); setDialogOpen(true) }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Template
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? 'Edit Template' : 'Create Template'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define a reusable message template with variable placeholders.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Template Name</Label>
|
||||
<Input
|
||||
placeholder="e.g., Evaluation Reminder"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Input
|
||||
placeholder="e.g., Notification, Reminder"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Subject</Label>
|
||||
<Input
|
||||
placeholder="e.g., Reminder: {{roundName}} evaluation deadline"
|
||||
value={formData.subject}
|
||||
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Message Body</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
{showPreview ? 'Edit' : 'Preview'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showPreview ? (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Subject: {getPreviewText(formData.subject)}
|
||||
</p>
|
||||
<div className="text-sm whitespace-pre-wrap border-t pt-2">
|
||||
{getPreviewText(formData.body) || 'No content yet'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder="Write your template message..."
|
||||
value={formData.body}
|
||||
onChange={(e) => setFormData({ ...formData, body: e.target.value })}
|
||||
rows={8}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variable buttons */}
|
||||
{!showPreview && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-1">
|
||||
<Variable className="h-3 w-3" />
|
||||
Insert Variable
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{AVAILABLE_VARIABLES.map((v) => (
|
||||
<Button
|
||||
key={v.name}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => insertVariable(v.name)}
|
||||
title={v.desc}
|
||||
>
|
||||
{v.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="template-active"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, isActive: checked })
|
||||
}
|
||||
/>
|
||||
<label htmlFor="template-active" className="text-sm cursor-pointer">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{editingId ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Variable reference panel */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Variable className="h-4 w-4" />
|
||||
Available Template Variables
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{AVAILABLE_VARIABLES.map((v) => (
|
||||
<div key={v.name} className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted rounded px-2 py-1 font-mono">
|
||||
{v.name}
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground">{v.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Templates list */}
|
||||
{isLoading ? (
|
||||
<TemplatesSkeleton />
|
||||
) : templates && (templates as unknown[]).length > 0 ? (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Category</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Subject</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(templates as Array<Record<string, unknown>>).map((template) => (
|
||||
<TableRow key={String(template.id)}>
|
||||
<TableCell className="font-medium">
|
||||
{String(template.name)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{template.category ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{String(template.category)}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">--</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-sm text-muted-foreground truncate max-w-[200px]">
|
||||
{String(template.subject || '')}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{template.isActive !== false ? (
|
||||
<Badge variant="default" className="text-xs">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">Inactive</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEdit(template)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteId(String(template.id))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<LayoutTemplate className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No templates yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a template to speed up message composition.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Template</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this template? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteId && deleteMutation.mutate({ id: deleteId })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplatesSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-8 w-16 ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
LayoutTemplate,
|
||||
Eye,
|
||||
Variable,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const AVAILABLE_VARIABLES = [
|
||||
{ name: '{{projectName}}', desc: 'Project title' },
|
||||
{ name: '{{userName}}', desc: "Recipient's name" },
|
||||
{ name: '{{deadline}}', desc: 'Deadline date' },
|
||||
{ name: '{{roundName}}', desc: 'Round name' },
|
||||
{ name: '{{programName}}', desc: 'Program name' },
|
||||
]
|
||||
|
||||
interface TemplateFormData {
|
||||
name: string
|
||||
category: string
|
||||
subject: string
|
||||
body: string
|
||||
variables: string[]
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const defaultForm: TemplateFormData = {
|
||||
name: '',
|
||||
category: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
variables: [],
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
export default function MessageTemplatesPage() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState<TemplateFormData>(defaultForm)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: templates, isLoading } = trpc.message.listTemplates.useQuery()
|
||||
|
||||
const createMutation = trpc.message.createTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.message.listTemplates.invalidate()
|
||||
toast.success('Template created')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const updateMutation = trpc.message.updateTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.message.listTemplates.invalidate()
|
||||
toast.success('Template updated')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const deleteMutation = trpc.message.deleteTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.message.listTemplates.invalidate()
|
||||
toast.success('Template deleted')
|
||||
setDeleteId(null)
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogOpen(false)
|
||||
setEditingId(null)
|
||||
setFormData(defaultForm)
|
||||
setShowPreview(false)
|
||||
}
|
||||
|
||||
const openEdit = (template: Record<string, unknown>) => {
|
||||
setEditingId(String(template.id))
|
||||
setFormData({
|
||||
name: String(template.name || ''),
|
||||
category: String(template.category || ''),
|
||||
subject: String(template.subject || ''),
|
||||
body: String(template.body || ''),
|
||||
variables: Array.isArray(template.variables) ? template.variables.map(String) : [],
|
||||
isActive: template.isActive !== false,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const insertVariable = (variable: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
body: prev.body + variable,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.name.trim() || !formData.subject.trim()) {
|
||||
toast.error('Name and subject are required')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: formData.name.trim(),
|
||||
category: formData.category.trim() || 'General',
|
||||
subject: formData.subject.trim(),
|
||||
body: formData.body.trim(),
|
||||
variables: formData.variables.length > 0 ? formData.variables : undefined,
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, ...payload, isActive: formData.isActive })
|
||||
} else {
|
||||
createMutation.mutate(payload)
|
||||
}
|
||||
}
|
||||
|
||||
const getPreviewText = (text: string): string => {
|
||||
return text
|
||||
.replace(/\{\{userName\}\}/g, 'John Doe')
|
||||
.replace(/\{\{projectName\}\}/g, 'Ocean Cleanup Initiative')
|
||||
.replace(/\{\{roundName\}\}/g, 'Round 1 - Semi-Finals')
|
||||
.replace(/\{\{programName\}\}/g, 'MOPC 2026')
|
||||
.replace(/\{\{deadline\}\}/g, 'March 15, 2026')
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/messages">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Messages
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Message Templates</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create and manage reusable message templates
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => { setFormData(defaultForm); setEditingId(null); setDialogOpen(true) }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Template
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? 'Edit Template' : 'Create Template'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define a reusable message template with variable placeholders.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Template Name</Label>
|
||||
<Input
|
||||
placeholder="e.g., Evaluation Reminder"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Input
|
||||
placeholder="e.g., Notification, Reminder"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Subject</Label>
|
||||
<Input
|
||||
placeholder="e.g., Reminder: {{roundName}} evaluation deadline"
|
||||
value={formData.subject}
|
||||
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Message Body</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
{showPreview ? 'Edit' : 'Preview'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showPreview ? (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Subject: {getPreviewText(formData.subject)}
|
||||
</p>
|
||||
<div className="text-sm whitespace-pre-wrap border-t pt-2">
|
||||
{getPreviewText(formData.body) || 'No content yet'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder="Write your template message..."
|
||||
value={formData.body}
|
||||
onChange={(e) => setFormData({ ...formData, body: e.target.value })}
|
||||
rows={8}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variable buttons */}
|
||||
{!showPreview && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-1">
|
||||
<Variable className="h-3 w-3" />
|
||||
Insert Variable
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{AVAILABLE_VARIABLES.map((v) => (
|
||||
<Button
|
||||
key={v.name}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => insertVariable(v.name)}
|
||||
title={v.desc}
|
||||
>
|
||||
{v.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="template-active"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, isActive: checked })
|
||||
}
|
||||
/>
|
||||
<label htmlFor="template-active" className="text-sm cursor-pointer">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{editingId ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Variable reference panel */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Variable className="h-4 w-4" />
|
||||
Available Template Variables
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{AVAILABLE_VARIABLES.map((v) => (
|
||||
<div key={v.name} className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted rounded px-2 py-1 font-mono">
|
||||
{v.name}
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground">{v.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Templates list */}
|
||||
{isLoading ? (
|
||||
<TemplatesSkeleton />
|
||||
) : templates && (templates as unknown[]).length > 0 ? (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Category</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Subject</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(templates as Array<Record<string, unknown>>).map((template) => (
|
||||
<TableRow key={String(template.id)}>
|
||||
<TableCell className="font-medium">
|
||||
{String(template.name)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{template.category ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{String(template.category)}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">--</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-sm text-muted-foreground truncate max-w-[200px]">
|
||||
{String(template.subject || '')}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{template.isActive !== false ? (
|
||||
<Badge variant="default" className="text-xs">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">Inactive</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEdit(template)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteId(String(template.id))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<LayoutTemplate className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No templates yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a template to speed up message composition.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Template</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this template? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteId && deleteMutation.mutate({ id: deleteId })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplatesSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-8 w-16 ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +1,72 @@
|
|||
import type { Metadata } from 'next'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { CircleDot } from 'lucide-react'
|
||||
import { DashboardContent } from './dashboard-content'
|
||||
|
||||
export const metadata: Metadata = { title: 'Admin Dashboard' }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ edition?: string }>
|
||||
}
|
||||
|
||||
export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
||||
let editionId: string | null = null
|
||||
let sessionName = 'Admin'
|
||||
|
||||
try {
|
||||
const [session, params] = await Promise.all([
|
||||
auth(),
|
||||
searchParams,
|
||||
])
|
||||
|
||||
editionId = params.edition || null
|
||||
sessionName = session?.user?.name || 'Admin'
|
||||
|
||||
if (!editionId) {
|
||||
const defaultEdition = await prisma.program.findFirst({
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { year: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
editionId = defaultEdition?.id || null
|
||||
|
||||
if (!editionId) {
|
||||
const anyEdition = await prisma.program.findFirst({
|
||||
orderBy: { year: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
editionId = anyEdition?.id || null
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AdminDashboard] Page init failed:', err)
|
||||
}
|
||||
|
||||
if (!editionId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<CircleDot 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 dashboard
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DashboardContent editionId={editionId} sessionName={sessionName} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import type { Metadata } from 'next'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { CircleDot } from 'lucide-react'
|
||||
import { DashboardContent } from './dashboard-content'
|
||||
|
||||
export const metadata: Metadata = { title: 'Admin Dashboard' }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ edition?: string }>
|
||||
}
|
||||
|
||||
export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
||||
let editionId: string | null = null
|
||||
let sessionName = 'Admin'
|
||||
|
||||
try {
|
||||
const [session, params] = await Promise.all([
|
||||
auth(),
|
||||
searchParams,
|
||||
])
|
||||
|
||||
editionId = params.edition || null
|
||||
sessionName = session?.user?.name || 'Admin'
|
||||
|
||||
if (!editionId) {
|
||||
const defaultEdition = await prisma.program.findFirst({
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { year: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
editionId = defaultEdition?.id || null
|
||||
|
||||
if (!editionId) {
|
||||
const anyEdition = await prisma.program.findFirst({
|
||||
orderBy: { year: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
editionId = anyEdition?.id || null
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AdminDashboard] Page init failed:', err)
|
||||
}
|
||||
|
||||
if (!editionId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<CircleDot 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 dashboard
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DashboardContent editionId={editionId} sessionName={sessionName} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,282 +1,282 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { ArrowLeft, Loader2, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function EditPartnerPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const id = params.id as string
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
website: '',
|
||||
partnerType: 'PARTNER',
|
||||
visibility: 'ADMIN_ONLY',
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const { data: partner, isLoading } = trpc.partner.get.useQuery({ id })
|
||||
|
||||
useEffect(() => {
|
||||
if (partner) {
|
||||
setFormData({
|
||||
name: partner.name,
|
||||
description: partner.description || '',
|
||||
website: partner.website || '',
|
||||
partnerType: partner.partnerType,
|
||||
visibility: partner.visibility,
|
||||
isActive: partner.isActive,
|
||||
})
|
||||
}
|
||||
}, [partner])
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const updatePartner = trpc.partner.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.partner.list.invalidate()
|
||||
utils.partner.get.invalidate()
|
||||
toast.success('Partner updated successfully')
|
||||
router.push('/admin/partners')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to update partner')
|
||||
setIsSubmitting(false)
|
||||
},
|
||||
})
|
||||
|
||||
const deletePartner = trpc.partner.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.partner.list.invalidate()
|
||||
toast.success('Partner deleted successfully')
|
||||
router.push('/admin/partners')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to delete partner')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
updatePartner.mutate({
|
||||
id,
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
website: formData.website || null,
|
||||
partnerType: formData.partnerType as 'SPONSOR' | 'PARTNER' | 'SUPPORTER' | 'MEDIA' | 'OTHER',
|
||||
visibility: formData.visibility as 'ADMIN_ONLY' | 'JURY_VISIBLE' | 'PUBLIC',
|
||||
isActive: formData.isActive,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete handled via AlertDialog in JSX
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Edit Partner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Update partner information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Partner</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this partner. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deletePartner.mutate({ id })}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Partner Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about the partner organization
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Organization Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="partnerType">Partner Type</Label>
|
||||
<Select
|
||||
value={formData.partnerType}
|
||||
onValueChange={(value) => setFormData({ ...formData, partnerType: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
||||
<SelectItem value="PARTNER">Partner</SelectItem>
|
||||
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
||||
<SelectItem value="MEDIA">Media</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</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 })}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Website</Label>
|
||||
<Input
|
||||
id="website"
|
||||
type="url"
|
||||
value={formData.website}
|
||||
onChange={(e) => setFormData({ ...formData, website: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visibility">Visibility</Label>
|
||||
<Select
|
||||
value={formData.visibility}
|
||||
onValueChange={(value) => setFormData({ ...formData, visibility: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ADMIN_ONLY">Admin Only</SelectItem>
|
||||
<SelectItem value="JURY_VISIBLE">Visible to Jury</SelectItem>
|
||||
<SelectItem value="PUBLIC">Public</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-8">
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
|
||||
/>
|
||||
<Label htmlFor="isActive">Active</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { ArrowLeft, Loader2, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function EditPartnerPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const id = params.id as string
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
website: '',
|
||||
partnerType: 'PARTNER',
|
||||
visibility: 'ADMIN_ONLY',
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const { data: partner, isLoading } = trpc.partner.get.useQuery({ id })
|
||||
|
||||
useEffect(() => {
|
||||
if (partner) {
|
||||
setFormData({
|
||||
name: partner.name,
|
||||
description: partner.description || '',
|
||||
website: partner.website || '',
|
||||
partnerType: partner.partnerType,
|
||||
visibility: partner.visibility,
|
||||
isActive: partner.isActive,
|
||||
})
|
||||
}
|
||||
}, [partner])
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const updatePartner = trpc.partner.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.partner.list.invalidate()
|
||||
utils.partner.get.invalidate()
|
||||
toast.success('Partner updated successfully')
|
||||
router.push('/admin/partners')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to update partner')
|
||||
setIsSubmitting(false)
|
||||
},
|
||||
})
|
||||
|
||||
const deletePartner = trpc.partner.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.partner.list.invalidate()
|
||||
toast.success('Partner deleted successfully')
|
||||
router.push('/admin/partners')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to delete partner')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
updatePartner.mutate({
|
||||
id,
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
website: formData.website || null,
|
||||
partnerType: formData.partnerType as 'SPONSOR' | 'PARTNER' | 'SUPPORTER' | 'MEDIA' | 'OTHER',
|
||||
visibility: formData.visibility as 'ADMIN_ONLY' | 'JURY_VISIBLE' | 'PUBLIC',
|
||||
isActive: formData.isActive,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete handled via AlertDialog in JSX
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Edit Partner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Update partner information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Partner</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this partner. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deletePartner.mutate({ id })}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Partner Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about the partner organization
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Organization Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="partnerType">Partner Type</Label>
|
||||
<Select
|
||||
value={formData.partnerType}
|
||||
onValueChange={(value) => setFormData({ ...formData, partnerType: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
||||
<SelectItem value="PARTNER">Partner</SelectItem>
|
||||
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
||||
<SelectItem value="MEDIA">Media</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</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 })}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Website</Label>
|
||||
<Input
|
||||
id="website"
|
||||
type="url"
|
||||
value={formData.website}
|
||||
onChange={(e) => setFormData({ ...formData, website: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visibility">Visibility</Label>
|
||||
<Select
|
||||
value={formData.visibility}
|
||||
onValueChange={(value) => setFormData({ ...formData, visibility: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ADMIN_ONLY">Admin Only</SelectItem>
|
||||
<SelectItem value="JURY_VISIBLE">Visible to Jury</SelectItem>
|
||||
<SelectItem value="PUBLIC">Public</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-8">
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
|
||||
/>
|
||||
<Label htmlFor="isActive">Active</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,170 +1,170 @@
|
|||
'use client'
|
||||
|
||||
import { 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 { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function NewPartnerPage() {
|
||||
const router = useRouter()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [partnerType, setPartnerType] = useState('PARTNER')
|
||||
const [visibility, setVisibility] = useState('ADMIN_ONLY')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const createPartner = trpc.partner.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.partner.list.invalidate()
|
||||
toast.success('Partner created successfully')
|
||||
router.push('/admin/partners')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to create partner')
|
||||
setIsSubmitting(false)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const name = formData.get('name') as string
|
||||
const description = formData.get('description') as string
|
||||
const website = formData.get('website') as string
|
||||
|
||||
createPartner.mutate({
|
||||
name,
|
||||
programId: null,
|
||||
description: description || undefined,
|
||||
website: website || undefined,
|
||||
partnerType: partnerType as 'SPONSOR' | 'PARTNER' | 'SUPPORTER' | 'MEDIA' | 'OTHER',
|
||||
visibility: visibility as 'ADMIN_ONLY' | 'JURY_VISIBLE' | 'PUBLIC',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Add Partner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Add a new partner or sponsor organization
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Partner Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about the partner organization
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Organization Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="e.g., Ocean Conservation Foundation"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="partnerType">Partner Type</Label>
|
||||
<Select value={partnerType} onValueChange={setPartnerType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
||||
<SelectItem value="PARTNER">Partner</SelectItem>
|
||||
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
||||
<SelectItem value="MEDIA">Media</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Describe the organization and partnership..."
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Website</Label>
|
||||
<Input
|
||||
id="website"
|
||||
name="website"
|
||||
type="url"
|
||||
placeholder="https://example.org"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visibility">Visibility</Label>
|
||||
<Select value={visibility} onValueChange={setVisibility}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ADMIN_ONLY">Admin Only</SelectItem>
|
||||
<SelectItem value="JURY_VISIBLE">Visible to Jury</SelectItem>
|
||||
<SelectItem value="PUBLIC">Public</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Add Partner
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { 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 { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function NewPartnerPage() {
|
||||
const router = useRouter()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [partnerType, setPartnerType] = useState('PARTNER')
|
||||
const [visibility, setVisibility] = useState('ADMIN_ONLY')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const createPartner = trpc.partner.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.partner.list.invalidate()
|
||||
toast.success('Partner created successfully')
|
||||
router.push('/admin/partners')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to create partner')
|
||||
setIsSubmitting(false)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const name = formData.get('name') as string
|
||||
const description = formData.get('description') as string
|
||||
const website = formData.get('website') as string
|
||||
|
||||
createPartner.mutate({
|
||||
name,
|
||||
programId: null,
|
||||
description: description || undefined,
|
||||
website: website || undefined,
|
||||
partnerType: partnerType as 'SPONSOR' | 'PARTNER' | 'SUPPORTER' | 'MEDIA' | 'OTHER',
|
||||
visibility: visibility as 'ADMIN_ONLY' | 'JURY_VISIBLE' | 'PUBLIC',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Add Partner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Add a new partner or sponsor organization
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Partner Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about the partner organization
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Organization Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="e.g., Ocean Conservation Foundation"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="partnerType">Partner Type</Label>
|
||||
<Select value={partnerType} onValueChange={setPartnerType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
||||
<SelectItem value="PARTNER">Partner</SelectItem>
|
||||
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
||||
<SelectItem value="MEDIA">Media</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Describe the organization and partnership..."
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Website</Label>
|
||||
<Input
|
||||
id="website"
|
||||
name="website"
|
||||
type="url"
|
||||
placeholder="https://example.org"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visibility">Visibility</Label>
|
||||
<Select value={visibility} onValueChange={setVisibility}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ADMIN_ONLY">Admin Only</SelectItem>
|
||||
<SelectItem value="JURY_VISIBLE">Visible to Jury</SelectItem>
|
||||
<SelectItem value="PUBLIC">Public</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Add Partner
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,259 +1,259 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
ExternalLink,
|
||||
Building2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Globe,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
const visibilityIcons = {
|
||||
ADMIN_ONLY: EyeOff,
|
||||
JURY_VISIBLE: Eye,
|
||||
PUBLIC: Globe,
|
||||
}
|
||||
|
||||
const partnerTypeColors: Record<string, string> = {
|
||||
SPONSOR: 'bg-yellow-100 text-yellow-800',
|
||||
PARTNER: 'bg-blue-100 text-blue-800',
|
||||
SUPPORTER: 'bg-green-100 text-green-800',
|
||||
MEDIA: 'bg-purple-100 text-purple-800',
|
||||
OTHER: 'bg-gray-100 text-gray-800',
|
||||
}
|
||||
|
||||
export default function PartnersPage() {
|
||||
const { data, isLoading } = trpc.partner.list.useQuery({ perPage: 50 })
|
||||
const partners = data?.data
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [activeFilter, setActiveFilter] = useState('all')
|
||||
|
||||
const filteredPartners = useMemo(() => {
|
||||
if (!partners) return []
|
||||
return partners.filter((partner) => {
|
||||
const matchesSearch =
|
||||
!debouncedSearch ||
|
||||
partner.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
partner.description?.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
const matchesType = typeFilter === 'all' || partner.partnerType === typeFilter
|
||||
const matchesActive =
|
||||
activeFilter === 'all' ||
|
||||
(activeFilter === 'active' && partner.isActive) ||
|
||||
(activeFilter === 'inactive' && !partner.isActive)
|
||||
return matchesSearch && matchesType && matchesActive
|
||||
})
|
||||
}, [partners, debouncedSearch, typeFilter, activeFilter])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<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]" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Partner cards skeleton */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Partners</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage partner and sponsor organizations
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/partners/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Partner
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search partners..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
||||
<SelectItem value="PARTNER">Partner</SelectItem>
|
||||
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
||||
<SelectItem value="MEDIA">Media</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
{partners && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredPartners.length} of {partners.length} partners
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Partners Grid */}
|
||||
{filteredPartners.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredPartners.map((partner) => {
|
||||
const VisibilityIcon = visibilityIcons[partner.visibility as keyof typeof visibilityIcons] || Eye
|
||||
return (
|
||||
<Card key={partner.id} className={!partner.isActive ? 'opacity-60' : ''}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||
<Building2 className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium truncate">{partner.name}</h3>
|
||||
{!partner.isActive && (
|
||||
<Badge variant="secondary">Inactive</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge className={partnerTypeColors[partner.partnerType] || ''} variant="outline">
|
||||
{partner.partnerType}
|
||||
</Badge>
|
||||
<VisibilityIcon className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
{partner.description && (
|
||||
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
|
||||
{partner.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-4 pt-4 border-t">
|
||||
{partner.website && (
|
||||
<a
|
||||
href={partner.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
Website
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
<Link href={`/admin/partners/${partner.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Pencil className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : partners && partners.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No partners match your filters
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Building2 className="h-12 w-12 text-muted-foreground/40" />
|
||||
<h3 className="mt-3 text-lg font-medium">No partners yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||
Add sponsor and partner organizations to showcase on the platform.
|
||||
</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/admin/partners/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Partner
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
ExternalLink,
|
||||
Building2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Globe,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
const visibilityIcons = {
|
||||
ADMIN_ONLY: EyeOff,
|
||||
JURY_VISIBLE: Eye,
|
||||
PUBLIC: Globe,
|
||||
}
|
||||
|
||||
const partnerTypeColors: Record<string, string> = {
|
||||
SPONSOR: 'bg-yellow-100 text-yellow-800',
|
||||
PARTNER: 'bg-blue-100 text-blue-800',
|
||||
SUPPORTER: 'bg-green-100 text-green-800',
|
||||
MEDIA: 'bg-purple-100 text-purple-800',
|
||||
OTHER: 'bg-gray-100 text-gray-800',
|
||||
}
|
||||
|
||||
export default function PartnersPage() {
|
||||
const { data, isLoading } = trpc.partner.list.useQuery({ perPage: 50 })
|
||||
const partners = data?.data
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [activeFilter, setActiveFilter] = useState('all')
|
||||
|
||||
const filteredPartners = useMemo(() => {
|
||||
if (!partners) return []
|
||||
return partners.filter((partner) => {
|
||||
const matchesSearch =
|
||||
!debouncedSearch ||
|
||||
partner.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
partner.description?.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
const matchesType = typeFilter === 'all' || partner.partnerType === typeFilter
|
||||
const matchesActive =
|
||||
activeFilter === 'all' ||
|
||||
(activeFilter === 'active' && partner.isActive) ||
|
||||
(activeFilter === 'inactive' && !partner.isActive)
|
||||
return matchesSearch && matchesType && matchesActive
|
||||
})
|
||||
}, [partners, debouncedSearch, typeFilter, activeFilter])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<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]" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Partner cards skeleton */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Partners</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage partner and sponsor organizations
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/partners/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Partner
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search partners..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
||||
<SelectItem value="PARTNER">Partner</SelectItem>
|
||||
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
||||
<SelectItem value="MEDIA">Media</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
{partners && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredPartners.length} of {partners.length} partners
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Partners Grid */}
|
||||
{filteredPartners.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredPartners.map((partner) => {
|
||||
const VisibilityIcon = visibilityIcons[partner.visibility as keyof typeof visibilityIcons] || Eye
|
||||
return (
|
||||
<Card key={partner.id} className={!partner.isActive ? 'opacity-60' : ''}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||
<Building2 className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium truncate">{partner.name}</h3>
|
||||
{!partner.isActive && (
|
||||
<Badge variant="secondary">Inactive</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge className={partnerTypeColors[partner.partnerType] || ''} variant="outline">
|
||||
{partner.partnerType}
|
||||
</Badge>
|
||||
<VisibilityIcon className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
{partner.description && (
|
||||
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
|
||||
{partner.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-4 pt-4 border-t">
|
||||
{partner.website && (
|
||||
<a
|
||||
href={partner.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
Website
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
<Link href={`/admin/partners/${partner.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Pencil className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : partners && partners.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No partners match your filters
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Building2 className="h-12 w-12 text-muted-foreground/40" />
|
||||
<h3 className="mt-3 text-lg font-medium">No partners yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||
Add sponsor and partner organizations to showcase on the platform.
|
||||
</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/admin/partners/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Partner
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue