Compare commits
5 Commits
9ee767b6cd
...
c321d4711e
| Author | SHA1 | Date |
|---|---|---|
|
|
c321d4711e | |
|
|
2d91ce02fc | |
|
|
3975b5c51f | |
|
|
b5425e705e | |
|
|
e56e143a40 |
|
|
@ -0,0 +1,12 @@
|
||||||
|
* 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
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: code.letsbe.solutions
|
REGISTRY: code.monaco-opc.com
|
||||||
IMAGE_NAME: letsbe/mopc-app
|
IMAGE_NAME: mopc/mopc-portal
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
|
||||||
776
CLAUDE.md
776
CLAUDE.md
|
|
@ -1,388 +1,388 @@
|
||||||
# MOPC Platform - Claude Code Context
|
# MOPC Platform - Claude Code Context
|
||||||
|
|
||||||
## Project Overview
|
## 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:
|
**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 1**: ~130 projects → ~60 semi-finalists
|
||||||
- **Round 2**: ~60 projects → 6 finalists
|
- **Round 2**: ~60 projects → 6 finalists
|
||||||
|
|
||||||
**Domain**: `monaco-opc.com`
|
**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.
|
The platform is designed for future expansion into a comprehensive program management system including learning hub, communication workflows, and partner modules.
|
||||||
|
|
||||||
## Key Decisions
|
## Key Decisions
|
||||||
|
|
||||||
| Decision | Choice |
|
| Decision | Choice |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
| Evaluation Criteria | Fully configurable per round (admin defines) |
|
| Evaluation Criteria | Fully configurable per round (admin defines) |
|
||||||
| CSV Import | Flexible column mapping (admin maps columns) |
|
| CSV Import | Flexible column mapping (admin maps columns) |
|
||||||
| Max File Size | 500MB (for videos) |
|
| Max File Size | 500MB (for videos) |
|
||||||
| Observer Role | Included in Phase 1 |
|
| Observer Role | Included in Phase 1 |
|
||||||
| First Admin | Database seed script |
|
| First Admin | Database seed script |
|
||||||
| Past Evaluations | Visible read-only after submit |
|
| Past Evaluations | Visible read-only after submit |
|
||||||
| Grace Period | Admin-configurable per juror/project |
|
| Grace Period | Admin-configurable per juror/project |
|
||||||
| Smart Assignment | AI-powered (GPT) + Smart Algorithm fallback + geo-diversity, familiarity, COI scoring |
|
| Smart Assignment | AI-powered (GPT) + Smart Algorithm fallback + geo-diversity, familiarity, COI scoring |
|
||||||
| AI Data Privacy | All data anonymized before sending to GPT |
|
| AI Data Privacy | All data anonymized before sending to GPT |
|
||||||
| Evaluation Criteria Types | `numeric`, `text`, `boolean`, `section_header` (backward-compatible) |
|
| Evaluation Criteria Types | `numeric`, `text`, `boolean`, `section_header` (backward-compatible) |
|
||||||
| COI Workflow | Mandatory declaration before evaluation, admin review |
|
| COI Workflow | Mandatory declaration before evaluation, admin review |
|
||||||
| Evaluation Reminders | Cron-based email reminders with countdown urgency |
|
| Evaluation Reminders | Cron-based email reminders with countdown urgency |
|
||||||
|
|
||||||
## Brand Identity
|
## Brand Identity
|
||||||
|
|
||||||
| Name | Hex | Usage |
|
| Name | Hex | Usage |
|
||||||
|------|-----|-------|
|
|------|-----|-------|
|
||||||
| Primary Red | `#de0f1e` | CTAs, alerts |
|
| Primary Red | `#de0f1e` | CTAs, alerts |
|
||||||
| Dark Blue | `#053d57` | Headers, sidebar |
|
| Dark Blue | `#053d57` | Headers, sidebar |
|
||||||
| White | `#fefefe` | Backgrounds |
|
| White | `#fefefe` | Backgrounds |
|
||||||
| Teal | `#557f8c` | Links, secondary |
|
| Teal | `#557f8c` | Links, secondary |
|
||||||
|
|
||||||
**Typography**: Montserrat (600/700 for headings, 300/400 for body)
|
**Typography**: Montserrat (600/700 for headings, 300/400 for body)
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Technology | Version |
|
| Layer | Technology | Version |
|
||||||
|-------|-----------|---------|
|
|-------|-----------|---------|
|
||||||
| **Framework** | Next.js (App Router) | 15.x |
|
| **Framework** | Next.js (App Router) | 15.x |
|
||||||
| **Language** | TypeScript | 5.x |
|
| **Language** | TypeScript | 5.x |
|
||||||
| **UI Components** | shadcn/ui | latest |
|
| **UI Components** | shadcn/ui | latest |
|
||||||
| **Styling** | Tailwind CSS | 3.x |
|
| **Styling** | Tailwind CSS | 3.x |
|
||||||
| **API Layer** | tRPC | 11.x |
|
| **API Layer** | tRPC | 11.x |
|
||||||
| **Database** | PostgreSQL | 16.x |
|
| **Database** | PostgreSQL | 16.x |
|
||||||
| **ORM** | Prisma | 6.x |
|
| **ORM** | Prisma | 6.x |
|
||||||
| **Authentication** | NextAuth.js (Auth.js) | 5.x |
|
| **Authentication** | NextAuth.js (Auth.js) | 5.x |
|
||||||
| **AI** | OpenAI GPT | 4.x SDK |
|
| **AI** | OpenAI GPT | 4.x SDK |
|
||||||
| **Animation** | Motion (Framer Motion) | 11.x |
|
| **Animation** | Motion (Framer Motion) | 11.x |
|
||||||
| **Notifications** | Sonner | 1.x |
|
| **Notifications** | Sonner | 1.x |
|
||||||
| **Command Palette** | cmdk | 1.x |
|
| **Command Palette** | cmdk | 1.x |
|
||||||
| **File Storage** | MinIO (S3-compatible) | External |
|
| **File Storage** | MinIO (S3-compatible) | External |
|
||||||
| **Email** | Nodemailer + Poste.io | External |
|
| **Email** | Nodemailer + Poste.io | External |
|
||||||
| **Containerization** | Docker Compose | 2.x |
|
| **Containerization** | Docker Compose | 2.x |
|
||||||
| **Reverse Proxy** | Nginx | External |
|
| **Reverse Proxy** | Nginx | External |
|
||||||
|
|
||||||
## Architecture Principles
|
## Architecture Principles
|
||||||
|
|
||||||
1. **Type Safety First**: End-to-end TypeScript from database to UI via Prisma → tRPC → React
|
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
|
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
|
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
|
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
|
5. **Security by Default**: RBAC, audit logging, secure file access with pre-signed URLs
|
||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
mopc-platform/
|
mopc-platform/
|
||||||
├── CLAUDE.md # This file - project context
|
├── CLAUDE.md # This file - project context
|
||||||
├── docs/
|
├── docs/
|
||||||
│ └── architecture/ # Architecture documentation
|
│ └── architecture/ # Architecture documentation
|
||||||
│ ├── README.md # System overview
|
│ ├── README.md # System overview
|
||||||
│ ├── database.md # Database design
|
│ ├── database.md # Database design
|
||||||
│ ├── api.md # API design
|
│ ├── api.md # API design
|
||||||
│ ├── infrastructure.md # Deployment docs
|
│ ├── infrastructure.md # Deployment docs
|
||||||
│ └── ui.md # UI/UX patterns
|
│ └── ui.md # UI/UX patterns
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app/ # Next.js App Router pages
|
│ ├── app/ # Next.js App Router pages
|
||||||
│ │ ├── (auth)/ # Public auth routes (login, verify)
|
│ │ ├── (auth)/ # Public auth routes (login, verify)
|
||||||
│ │ ├── (admin)/ # Admin dashboard (protected)
|
│ │ ├── (admin)/ # Admin dashboard (protected)
|
||||||
│ │ ├── (jury)/ # Jury interface (protected)
|
│ │ ├── (jury)/ # Jury interface (protected)
|
||||||
│ │ ├── api/ # API routes
|
│ │ ├── api/ # API routes
|
||||||
│ │ │ ├── trpc/ # tRPC endpoint
|
│ │ │ ├── trpc/ # tRPC endpoint
|
||||||
│ │ │ └── cron/
|
│ │ │ └── cron/
|
||||||
│ │ │ └── reminders/ # Cron endpoint for evaluation reminders (F4)
|
│ │ │ └── reminders/ # Cron endpoint for evaluation reminders (F4)
|
||||||
│ │ ├── layout.tsx # Root layout
|
│ │ ├── layout.tsx # Root layout
|
||||||
│ │ └── page.tsx # Home/landing
|
│ │ └── page.tsx # Home/landing
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── ui/ # shadcn/ui components
|
│ │ ├── ui/ # shadcn/ui components
|
||||||
│ │ ├── admin/ # Admin-specific components
|
│ │ ├── admin/ # Admin-specific components
|
||||||
│ │ │ └── evaluation-summary-card.tsx # AI summary display
|
│ │ │ └── evaluation-summary-card.tsx # AI summary display
|
||||||
│ │ ├── forms/ # Form components
|
│ │ ├── forms/ # Form components
|
||||||
│ │ │ ├── evaluation-form.tsx # With progress indicator (F1)
|
│ │ │ ├── evaluation-form.tsx # With progress indicator (F1)
|
||||||
│ │ │ ├── coi-declaration-dialog.tsx # COI blocking dialog (F5)
|
│ │ │ ├── coi-declaration-dialog.tsx # COI blocking dialog (F5)
|
||||||
│ │ │ └── evaluation-form-with-coi.tsx # COI-gated wrapper (F5)
|
│ │ │ └── evaluation-form-with-coi.tsx # COI-gated wrapper (F5)
|
||||||
│ │ ├── layouts/ # Layout components (sidebar, nav)
|
│ │ ├── layouts/ # Layout components (sidebar, nav)
|
||||||
│ │ └── shared/ # Shared components
|
│ │ └── shared/ # Shared components
|
||||||
│ │ └── countdown-timer.tsx # Live countdown with urgency (F4)
|
│ │ └── countdown-timer.tsx # Live countdown with urgency (F4)
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── auth.ts # NextAuth configuration
|
│ │ ├── auth.ts # NextAuth configuration
|
||||||
│ │ ├── prisma.ts # Prisma client singleton
|
│ │ ├── prisma.ts # Prisma client singleton
|
||||||
│ │ ├── trpc/ # tRPC client & server setup
|
│ │ ├── trpc/ # tRPC client & server setup
|
||||||
│ │ ├── minio.ts # MinIO client
|
│ │ ├── minio.ts # MinIO client
|
||||||
│ │ └── email.ts # Email utilities
|
│ │ └── email.ts # Email utilities
|
||||||
│ ├── server/
|
│ ├── server/
|
||||||
│ │ ├── routers/ # tRPC routers by domain
|
│ │ ├── routers/ # tRPC routers by domain
|
||||||
│ │ │ ├── program.ts
|
│ │ │ ├── program.ts
|
||||||
│ │ │ ├── round.ts
|
│ │ │ ├── round.ts
|
||||||
│ │ │ ├── project.ts
|
│ │ │ ├── project.ts
|
||||||
│ │ │ ├── user.ts
|
│ │ │ ├── user.ts
|
||||||
│ │ │ ├── assignment.ts
|
│ │ │ ├── assignment.ts
|
||||||
│ │ │ ├── evaluation.ts
|
│ │ │ ├── evaluation.ts
|
||||||
│ │ │ ├── audit.ts
|
│ │ │ ├── audit.ts
|
||||||
│ │ │ ├── settings.ts
|
│ │ │ ├── settings.ts
|
||||||
│ │ │ ├── gracePeriod.ts
|
│ │ │ ├── gracePeriod.ts
|
||||||
│ │ │ ├── export.ts # CSV export incl. filtering results (F2)
|
│ │ │ ├── export.ts # CSV export incl. filtering results (F2)
|
||||||
│ │ │ ├── analytics.ts # Reports/analytics (observer access, F3)
|
│ │ │ ├── analytics.ts # Reports/analytics (observer access, F3)
|
||||||
│ │ │ └── mentor.ts # Mentor messaging endpoints (F10)
|
│ │ │ └── mentor.ts # Mentor messaging endpoints (F10)
|
||||||
│ │ ├── services/ # Business logic services
|
│ │ ├── services/ # Business logic services
|
||||||
│ │ │ ├── smart-assignment.ts # With geo/familiarity/COI scoring (F8)
|
│ │ │ ├── smart-assignment.ts # With geo/familiarity/COI scoring (F8)
|
||||||
│ │ │ ├── evaluation-reminders.ts # Email reminder service (F4)
|
│ │ │ ├── evaluation-reminders.ts # Email reminder service (F4)
|
||||||
│ │ │ └── ai-evaluation-summary.ts # GPT summary generation (F7)
|
│ │ │ └── ai-evaluation-summary.ts # GPT summary generation (F7)
|
||||||
│ │ └── middleware/ # RBAC & auth middleware
|
│ │ └── middleware/ # RBAC & auth middleware
|
||||||
│ ├── hooks/ # React hooks
|
│ ├── hooks/ # React hooks
|
||||||
│ ├── types/ # Shared TypeScript types
|
│ ├── types/ # Shared TypeScript types
|
||||||
│ └── utils/ # Utility functions
|
│ └── utils/ # Utility functions
|
||||||
├── prisma/
|
├── prisma/
|
||||||
│ ├── schema.prisma # Database schema
|
│ ├── schema.prisma # Database schema
|
||||||
│ ├── migrations/ # Migration files
|
│ ├── migrations/ # Migration files
|
||||||
│ └── seed.ts # Seed data
|
│ └── seed.ts # Seed data
|
||||||
├── public/ # Static assets
|
├── public/ # Static assets
|
||||||
├── docker/
|
├── docker/
|
||||||
│ ├── Dockerfile # Production build
|
│ ├── Dockerfile # Production build
|
||||||
│ ├── docker-compose.yml # Production stack
|
│ ├── docker-compose.yml # Production stack
|
||||||
│ └── docker-compose.dev.yml # Development stack
|
│ └── docker-compose.dev.yml # Development stack
|
||||||
├── tests/
|
├── tests/
|
||||||
│ ├── unit/ # Unit tests
|
│ ├── unit/ # Unit tests
|
||||||
│ └── e2e/ # End-to-end tests
|
│ └── e2e/ # End-to-end tests
|
||||||
└── config files... # package.json, tsconfig, etc.
|
└── config files... # package.json, tsconfig, etc.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
### TypeScript
|
### TypeScript
|
||||||
- Strict mode enabled
|
- Strict mode enabled
|
||||||
- Explicit return types for functions
|
- Explicit return types for functions
|
||||||
- Use `type` over `interface` for consistency (unless extending)
|
- Use `type` over `interface` for consistency (unless extending)
|
||||||
- Prefer `unknown` over `any`
|
- Prefer `unknown` over `any`
|
||||||
|
|
||||||
### React/Next.js
|
### React/Next.js
|
||||||
- Use Server Components by default
|
- Use Server Components by default
|
||||||
- `'use client'` only when needed (interactivity, hooks)
|
- `'use client'` only when needed (interactivity, hooks)
|
||||||
- Collocate components with their routes when specific to that route
|
- Collocate components with their routes when specific to that route
|
||||||
- Use React Query (via tRPC) for server state
|
- Use React Query (via tRPC) for server state
|
||||||
|
|
||||||
### Naming Conventions
|
### Naming Conventions
|
||||||
- **Files**: kebab-case (`user-profile.tsx`)
|
- **Files**: kebab-case (`user-profile.tsx`)
|
||||||
- **Components**: PascalCase (`UserProfile`)
|
- **Components**: PascalCase (`UserProfile`)
|
||||||
- **Functions/Variables**: camelCase (`getUserById`)
|
- **Functions/Variables**: camelCase (`getUserById`)
|
||||||
- **Constants**: SCREAMING_SNAKE_CASE (`MAX_FILE_SIZE`)
|
- **Constants**: SCREAMING_SNAKE_CASE (`MAX_FILE_SIZE`)
|
||||||
- **Database Tables**: PascalCase in Prisma (`User`, `Project`)
|
- **Database Tables**: PascalCase in Prisma (`User`, `Project`)
|
||||||
- **Database Columns**: camelCase in Prisma (`createdAt`)
|
- **Database Columns**: camelCase in Prisma (`createdAt`)
|
||||||
|
|
||||||
### Styling
|
### Styling
|
||||||
- Tailwind CSS utility classes
|
- Tailwind CSS utility classes
|
||||||
- Mobile-first: base styles for mobile, `md:` for tablet, `lg:` for desktop
|
- Mobile-first: base styles for mobile, `md:` for tablet, `lg:` for desktop
|
||||||
- Use shadcn/ui components as base, customize via CSS variables
|
- Use shadcn/ui components as base, customize via CSS variables
|
||||||
- No inline styles; no separate CSS files unless absolutely necessary
|
- No inline styles; no separate CSS files unless absolutely necessary
|
||||||
|
|
||||||
### API Design (tRPC)
|
### API Design (tRPC)
|
||||||
- Group by domain: `trpc.program.create()`, `trpc.round.list()`
|
- Group by domain: `trpc.program.create()`, `trpc.round.list()`
|
||||||
- Use Zod for input validation
|
- Use Zod for input validation
|
||||||
- Return consistent response shapes
|
- Return consistent response shapes
|
||||||
- Throw `TRPCError` with appropriate codes
|
- Throw `TRPCError` with appropriate codes
|
||||||
|
|
||||||
## Common Commands
|
## Common Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
# Development
|
||||||
npm run dev # Start Next.js dev server
|
npm run dev # Start Next.js dev server
|
||||||
npm run db:studio # Open Prisma Studio
|
npm run db:studio # Open Prisma Studio
|
||||||
npm run db:push # Push schema changes (dev only)
|
npm run db:push # Push schema changes (dev only)
|
||||||
npm run db:migrate # Run migrations
|
npm run db:migrate # Run migrations
|
||||||
npm run db:seed # Seed database
|
npm run db:seed # Seed database
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
npm run test # Run unit tests
|
npm run test # Run unit tests
|
||||||
npm run test:e2e # Run E2E tests
|
npm run test:e2e # Run E2E tests
|
||||||
npm run test:coverage # Test with coverage
|
npm run test:coverage # Test with coverage
|
||||||
|
|
||||||
# Build & Deploy
|
# Build & Deploy
|
||||||
npm run build # Production build
|
npm run build # Production build
|
||||||
npm run start # Start production server
|
npm run start # Start production server
|
||||||
docker compose up -d # Start Docker stack
|
docker compose up -d # Start Docker stack
|
||||||
docker compose logs -f app # View app logs
|
docker compose logs -f app # View app logs
|
||||||
|
|
||||||
# Code Quality
|
# Code Quality
|
||||||
npm run lint # ESLint
|
npm run lint # ESLint
|
||||||
npm run format # Prettier
|
npm run format # Prettier
|
||||||
npm run typecheck # TypeScript check
|
npm run typecheck # TypeScript check
|
||||||
```
|
```
|
||||||
|
|
||||||
## Windows Development Notes
|
## 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**: 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:
|
**IMPORTANT**: When invoking PowerShell from bash, always use `-ExecutionPolicy Bypass` to skip the user profile script which is blocked by execution policy:
|
||||||
```bash
|
```bash
|
||||||
powershell -ExecutionPolicy Bypass -Command "..."
|
powershell -ExecutionPolicy Bypass -Command "..."
|
||||||
```
|
```
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
```bash
|
```bash
|
||||||
# npm commands
|
# npm commands
|
||||||
powershell -ExecutionPolicy Bypass -Command "npm install"
|
powershell -ExecutionPolicy Bypass -Command "npm install"
|
||||||
powershell -ExecutionPolicy Bypass -Command "npm run build"
|
powershell -ExecutionPolicy Bypass -Command "npm run build"
|
||||||
powershell -ExecutionPolicy Bypass -Command "npx prisma generate"
|
powershell -ExecutionPolicy Bypass -Command "npx prisma generate"
|
||||||
|
|
||||||
# Docker commands
|
# Docker commands
|
||||||
powershell -ExecutionPolicy Bypass -Command "docker compose -f docker/docker-compose.dev.yml up -d"
|
powershell -ExecutionPolicy Bypass -Command "docker compose -f docker/docker-compose.dev.yml up -d"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Docker commands on Windows (use 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 up -d
|
||||||
docker compose -f docker/docker-compose.dev.yml build --no-cache app
|
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 logs -f app
|
||||||
docker compose -f docker/docker-compose.dev.yml down
|
docker compose -f docker/docker-compose.dev.yml down
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL="postgresql://user:pass@localhost:5432/mopc"
|
DATABASE_URL="postgresql://user:pass@localhost:5432/mopc"
|
||||||
|
|
||||||
# NextAuth
|
# NextAuth
|
||||||
NEXTAUTH_URL="https://monaco-opc.com"
|
NEXTAUTH_URL="https://monaco-opc.com"
|
||||||
NEXTAUTH_SECRET="your-secret-key"
|
NEXTAUTH_SECRET="your-secret-key"
|
||||||
|
|
||||||
# MinIO (existing separate stack)
|
# MinIO (existing separate stack)
|
||||||
MINIO_ENDPOINT="http://localhost:9000"
|
MINIO_ENDPOINT="http://localhost:9000"
|
||||||
MINIO_ACCESS_KEY="your-access-key"
|
MINIO_ACCESS_KEY="your-access-key"
|
||||||
MINIO_SECRET_KEY="your-secret-key"
|
MINIO_SECRET_KEY="your-secret-key"
|
||||||
MINIO_BUCKET="mopc-files"
|
MINIO_BUCKET="mopc-files"
|
||||||
|
|
||||||
# Email (Poste.io - existing)
|
# Email (Poste.io - existing)
|
||||||
SMTP_HOST="localhost"
|
SMTP_HOST="localhost"
|
||||||
SMTP_PORT="587"
|
SMTP_PORT="587"
|
||||||
SMTP_USER="noreply@monaco-opc.com"
|
SMTP_USER="noreply@monaco-opc.com"
|
||||||
SMTP_PASS="your-smtp-password"
|
SMTP_PASS="your-smtp-password"
|
||||||
EMAIL_FROM="MOPC Platform <noreply@monaco-opc.com>"
|
EMAIL_FROM="MOPC Platform <noreply@monaco-opc.com>"
|
||||||
|
|
||||||
# OpenAI (for smart assignment and AI evaluation summaries)
|
# OpenAI (for smart assignment and AI evaluation summaries)
|
||||||
OPENAI_API_KEY="your-openai-api-key"
|
OPENAI_API_KEY="your-openai-api-key"
|
||||||
|
|
||||||
# Cron (for scheduled evaluation reminders)
|
# Cron (for scheduled evaluation reminders)
|
||||||
CRON_SECRET="your-cron-secret-key"
|
CRON_SECRET="your-cron-secret-key"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Architectural Decisions
|
## Key Architectural Decisions
|
||||||
|
|
||||||
### 1. Next.js App Router over Pages Router
|
### 1. Next.js App Router over Pages Router
|
||||||
**Rationale**: Server Components reduce client bundle, better data fetching patterns, layouts system
|
**Rationale**: Server Components reduce client bundle, better data fetching patterns, layouts system
|
||||||
|
|
||||||
### 2. tRPC over REST
|
### 2. tRPC over REST
|
||||||
**Rationale**: End-to-end type safety without code generation, excellent DX with autocomplete
|
**Rationale**: End-to-end type safety without code generation, excellent DX with autocomplete
|
||||||
|
|
||||||
### 3. Prisma over raw SQL
|
### 3. Prisma over raw SQL
|
||||||
**Rationale**: Type-safe queries, migration system, works seamlessly with TypeScript
|
**Rationale**: Type-safe queries, migration system, works seamlessly with TypeScript
|
||||||
|
|
||||||
### 4. NextAuth.js over custom auth
|
### 4. NextAuth.js over custom auth
|
||||||
**Rationale**: Battle-tested, supports magic links, session management built-in
|
**Rationale**: Battle-tested, supports magic links, session management built-in
|
||||||
|
|
||||||
### 5. MinIO (external) over local file storage
|
### 5. MinIO (external) over local file storage
|
||||||
**Rationale**: S3-compatible, pre-signed URLs for security, scalable, already deployed
|
**Rationale**: S3-compatible, pre-signed URLs for security, scalable, already deployed
|
||||||
|
|
||||||
### 6. JSON fields for extensibility
|
### 6. JSON fields for extensibility
|
||||||
**Rationale**: `metadata_json`, `settings_json` allow adding attributes without migrations
|
**Rationale**: `metadata_json`, `settings_json` allow adding attributes without migrations
|
||||||
|
|
||||||
### 7. Soft deletes with status fields
|
### 7. Soft deletes with status fields
|
||||||
**Rationale**: Audit trail preservation, recovery capability, referential integrity
|
**Rationale**: Audit trail preservation, recovery capability, referential integrity
|
||||||
|
|
||||||
## User Roles (RBAC)
|
## User Roles (RBAC)
|
||||||
|
|
||||||
| Role | Permissions |
|
| Role | Permissions |
|
||||||
|------|------------|
|
|------|------------|
|
||||||
| **SUPER_ADMIN** | Full system access, all programs, user management |
|
| **SUPER_ADMIN** | Full system access, all programs, user management |
|
||||||
| **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury |
|
| **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury |
|
||||||
| **JURY_MEMBER** | View assigned projects only, submit evaluations, declare COI |
|
| **JURY_MEMBER** | View assigned projects only, submit evaluations, declare COI |
|
||||||
| **OBSERVER** | Read-only access to dashboards, all analytics/reports |
|
| **OBSERVER** | Read-only access to dashboards, all analytics/reports |
|
||||||
| **MENTOR** | View assigned projects, message applicants via `mentorProcedure` |
|
| **MENTOR** | View assigned projects, message applicants via `mentorProcedure` |
|
||||||
| **APPLICANT** | View own project status, upload documents per round, message mentor |
|
| **APPLICANT** | View own project status, upload documents per round, message mentor |
|
||||||
|
|
||||||
## Important Constraints
|
## Important Constraints
|
||||||
|
|
||||||
1. **Jury can only see assigned projects** - enforced at query level
|
1. **Jury can only see assigned projects** - enforced at query level
|
||||||
2. **Voting windows are strict** - submissions blocked outside active window
|
2. **Voting windows are strict** - submissions blocked outside active window
|
||||||
3. **Evaluations are versioned** - edits create new versions
|
3. **Evaluations are versioned** - edits create new versions
|
||||||
4. **All admin actions are audited** - immutable audit log
|
4. **All admin actions are audited** - immutable audit log
|
||||||
5. **Files accessed via pre-signed URLs** - no public bucket access
|
5. **Files accessed via pre-signed URLs** - no public bucket access
|
||||||
6. **Mobile responsiveness is mandatory** - every view must work on phones
|
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
|
7. **File downloads require project authorization** - jury/mentor must be assigned to the project
|
||||||
8. **Mentor endpoints require MENTOR role** - uses `mentorProcedure` middleware
|
8. **Mentor endpoints require MENTOR role** - uses `mentorProcedure` middleware
|
||||||
9. **COI declaration required before evaluation** - blocking dialog gates evaluation form; admin reviews COI declarations
|
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
|
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
|
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
|
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
|
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
|
14. **Per-round document management** - `ProjectFile` supports `roundId` scoping and `isLate` deadline tracking
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
|
|
||||||
### CSRF Protection
|
### CSRF Protection
|
||||||
tRPC mutations are protected against CSRF attacks because:
|
tRPC mutations are protected against CSRF attacks because:
|
||||||
- tRPC uses `application/json` content type, which triggers CORS preflight on cross-origin requests
|
- 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)
|
- Browsers block cross-origin JSON POSTs by default (Same-Origin Policy)
|
||||||
- NextAuth's own routes (`/api/auth/*`) have built-in CSRF token protection
|
- NextAuth's own routes (`/api/auth/*`) have built-in CSRF token protection
|
||||||
- No custom CORS headers are configured to allow external origins
|
- 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.
|
**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
|
### Rate Limiting
|
||||||
- tRPC API: 100 requests/minute per IP
|
- tRPC API: 100 requests/minute per IP
|
||||||
- Auth endpoints: 10 POST requests/minute per IP
|
- Auth endpoints: 10 POST requests/minute per IP
|
||||||
- Account lockout: 5 failed password attempts triggers 15-minute lockout
|
- Account lockout: 5 failed password attempts triggers 15-minute lockout
|
||||||
|
|
||||||
## External Services (Pre-existing)
|
## External Services (Pre-existing)
|
||||||
|
|
||||||
These services are already running on the VPS in separate Docker Compose stacks:
|
These services are already running on the VPS in separate Docker Compose stacks:
|
||||||
|
|
||||||
- **MinIO**: `http://localhost:9000` - S3-compatible storage
|
- **MinIO**: `http://localhost:9000` - S3-compatible storage
|
||||||
- **Poste.io**: `localhost:587` - SMTP server for emails
|
- **Poste.io**: `localhost:587` - SMTP server for emails
|
||||||
- **Nginx**: Host-level reverse proxy with SSL (certbot)
|
- **Nginx**: Host-level reverse proxy with SSL (certbot)
|
||||||
|
|
||||||
The MOPC platform connects to these via environment variables.
|
The MOPC platform connects to these via environment variables.
|
||||||
|
|
||||||
## Phase 1 Scope
|
## Phase 1 Scope
|
||||||
|
|
||||||
### In Scope
|
### In Scope
|
||||||
- Round management (create, configure, activate/close)
|
- Round management (create, configure, activate/close)
|
||||||
- Project import (CSV) and file uploads
|
- Project import (CSV) and file uploads
|
||||||
- Jury invitation (magic link)
|
- Jury invitation (magic link)
|
||||||
- Manual project assignment (single + bulk)
|
- Manual project assignment (single + bulk)
|
||||||
- Evaluation form (configurable criteria)
|
- Evaluation form (configurable criteria)
|
||||||
- Autosave + final submit
|
- Autosave + final submit
|
||||||
- Voting window enforcement
|
- Voting window enforcement
|
||||||
- Progress dashboards
|
- Progress dashboards
|
||||||
- CSV export
|
- CSV export
|
||||||
- Audit logging
|
- Audit logging
|
||||||
- **F1: Evaluation progress indicator** - sticky status bar with percentage tracking across criteria, global score, decision, feedback
|
- **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`
|
- **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
|
- **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`)
|
- **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
|
- **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
|
- **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
|
- **F7: AI-powered evaluation summary** - `EvaluationSummary` model; GPT-generated strengths/weaknesses, themes, scoring stats
|
||||||
- **F8: Smart assignment improvements** - `geoDiversityPenalty`, `previousRoundFamiliarity`, `coiPenalty` scoring factors
|
- **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
|
- **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
|
- **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+)
|
### Out of Scope (Phase 2+)
|
||||||
- Typeform/Notion integrations
|
- Typeform/Notion integrations
|
||||||
- WhatsApp notifications
|
- WhatsApp notifications
|
||||||
- Learning hub
|
- Learning hub
|
||||||
- Partner modules
|
- Partner modules
|
||||||
- Public website
|
- Public website
|
||||||
|
|
||||||
## Testing Strategy
|
## Testing Strategy
|
||||||
|
|
||||||
- **Unit Tests**: Business logic, utilities, validators
|
- **Unit Tests**: Business logic, utilities, validators
|
||||||
- **Integration Tests**: tRPC routers with test database
|
- **Integration Tests**: tRPC routers with test database
|
||||||
- **E2E Tests**: Critical user flows (Playwright)
|
- **E2E Tests**: Critical user flows (Playwright)
|
||||||
- **Manual Testing**: Responsive design on real devices
|
- **Manual Testing**: Responsive design on real devices
|
||||||
|
|
||||||
## Documentation Links
|
## Documentation Links
|
||||||
|
|
||||||
- [Architecture Overview](./docs/architecture/README.md)
|
- [Architecture Overview](./docs/architecture/README.md)
|
||||||
- [Database Design](./docs/architecture/database.md)
|
- [Database Design](./docs/architecture/database.md)
|
||||||
- [API Design](./docs/architecture/api.md)
|
- [API Design](./docs/architecture/api.md)
|
||||||
- [Infrastructure](./docs/architecture/infrastructure.md)
|
- [Infrastructure](./docs/architecture/infrastructure.md)
|
||||||
- [UI/UX Patterns](./docs/architecture/ui.md)
|
- [UI/UX Patterns](./docs/architecture/ui.md)
|
||||||
|
|
|
||||||
654
DEPLOYMENT.md
654
DEPLOYMENT.md
|
|
@ -1,327 +1,327 @@
|
||||||
# MOPC Platform - Server Deployment Guide
|
# MOPC Platform - Server Deployment Guide
|
||||||
|
|
||||||
Deployment guide for the MOPC platform on a Linux VPS with Docker.
|
Deployment guide for the MOPC platform on a Linux VPS with Docker.
|
||||||
|
|
||||||
**Domain**: `portal.monaco-opc.com`
|
**Domain**: `portal.monaco-opc.com`
|
||||||
**App Port**: 7600 (behind Nginx reverse proxy)
|
**App Port**: 7600 (behind Nginx reverse proxy)
|
||||||
**CI/CD**: Gitea Actions (Ubuntu runner) builds and pushes Docker images
|
**CI/CD**: Gitea Actions (Ubuntu runner) builds and pushes Docker images
|
||||||
|
|
||||||
## CI/CD Pipeline
|
## CI/CD Pipeline
|
||||||
|
|
||||||
The app is built automatically by a Gitea runner on every push to `main`:
|
The app is built automatically by a Gitea runner on every push to `main`:
|
||||||
|
|
||||||
1. Gitea Actions workflow builds the Docker image on Ubuntu
|
1. Gitea Actions workflow builds the Docker image on Ubuntu
|
||||||
2. Image is pushed to the Gitea container registry
|
2. Image is pushed to the Gitea container registry
|
||||||
3. On the server, `docker compose up -d` refreshes the image and restarts the app
|
3. On the server, `docker compose up -d` refreshes the image and restarts the app
|
||||||
|
|
||||||
### Gitea Setup
|
### Gitea Setup
|
||||||
|
|
||||||
Configure the following in your Gitea repository settings:
|
Configure the following in your Gitea repository settings:
|
||||||
|
|
||||||
**Repository Variables** (Settings > Actions > Variables):
|
**Repository Variables** (Settings > Actions > Variables):
|
||||||
|
|
||||||
| Variable | Value |
|
| Variable | Value |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| `REGISTRY_URL` | Your Gitea registry URL (e.g. `gitea.example.com/your-org`) |
|
| `REGISTRY_URL` | Your Gitea registry URL (e.g. `gitea.example.com/your-org`) |
|
||||||
|
|
||||||
**Repository Secrets** (Settings > Actions > Secrets):
|
**Repository Secrets** (Settings > Actions > Secrets):
|
||||||
|
|
||||||
| Secret | Value |
|
| Secret | Value |
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| `REGISTRY_USER` | Gitea username with registry access |
|
| `REGISTRY_USER` | Gitea username with registry access |
|
||||||
| `REGISTRY_PASSWORD` | Gitea access token or password |
|
| `REGISTRY_PASSWORD` | Gitea access token or password |
|
||||||
|
|
||||||
The workflow file is at `.gitea/workflows/build.yml`.
|
The workflow file is at `.gitea/workflows/build.yml`.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Linux VPS (Ubuntu 22.04+ recommended)
|
- Linux VPS (Ubuntu 22.04+ recommended)
|
||||||
- Docker Engine 24+ with Compose v2
|
- Docker Engine 24+ with Compose v2
|
||||||
- Nginx installed on the host
|
- Nginx installed on the host
|
||||||
- Certbot for SSL certificates
|
- Certbot for SSL certificates
|
||||||
|
|
||||||
### Install Docker (if needed)
|
### Install Docker (if needed)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://get.docker.com | sh
|
curl -fsSL https://get.docker.com | sh
|
||||||
sudo usermod -aG docker $USER
|
sudo usermod -aG docker $USER
|
||||||
# Log out and back in
|
# Log out and back in
|
||||||
```
|
```
|
||||||
|
|
||||||
### Install Nginx & Certbot (if needed)
|
### Install Nginx & Certbot (if needed)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install nginx certbot python3-certbot-nginx
|
sudo apt install nginx certbot python3-certbot-nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
## First-Time Deployment
|
## First-Time Deployment
|
||||||
|
|
||||||
### 1. Clone the repository
|
### 1. Clone the repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <your-repo-url> /opt/mopc
|
git clone <your-repo-url> /opt/mopc
|
||||||
cd /opt/mopc
|
cd /opt/mopc
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configure environment variables
|
### 2. Configure environment variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp docker/.env.production docker/.env
|
cp docker/.env.production docker/.env
|
||||||
nano docker/.env
|
nano docker/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
Fill in all `CHANGE_ME` values. Generate secrets with:
|
Fill in all `CHANGE_ME` values. Generate secrets with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openssl rand -base64 32
|
openssl rand -base64 32
|
||||||
```
|
```
|
||||||
|
|
||||||
Required variables:
|
Required variables:
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `REGISTRY_URL` | Gitea registry URL (e.g. `gitea.example.com/your-org`) |
|
| `REGISTRY_URL` | Gitea registry URL (e.g. `gitea.example.com/your-org`) |
|
||||||
| `DB_PASSWORD` | PostgreSQL password |
|
| `DB_PASSWORD` | PostgreSQL password |
|
||||||
| `NEXTAUTH_SECRET` | Auth session secret (openssl rand) |
|
| `NEXTAUTH_SECRET` | Auth session secret (openssl rand) |
|
||||||
| `NEXTAUTH_URL` | `https://portal.monaco-opc.com` |
|
| `NEXTAUTH_URL` | `https://portal.monaco-opc.com` |
|
||||||
| `MINIO_ENDPOINT` | MinIO internal URL (e.g. `http://localhost:9000`) |
|
| `MINIO_ENDPOINT` | MinIO internal URL (e.g. `http://localhost:9000`) |
|
||||||
| `MINIO_ACCESS_KEY` | MinIO access key |
|
| `MINIO_ACCESS_KEY` | MinIO access key |
|
||||||
| `MINIO_SECRET_KEY` | MinIO secret key |
|
| `MINIO_SECRET_KEY` | MinIO secret key |
|
||||||
| `MINIO_BUCKET` | MinIO bucket name (`mopc-files`) |
|
| `MINIO_BUCKET` | MinIO bucket name (`mopc-files`) |
|
||||||
| `SMTP_HOST` | SMTP server host |
|
| `SMTP_HOST` | SMTP server host |
|
||||||
| `SMTP_PORT` | SMTP port (587) |
|
| `SMTP_PORT` | SMTP port (587) |
|
||||||
| `SMTP_USER` | SMTP username |
|
| `SMTP_USER` | SMTP username |
|
||||||
| `SMTP_PASS` | SMTP password |
|
| `SMTP_PASS` | SMTP password |
|
||||||
| `EMAIL_FROM` | Sender address |
|
| `EMAIL_FROM` | Sender address |
|
||||||
|
|
||||||
### 3. Run the deploy script
|
### 3. Run the deploy script
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x scripts/deploy.sh scripts/seed.sh scripts/update.sh
|
chmod +x scripts/deploy.sh scripts/seed.sh scripts/update.sh
|
||||||
./scripts/deploy.sh
|
./scripts/deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This will:
|
This will:
|
||||||
- Log in to the container registry
|
- Log in to the container registry
|
||||||
- Pull the latest app image
|
- Pull the latest app image
|
||||||
- Start PostgreSQL + the app
|
- Start PostgreSQL + the app
|
||||||
- Run database migrations automatically on startup
|
- Run database migrations automatically on startup
|
||||||
- Wait for the health check
|
- Wait for the health check
|
||||||
|
|
||||||
### 4. Seed the database (one time only)
|
### 4. Seed the database (one time only)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/seed.sh
|
./scripts/seed.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This seeds:
|
This seeds:
|
||||||
- Super admin user (`matt.ciaccio@gmail.com`)
|
- Super admin user (`matt.ciaccio@gmail.com`)
|
||||||
- System settings
|
- System settings
|
||||||
- Program & Round 1 configuration
|
- Program & Round 1 configuration
|
||||||
- Evaluation form
|
- Evaluation form
|
||||||
- All candidature data from CSV
|
- All candidature data from CSV
|
||||||
|
|
||||||
### 5. Set up Nginx
|
### 5. Set up Nginx
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ln -s /opt/mopc/docker/nginx/mopc-platform.conf /etc/nginx/sites-enabled/
|
sudo ln -s /opt/mopc/docker/nginx/mopc-platform.conf /etc/nginx/sites-enabled/
|
||||||
sudo nginx -t
|
sudo nginx -t
|
||||||
sudo systemctl reload nginx
|
sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Set up SSL
|
### 6. Set up SSL
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo certbot --nginx -d portal.monaco-opc.com
|
sudo certbot --nginx -d portal.monaco-opc.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Auto-renewal is configured by default. Test with:
|
Auto-renewal is configured by default. Test with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo certbot renew --dry-run
|
sudo certbot renew --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7. Verify
|
### 7. Verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl https://portal.monaco-opc.com/api/health
|
curl https://portal.monaco-opc.com/api/health
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected response:
|
Expected response:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"status":"healthy","timestamp":"...","services":{"database":"connected"}}
|
{"status":"healthy","timestamp":"...","services":{"database":"connected"}}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Updating the Platform
|
## Updating the Platform
|
||||||
|
|
||||||
After Gitea CI builds a new image (push to `main`):
|
After Gitea CI builds a new image (push to `main`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/mopc
|
cd /opt/mopc
|
||||||
./scripts/update.sh
|
./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.
|
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:
|
Manual equivalent:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/mopc/docker
|
cd /opt/mopc/docker
|
||||||
docker compose up -d --pull always --force-recreate app
|
docker compose up -d --pull always --force-recreate app
|
||||||
```
|
```
|
||||||
|
|
||||||
`prisma migrate deploy` runs automatically in the container entrypoint before the app starts.
|
`prisma migrate deploy` runs automatically in the container entrypoint before the app starts.
|
||||||
|
|
||||||
## Manual Operations
|
## Manual Operations
|
||||||
|
|
||||||
### View logs
|
### View logs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/mopc/docker
|
cd /opt/mopc/docker
|
||||||
docker compose logs -f app # App logs
|
docker compose logs -f app # App logs
|
||||||
docker compose logs -f postgres # Database logs
|
docker compose logs -f postgres # Database logs
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run migrations manually
|
### Run migrations manually
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/mopc/docker
|
cd /opt/mopc/docker
|
||||||
docker compose exec app npx prisma migrate deploy
|
docker compose exec app npx prisma migrate deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
### Open a shell in the app container
|
### Open a shell in the app container
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/mopc/docker
|
cd /opt/mopc/docker
|
||||||
docker compose exec app sh
|
docker compose exec app sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Restart services
|
### Restart services
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/mopc/docker
|
cd /opt/mopc/docker
|
||||||
docker compose restart app # App only
|
docker compose restart app # App only
|
||||||
docker compose restart # All services
|
docker compose restart # All services
|
||||||
```
|
```
|
||||||
|
|
||||||
### Stop everything
|
### Stop everything
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/mopc/docker
|
cd /opt/mopc/docker
|
||||||
docker compose down # Stop containers (data preserved)
|
docker compose down # Stop containers (data preserved)
|
||||||
docker compose down -v # Stop AND delete volumes (data lost!)
|
docker compose down -v # Stop AND delete volumes (data lost!)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Database Backups
|
## Database Backups
|
||||||
|
|
||||||
### Create a backup
|
### Create a backup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec mopc-postgres pg_dump -U mopc mopc | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
docker exec mopc-postgres pg_dump -U mopc mopc | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
### Restore a backup
|
### Restore a backup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gunzip < backup_20260130_020000.sql.gz | docker exec -i mopc-postgres psql -U mopc mopc
|
gunzip < backup_20260130_020000.sql.gz | docker exec -i mopc-postgres psql -U mopc mopc
|
||||||
```
|
```
|
||||||
|
|
||||||
### Set up daily backups (cron)
|
### Set up daily backups (cron)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /data/backups/mopc
|
sudo mkdir -p /data/backups/mopc
|
||||||
|
|
||||||
cat > /opt/mopc/scripts/backup-db.sh << 'SCRIPT'
|
cat > /opt/mopc/scripts/backup-db.sh << 'SCRIPT'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
BACKUP_DIR=/data/backups/mopc
|
BACKUP_DIR=/data/backups/mopc
|
||||||
DATE=$(date +%Y%m%d_%H%M%S)
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
docker exec mopc-postgres pg_dump -U mopc mopc | gzip > $BACKUP_DIR/mopc_$DATE.sql.gz
|
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
|
find $BACKUP_DIR -name "mopc_*.sql.gz" -mtime +30 -delete
|
||||||
SCRIPT
|
SCRIPT
|
||||||
|
|
||||||
chmod +x /opt/mopc/scripts/backup-db.sh
|
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
|
echo "0 2 * * * /opt/mopc/scripts/backup-db.sh >> /var/log/mopc-backup.log 2>&1" | sudo tee /etc/cron.d/mopc-backup
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Gitea CI (Ubuntu runner)
|
Gitea CI (Ubuntu runner)
|
||||||
|
|
|
|
||||||
v (docker push)
|
v (docker push)
|
||||||
Container Registry
|
Container Registry
|
||||||
|
|
|
|
||||||
v (docker pull)
|
v (docker pull)
|
||||||
Linux VPS
|
Linux VPS
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
Nginx (host, port 443) -- SSL termination
|
Nginx (host, port 443) -- SSL termination
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
mopc-app (Docker, port 7600) -- Next.js standalone
|
mopc-app (Docker, port 7600) -- Next.js standalone
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
mopc-postgres (Docker, port 5432) -- PostgreSQL 16
|
mopc-postgres (Docker, port 5432) -- PostgreSQL 16
|
||||||
|
|
||||||
External services (separate Docker stacks):
|
External services (separate Docker stacks):
|
||||||
- MinIO (port 9000) -- S3-compatible file storage
|
- MinIO (port 9000) -- S3-compatible file storage
|
||||||
- Poste.io (port 587) -- SMTP email
|
- Poste.io (port 587) -- SMTP email
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### App won't start
|
### App won't start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/mopc/docker
|
cd /opt/mopc/docker
|
||||||
docker compose logs app
|
docker compose logs app
|
||||||
docker compose exec postgres pg_isready -U mopc
|
docker compose exec postgres pg_isready -U mopc
|
||||||
```
|
```
|
||||||
|
|
||||||
### Can't pull image
|
### Can't pull image
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Re-authenticate with registry
|
# Re-authenticate with registry
|
||||||
docker login <your-registry-url>
|
docker login <your-registry-url>
|
||||||
|
|
||||||
# Check image exists
|
# Check image exists
|
||||||
docker pull <your-registry-url>/mopc-app:latest
|
docker pull <your-registry-url>/mopc-app:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Migration fails
|
### Migration fails
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check migration status
|
# Check migration status
|
||||||
docker compose exec app npx prisma migrate status
|
docker compose exec app npx prisma migrate status
|
||||||
|
|
||||||
# Reset (DESTROYS DATA):
|
# Reset (DESTROYS DATA):
|
||||||
docker compose exec app npx prisma migrate reset
|
docker compose exec app npx prisma migrate reset
|
||||||
```
|
```
|
||||||
|
|
||||||
### SSL certificate issues
|
### SSL certificate issues
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo certbot certificates
|
sudo certbot certificates
|
||||||
sudo certbot renew --force-renewal
|
sudo certbot renew --force-renewal
|
||||||
```
|
```
|
||||||
|
|
||||||
### Port conflict
|
### Port conflict
|
||||||
|
|
||||||
The app runs on port 7600. If something else uses it:
|
The app runs on port 7600. If something else uses it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ss -tlnp | grep 7600
|
sudo ss -tlnp | grep 7600
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security Checklist
|
## Security Checklist
|
||||||
|
|
||||||
- [ ] SSL certificate active and auto-renewing
|
- [ ] SSL certificate active and auto-renewing
|
||||||
- [ ] `docker/.env` has strong, unique passwords
|
- [ ] `docker/.env` has strong, unique passwords
|
||||||
- [ ] `NEXTAUTH_SECRET` is randomly generated
|
- [ ] `NEXTAUTH_SECRET` is randomly generated
|
||||||
- [ ] Gitea registry credentials secured
|
- [ ] Gitea registry credentials secured
|
||||||
- [ ] Firewall allows only ports 80, 443, 22
|
- [ ] Firewall allows only ports 80, 443, 22
|
||||||
- [ ] Docker daemon not exposed to network
|
- [ ] Docker daemon not exposed to network
|
||||||
- [ ] Daily backups configured
|
- [ ] Daily backups configured
|
||||||
- [ ] Nginx security headers active
|
- [ ] 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.
|
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
|
## 0) Product scope and phasing
|
||||||
|
|
||||||
### Phase 1 (critical, delivery in ~2 weeks)
|
### Phase 1 (critical, delivery in ~2 weeks)
|
||||||
|
|
||||||
**Secure Jury Online Voting Module** to run two selection rounds:
|
**Secure Jury Online Voting Module** to run two selection rounds:
|
||||||
|
|
||||||
* Round 1: ~130 projects → ~60 semi-finalists (Feb 18–23 voting window)
|
* Round 1: ~130 projects → ~60 semi-finalists (Feb 18–23 voting window)
|
||||||
* Round 2: ~60 projects → 6 finalists (~April 13 week 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.
|
* Voting is asynchronous, online, with assigned project access, scoring + feedback capture, and reporting dashboards.
|
||||||
|
|
||||||
### Phase 2+ (mid-term)
|
### Phase 2+ (mid-term)
|
||||||
|
|
||||||
Centralized MOPC platform:
|
Centralized MOPC platform:
|
||||||
|
|
||||||
* Applications/projects database
|
* Applications/projects database
|
||||||
* Document management (MinIO S3)
|
* Document management (MinIO S3)
|
||||||
* Jury spaces (history, comments, scoring)
|
* Jury spaces (history, comments, scoring)
|
||||||
* Learning hub / resources
|
* Learning hub / resources
|
||||||
* Communication workflows (email + possibly WhatsApp)
|
* Communication workflows (email + possibly WhatsApp)
|
||||||
* Partner/sponsor visibility modules
|
* Partner/sponsor visibility modules
|
||||||
* Potential website integration / shared back office
|
* Potential website integration / shared back office
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1) Users, roles, permissions (RBAC)
|
## 1) Users, roles, permissions (RBAC)
|
||||||
|
|
||||||
### Core roles
|
### Core roles
|
||||||
|
|
||||||
1. **Platform Super Admin**
|
1. **Platform Super Admin**
|
||||||
|
|
||||||
* Full system configuration, security policies, integrations, user/role management, data export, audit access.
|
* Full system configuration, security policies, integrations, user/role management, data export, audit access.
|
||||||
2. **Program Admin (MOPC Admin)**
|
2. **Program Admin (MOPC Admin)**
|
||||||
|
|
||||||
* Manages cycles/rounds, projects, jury members, assignments, voting windows, criteria forms, dashboards, exports.
|
* Manages cycles/rounds, projects, jury members, assignments, voting windows, criteria forms, dashboards, exports.
|
||||||
3. **Jury Member**
|
3. **Jury Member**
|
||||||
|
|
||||||
* Can access only assigned projects for active rounds; submit evaluations; view own submitted evaluations; optionally view aggregated results only if permitted.
|
* 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)**
|
4. **Read-only Observer (optional)**
|
||||||
|
|
||||||
* Internal meeting viewer: can see dashboards/aggregates but cannot edit votes.
|
* Internal meeting viewer: can see dashboards/aggregates but cannot edit votes.
|
||||||
|
|
||||||
### Permission model requirements
|
### Permission model requirements
|
||||||
|
|
||||||
* **Least privilege by default**
|
* **Least privilege by default**
|
||||||
* **Round-scoped permissions**: access can be constrained per selection round/cycle.
|
* **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”).
|
* **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.
|
* **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)
|
## 2) Core domain objects (data model concepts)
|
||||||
|
|
||||||
### Entities
|
### Entities
|
||||||
|
|
||||||
* **Program** (e.g., “MOPC 2026”)
|
* **Program** (e.g., “MOPC 2026”)
|
||||||
* **Selection Cycle / Round**
|
* **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.
|
* Attributes: name, start/end of voting window, status (draft/active/closed/archived), required reviews per project (default ≥3), scoring form version, jury cohort.
|
||||||
* **Project**
|
* **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).
|
* 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**
|
* **File Asset**
|
||||||
|
|
||||||
* Stored in MinIO (S3-compatible): object key, bucket, version/etag, mime type, size, upload timestamp, retention policy, access policy.
|
* Stored in MinIO (S3-compatible): object key, bucket, version/etag, mime type, size, upload timestamp, retention policy, access policy.
|
||||||
* **Jury Member**
|
* **Jury Member**
|
||||||
|
|
||||||
* Profile: name, email, organization (optional), role, expertise tags, status (invited/active/suspended).
|
* Profile: name, email, organization (optional), role, expertise tags, status (invited/active/suspended).
|
||||||
* **Expertise Tag**
|
* **Expertise Tag**
|
||||||
|
|
||||||
* Managed vocabulary or free-form with admin approval.
|
* Managed vocabulary or free-form with admin approval.
|
||||||
* **Assignment**
|
* **Assignment**
|
||||||
|
|
||||||
* Connects Jury Member ↔ Project ↔ Round
|
* Connects Jury Member ↔ Project ↔ Round
|
||||||
* Attributes: assignment method (manual/auto), created by, created timestamp, required review flag, completion status.
|
* Attributes: assignment method (manual/auto), created by, created timestamp, required review flag, completion status.
|
||||||
* **Evaluation (Vote)**
|
* **Evaluation (Vote)**
|
||||||
|
|
||||||
* Per assignment: criterion scores + global score + binary decision + qualitative feedback
|
* 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.
|
* Metadata: submitted_at, last_edited_at, finalization flag, versioning, IP/user-agent logging (optional), conflict handling.
|
||||||
* **Audit Log**
|
* **Audit Log**
|
||||||
|
|
||||||
* Immutable events: login, permission changes, voting window changes, assignments, overrides, exports, vote invalidations.
|
* Immutable events: login, permission changes, voting window changes, assignments, overrides, exports, vote invalidations.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3) Phase 1 functional requirements
|
## 3) Phase 1 functional requirements
|
||||||
|
|
||||||
### 3.1 Jury authentication & access
|
### 3.1 Jury authentication & access
|
||||||
|
|
||||||
* Invite flow:
|
* Invite flow:
|
||||||
|
|
||||||
* Admin imports jury list (CSV) or adds manually.
|
* Admin imports jury list (CSV) or adds manually.
|
||||||
* System sends invitation email with secure link + account activation.
|
* System sends invitation email with secure link + account activation.
|
||||||
* Authentication options (choose one for Phase 1, keep others pluggable):
|
* Authentication options (choose one for Phase 1, keep others pluggable):
|
||||||
|
|
||||||
* Email magic link (recommended for speed)
|
* Email magic link (recommended for speed)
|
||||||
* Password + MFA optional
|
* Password + MFA optional
|
||||||
* Session requirements:
|
* Session requirements:
|
||||||
|
|
||||||
* Configurable session duration
|
* Configurable session duration
|
||||||
* Forced logout on role revocation
|
* Forced logout on role revocation
|
||||||
* Access gating:
|
* Access gating:
|
||||||
|
|
||||||
* Jury can only view projects for **active** rounds and only those assigned.
|
* Jury can only view projects for **active** rounds and only those assigned.
|
||||||
|
|
||||||
### 3.2 Project ingestion & management
|
### 3.2 Project ingestion & management
|
||||||
|
|
||||||
Phase 1 can support either:
|
Phase 1 can support either:
|
||||||
|
|
||||||
* **Option A (fastest): Manual import**
|
* **Option A (fastest): Manual import**
|
||||||
|
|
||||||
* Admin uploads CSV with project metadata + file links or uploads.
|
* Admin uploads CSV with project metadata + file links or uploads.
|
||||||
* **Option B (semi-integrated): Sync from Notion/Typeform**
|
* **Option B (semi-integrated): Sync from Notion/Typeform**
|
||||||
|
|
||||||
* Read projects from existing Notion DB and/or Typeform export.
|
* Read projects from existing Notion DB and/or Typeform export.
|
||||||
|
|
||||||
Minimum capabilities:
|
Minimum capabilities:
|
||||||
|
|
||||||
* Admin CRUD on projects (create/update/archive)
|
* Admin CRUD on projects (create/update/archive)
|
||||||
* Project tagging (from “Which issue does your project address?” + additional admin tags)
|
* Project tagging (from “Which issue does your project address?” + additional admin tags)
|
||||||
* Attach required assets:
|
* Attach required assets:
|
||||||
|
|
||||||
* Executive summary (PDF/doc)
|
* Executive summary (PDF/doc)
|
||||||
* PDF presentation
|
* PDF presentation
|
||||||
* 30s intro video (mp4)
|
* 30s intro video (mp4)
|
||||||
* File storage via MinIO (see Section 6)
|
* File storage via MinIO (see Section 6)
|
||||||
|
|
||||||
### 3.3 Assignment system (≥3 reviews/project)
|
### 3.3 Assignment system (≥3 reviews/project)
|
||||||
|
|
||||||
Admin can:
|
Admin can:
|
||||||
|
|
||||||
* Manually assign projects to jury members (bulk assign supported)
|
* Manually assign projects to jury members (bulk assign supported)
|
||||||
* Auto-assign (optional but strongly recommended):
|
* Auto-assign (optional but strongly recommended):
|
||||||
|
|
||||||
* Input: jury expertise tags + project tags + constraints
|
* Input: jury expertise tags + project tags + constraints
|
||||||
* Constraints:
|
* Constraints:
|
||||||
|
|
||||||
* Each project assigned to at least N jurors (N configurable; default 3)
|
* Each project assigned to at least N jurors (N configurable; default 3)
|
||||||
* Load balancing across jurors (minimize variance)
|
* Load balancing across jurors (minimize variance)
|
||||||
* Avoid conflicts (optional): disallow assignment if juror marked conflict with project
|
* Avoid conflicts (optional): disallow assignment if juror marked conflict with project
|
||||||
* Output: assignment set + summary metrics (coverage, per-juror load, unmatched tags)
|
* Output: assignment set + summary metrics (coverage, per-juror load, unmatched tags)
|
||||||
* Reassignment rules:
|
* Reassignment rules:
|
||||||
|
|
||||||
* Admin can reassign at any time
|
* Admin can reassign at any time
|
||||||
* If an evaluation exists, admin can:
|
* If an evaluation exists, admin can:
|
||||||
|
|
||||||
* keep existing evaluation tied to original juror
|
* keep existing evaluation tied to original juror
|
||||||
* or invalidate/lock it (requires reason + audit event)
|
* or invalidate/lock it (requires reason + audit event)
|
||||||
|
|
||||||
### 3.4 Evaluation form & scoring logic
|
### 3.4 Evaluation form & scoring logic
|
||||||
|
|
||||||
Per project evaluation must capture:
|
Per project evaluation must capture:
|
||||||
|
|
||||||
* **Criterion scores** (scale-based, define exact scale as configurable; e.g., 1–5 or 1–10)
|
* **Criterion scores** (scale-based, define exact scale as configurable; e.g., 1–5 or 1–10)
|
||||||
|
|
||||||
1. Need clarity
|
1. Need clarity
|
||||||
2. Solution relevance
|
2. Solution relevance
|
||||||
3. Gap analysis (market/competitors)
|
3. Gap analysis (market/competitors)
|
||||||
4. Target customers clarity
|
4. Target customers clarity
|
||||||
5. Ocean impact
|
5. Ocean impact
|
||||||
* **Global score**: 1–10
|
* **Global score**: 1–10
|
||||||
* **Binary decision**: “Select as semi-finalist?” (Yes/No)
|
* **Binary decision**: “Select as semi-finalist?” (Yes/No)
|
||||||
* **Qualitative feedback**: long text
|
* **Qualitative feedback**: long text
|
||||||
|
|
||||||
Form requirements:
|
Form requirements:
|
||||||
|
|
||||||
* Admin-configurable criteria text, ordering, scales, and whether fields are mandatory
|
* Admin-configurable criteria text, ordering, scales, and whether fields are mandatory
|
||||||
* Autosave drafts
|
* Autosave drafts
|
||||||
* Final submit locks evaluation by default (admin can allow edits until window closes)
|
* Final submit locks evaluation by default (admin can allow edits until window closes)
|
||||||
* Support multiple rounds with potentially different forms (versioned forms per round)
|
* Support multiple rounds with potentially different forms (versioned forms per round)
|
||||||
|
|
||||||
### 3.5 Voting windows and enforcement (must-have)
|
### 3.5 Voting windows and enforcement (must-have)
|
||||||
|
|
||||||
Admins must be able to configure and enforce:
|
Admins must be able to configure and enforce:
|
||||||
|
|
||||||
* Voting window start/end **per round** (date-time, timezone-aware)
|
* Voting window start/end **per round** (date-time, timezone-aware)
|
||||||
* States:
|
* States:
|
||||||
|
|
||||||
* Draft (admins only)
|
* Draft (admins only)
|
||||||
* Active (jury can submit)
|
* Active (jury can submit)
|
||||||
* Closed (jury read-only)
|
* Closed (jury read-only)
|
||||||
* Archived (admin/export only)
|
* Archived (admin/export only)
|
||||||
* Enforcement rules:
|
* Enforcement rules:
|
||||||
|
|
||||||
* Jury cannot submit outside the active window
|
* Jury cannot submit outside the active window
|
||||||
* Admin “grace period” toggle to accept late submissions for specific jurors/projects
|
* Admin “grace period” toggle to accept late submissions for specific jurors/projects
|
||||||
* Admin can extend the window (global or subset) with audit logging
|
* Admin can extend the window (global or subset) with audit logging
|
||||||
* Dashboard countdown + clear messaging for jurors
|
* Dashboard countdown + clear messaging for jurors
|
||||||
|
|
||||||
### 3.6 Dashboards & outputs
|
### 3.6 Dashboards & outputs
|
||||||
|
|
||||||
Must produce:
|
Must produce:
|
||||||
|
|
||||||
* **Jury member view**
|
* **Jury member view**
|
||||||
|
|
||||||
* Assigned projects list, completion status, quick access to files, evaluation status (not started/draft/submitted)
|
* Assigned projects list, completion status, quick access to files, evaluation status (not started/draft/submitted)
|
||||||
* **Admin dashboards**
|
* **Admin dashboards**
|
||||||
|
|
||||||
* Coverage: projects with <N evaluations
|
* Coverage: projects with <N evaluations
|
||||||
* Progress: submission rates by juror
|
* Progress: submission rates by juror
|
||||||
* Aggregates per project:
|
* Aggregates per project:
|
||||||
|
|
||||||
* Average per criterion
|
* Average per criterion
|
||||||
* Average global score
|
* Average global score
|
||||||
* Distribution (min/max, std dev optional)
|
* Distribution (min/max, std dev optional)
|
||||||
* Count of “Yes” votes
|
* Count of “Yes” votes
|
||||||
* Qualitative comments list (with juror identity visible only to admins, configurable)
|
* Qualitative comments list (with juror identity visible only to admins, configurable)
|
||||||
* Shortlisting tools:
|
* Shortlisting tools:
|
||||||
|
|
||||||
* Filter/sort by aggregate score, yes-vote ratio, tag, missing reviews
|
* Filter/sort by aggregate score, yes-vote ratio, tag, missing reviews
|
||||||
* Export shortlist (e.g., top 60 / top 6) with manual override controls
|
* Export shortlist (e.g., top 60 / top 6) with manual override controls
|
||||||
* Exports (Phase 1):
|
* Exports (Phase 1):
|
||||||
|
|
||||||
* CSV/Excel export for:
|
* CSV/Excel export for:
|
||||||
|
|
||||||
* Evaluations (row per evaluation)
|
* Evaluations (row per evaluation)
|
||||||
* Aggregates (row per project)
|
* Aggregates (row per project)
|
||||||
* Assignment matrix
|
* Assignment matrix
|
||||||
* PDF export (optional) for meeting packs
|
* PDF export (optional) for meeting packs
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4) Admin console requirements (robust)
|
## 4) Admin console requirements (robust)
|
||||||
|
|
||||||
### 4.1 Governance & configuration
|
### 4.1 Governance & configuration
|
||||||
|
|
||||||
* Create/manage Programs and Rounds
|
* Create/manage Programs and Rounds
|
||||||
* Set:
|
* Set:
|
||||||
|
|
||||||
* Required reviews per project (N)
|
* Required reviews per project (N)
|
||||||
* Voting windows (start/end) + grace rules
|
* Voting windows (start/end) + grace rules
|
||||||
* Evaluation form version
|
* Evaluation form version
|
||||||
* Visibility rules (whether jurors can see aggregates, whether jurors can see their past submissions after close)
|
* Visibility rules (whether jurors can see aggregates, whether jurors can see their past submissions after close)
|
||||||
* Manage tags:
|
* Manage tags:
|
||||||
|
|
||||||
* Tag taxonomy, synonyms/merging, locked tags
|
* Tag taxonomy, synonyms/merging, locked tags
|
||||||
|
|
||||||
### 4.2 User management & security controls
|
### 4.2 User management & security controls
|
||||||
|
|
||||||
* Bulk invite/import
|
* Bulk invite/import
|
||||||
* Role assignment & revocation
|
* Role assignment & revocation
|
||||||
* Force password reset / disable account
|
* Force password reset / disable account
|
||||||
* View user activity logs
|
* View user activity logs
|
||||||
* Configure:
|
* Configure:
|
||||||
|
|
||||||
* Allowed email domains (optional)
|
* Allowed email domains (optional)
|
||||||
* MFA requirement (optional)
|
* MFA requirement (optional)
|
||||||
* Session lifetime (optional)
|
* Session lifetime (optional)
|
||||||
|
|
||||||
### 4.3 Assignment controls
|
### 4.3 Assignment controls
|
||||||
|
|
||||||
* Manual assignment UI (single + bulk)
|
* Manual assignment UI (single + bulk)
|
||||||
* Auto-assignment wizard:
|
* Auto-assignment wizard:
|
||||||
|
|
||||||
* select round
|
* select round
|
||||||
* choose balancing strategy (e.g., “maximize tag match”, “balance load first”)
|
* choose balancing strategy (e.g., “maximize tag match”, “balance load first”)
|
||||||
* preview results
|
* preview results
|
||||||
* apply
|
* apply
|
||||||
* Conflict of interest handling:
|
* Conflict of interest handling:
|
||||||
|
|
||||||
* Admin can mark conflicts (juror ↔ project)
|
* Admin can mark conflicts (juror ↔ project)
|
||||||
* Auto-assign must respect conflicts
|
* Auto-assign must respect conflicts
|
||||||
|
|
||||||
### 4.4 Data integrity controls
|
### 4.4 Data integrity controls
|
||||||
|
|
||||||
* Vote invalidation (requires reason)
|
* Vote invalidation (requires reason)
|
||||||
* Reopen evaluation (admin-only, logged)
|
* Reopen evaluation (admin-only, logged)
|
||||||
* Freeze round (hard lock)
|
* Freeze round (hard lock)
|
||||||
* Immutable audit log export
|
* Immutable audit log export
|
||||||
|
|
||||||
### 4.5 Integrations management
|
### 4.5 Integrations management
|
||||||
|
|
||||||
* Connectors toggles (Typeform/Notion/email provider/WhatsApp) with credentials stored securely
|
* Connectors toggles (Typeform/Notion/email provider/WhatsApp) with credentials stored securely
|
||||||
* MinIO bucket configuration + retention policies
|
* MinIO bucket configuration + retention policies
|
||||||
* Webhook management (optional)
|
* Webhook management (optional)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5) Non-functional requirements (Phase 1)
|
## 5) Non-functional requirements (Phase 1)
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
* TLS everywhere
|
* TLS everywhere
|
||||||
* RBAC + project-level access control
|
* RBAC + project-level access control
|
||||||
* Secure file access (pre-signed URLs with short TTL; no public buckets)
|
* Secure file access (pre-signed URLs with short TTL; no public buckets)
|
||||||
* Audit logging for admin actions + exports
|
* Audit logging for admin actions + exports
|
||||||
* Basic anti-abuse:
|
* Basic anti-abuse:
|
||||||
|
|
||||||
* rate limiting login endpoints
|
* rate limiting login endpoints
|
||||||
* brute-force protection if password auth used
|
* brute-force protection if password auth used
|
||||||
|
|
||||||
### Reliability & performance
|
### Reliability & performance
|
||||||
|
|
||||||
* Support:
|
* Support:
|
||||||
|
|
||||||
* Round 1: 15 jurors, 130 projects, min 390 evaluations
|
* Round 1: 15 jurors, 130 projects, min 390 evaluations
|
||||||
* Round 2: ~30 jurors, 60 projects
|
* Round 2: ~30 jurors, 60 projects
|
||||||
* Fast page load for dashboards and project pages
|
* Fast page load for dashboards and project pages
|
||||||
* File streaming for PDFs/videos (avoid timeouts)
|
* File streaming for PDFs/videos (avoid timeouts)
|
||||||
|
|
||||||
### Compliance & privacy (baseline)
|
### Compliance & privacy (baseline)
|
||||||
|
|
||||||
* Store only necessary personal data for jurors/candidates
|
* Store only necessary personal data for jurors/candidates
|
||||||
* Retention policies configurable (especially for candidate files)
|
* Retention policies configurable (especially for candidate files)
|
||||||
* Access logs available for security review
|
* Access logs available for security review
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6) File storage requirements (MinIO S3)
|
## 6) File storage requirements (MinIO S3)
|
||||||
|
|
||||||
### Storage design (requirements-level)
|
### Storage design (requirements-level)
|
||||||
|
|
||||||
* Use MinIO as S3-compatible object store for:
|
* Use MinIO as S3-compatible object store for:
|
||||||
|
|
||||||
* project documents (exec summary, deck)
|
* project documents (exec summary, deck)
|
||||||
* video files
|
* video files
|
||||||
* optional assets (logos, exports packs)
|
* optional assets (logos, exports packs)
|
||||||
* Buckets:
|
* Buckets:
|
||||||
|
|
||||||
* Separate buckets or prefixes by Program/Round to simplify retention + permissions
|
* Separate buckets or prefixes by Program/Round to simplify retention + permissions
|
||||||
* Access pattern:
|
* Access pattern:
|
||||||
|
|
||||||
* Upload: direct-to-S3 (preferred) or via backend proxy
|
* Upload: direct-to-S3 (preferred) or via backend proxy
|
||||||
* Download/view: **pre-signed URLs** generated by backend per authorized user
|
* Download/view: **pre-signed URLs** generated by backend per authorized user
|
||||||
* Optional features:
|
* Optional features:
|
||||||
|
|
||||||
* Object versioning enabled
|
* Object versioning enabled
|
||||||
* Antivirus scanning hook (Phase 2)
|
* Antivirus scanning hook (Phase 2)
|
||||||
* Lifecycle rules (auto-expire after X months)
|
* Lifecycle rules (auto-expire after X months)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7) “Current process” integration mapping (future-proof)
|
## 7) “Current process” integration mapping (future-proof)
|
||||||
|
|
||||||
### Existing flow
|
### Existing flow
|
||||||
|
|
||||||
* Typeform application → confirmation email → Tally upload → Notion tracking → Google Drive manual upload
|
* Typeform application → confirmation email → Tally upload → Notion tracking → Google Drive manual upload
|
||||||
|
|
||||||
### Platform integration targets
|
### Platform integration targets
|
||||||
|
|
||||||
Phase 1 (minimal):
|
Phase 1 (minimal):
|
||||||
|
|
||||||
* Allow admin to ingest projects and upload assets (replace Drive for jury-facing access)
|
* Allow admin to ingest projects and upload assets (replace Drive for jury-facing access)
|
||||||
|
|
||||||
Phase 2 options:
|
Phase 2 options:
|
||||||
|
|
||||||
* Typeform: pull submissions via API/webhooks
|
* Typeform: pull submissions via API/webhooks
|
||||||
* Tally: capture uploads directly to MinIO (or via platform upload portal)
|
* Tally: capture uploads directly to MinIO (or via platform upload portal)
|
||||||
* Notion: sync project status + metadata (one-way or two-way)
|
* Notion: sync project status + metadata (one-way or two-way)
|
||||||
* Email automation: reminder workflows for incomplete applications
|
* Email automation: reminder workflows for incomplete applications
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8) Additional ideas as “technical backlog candidates”
|
## 8) Additional ideas as “technical backlog candidates”
|
||||||
|
|
||||||
### Automated follow-ups for incomplete applications (Phase 2)
|
### Automated follow-ups for incomplete applications (Phase 2)
|
||||||
|
|
||||||
* State machine for applications: registered → awaiting docs → complete → expired
|
* State machine for applications: registered → awaiting docs → complete → expired
|
||||||
* Scheduler:
|
* Scheduler:
|
||||||
|
|
||||||
* send reminders at configurable intervals (e.g., +2d, +5d, +7d)
|
* send reminders at configurable intervals (e.g., +2d, +5d, +7d)
|
||||||
* stop on completion
|
* stop on completion
|
||||||
* Channels:
|
* Channels:
|
||||||
|
|
||||||
* Email must-have
|
* Email must-have
|
||||||
* WhatsApp optional (requires compliance + provider; store consent + opt-out)
|
* WhatsApp optional (requires compliance + provider; store consent + opt-out)
|
||||||
|
|
||||||
### Learning hub access (semi-finalists only)
|
### Learning hub access (semi-finalists only)
|
||||||
|
|
||||||
* Resource library stored in MinIO + metadata in DB
|
* Resource library stored in MinIO + metadata in DB
|
||||||
* Access controlled by cohort + passwordless login or access tokens
|
* Access controlled by cohort + passwordless login or access tokens
|
||||||
* Expiring invite links
|
* Expiring invite links
|
||||||
|
|
||||||
### Website integration
|
### Website integration
|
||||||
|
|
||||||
* Shared identity/back office (SSO-ready) OR separate admin domains
|
* Shared identity/back office (SSO-ready) OR separate admin domains
|
||||||
* Public-facing site remains content-only; platform is operational hub
|
* Public-facing site remains content-only; platform is operational hub
|
||||||
* Requirement: clear separation between “public content” and “private jury/applicant data”
|
* Requirement: clear separation between “public content” and “private jury/applicant data”
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9) Acceptance criteria checklist (Phase 1)
|
## 9) Acceptance criteria checklist (Phase 1)
|
||||||
|
|
||||||
1. Admin can create a round, set voting window (start/end), and activate it.
|
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.
|
2. Admin can import projects + upload/attach required files to MinIO.
|
||||||
3. Admin can import jurors, invite them, and jurors can log in securely.
|
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.
|
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.
|
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).
|
6. System blocks submissions outside the voting window (unless admin-granted exception).
|
||||||
7. Admin dashboard shows progress + aggregates per project; admin can export results.
|
7. Admin dashboard shows progress + aggregates per project; admin can export results.
|
||||||
8. All critical admin actions are audit-logged.
|
8. All critical admin actions are audit-logged.
|
||||||
9. File access is protected (no public links; pre-signed URLs with TTL).
|
9. File access is protected (no public links; pre-signed URLs with TTL).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
If you want, I can turn this into:
|
If you want, I can turn this into:
|
||||||
|
|
||||||
* a clean PRD-style document (Dev-ready) **plus**
|
* a clean PRD-style document (Dev-ready) **plus**
|
||||||
* a ticket breakdown (Epics → user stories → acceptance tests) for Phase 1 delivery.
|
* a ticket breakdown (Epics → user stories → acceptance tests) for Phase 1 delivery.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
▲ 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?)
|
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.)
|
-Add filters to the page (who sent documents, etc.)
|
||||||
|
|
||||||
-Partners section should be a semi-crm system to track possible sponsors and partners
|
-Partners section should be a semi-crm system to track possible sponsors and partners
|
||||||
|
|
||||||
-No translation into french (no localization)
|
-No translation into french (no localization)
|
||||||
|
|
||||||
-Ameliorate the user experience (make it more simple)
|
-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)
|
-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
|
-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
|
# Mixed Round Design Implementation Docs
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
This folder contains a single consolidated redesign program that intentionally blends:
|
This folder contains a single consolidated redesign program that intentionally blends:
|
||||||
|
|
||||||
- Delivery rigor and governance discipline from `codex-round-system-redesign`
|
- Delivery rigor and governance discipline from `codex-round-system-redesign`
|
||||||
- Target architecture depth and runtime detail from `claude-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)
|
- 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.
|
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 and Blend Strategy
|
||||||
|
|
||||||
### Foundation
|
### Foundation
|
||||||
The execution backbone is the `codex` style program model:
|
The execution backbone is the `codex` style program model:
|
||||||
|
|
||||||
1. Contract freeze first
|
1. Contract freeze first
|
||||||
2. Schema/runtime implementation in explicit phases
|
2. Schema/runtime implementation in explicit phases
|
||||||
3. Platform-wide dependency refit (not just feature slices)
|
3. Platform-wide dependency refit (not just feature slices)
|
||||||
4. Mandatory phase gates with hard release blockers
|
4. Mandatory phase gates with hard release blockers
|
||||||
|
|
||||||
### Borrowed Enhancements
|
### Borrowed Enhancements
|
||||||
The plan imports high-value details from other proposals:
|
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
|
- `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
|
- `glm-5`: award decision governance (`JURY_VOTE`, `AWARD_MASTER`, `ADMIN`) and explicit award track behavior options
|
||||||
|
|
||||||
## Execution Model
|
## Execution Model
|
||||||
|
|
||||||
- Single destructive cutover
|
- Single destructive cutover
|
||||||
- Full reseed
|
- Full reseed
|
||||||
- No backward-compatibility adapter layer
|
- No backward-compatibility adapter layer
|
||||||
- No dual-write period
|
- No dual-write period
|
||||||
- One atomic release commit once all gates are green
|
- 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.
|
This model is intentionally selected because infrastructure reset/rebuild is allowed and preferred for architecture quality.
|
||||||
|
|
||||||
## Architecture Summary
|
## Architecture Summary
|
||||||
|
|
||||||
- Competition lifecycle is stage-native, not round-pointer native.
|
- Competition lifecycle is stage-native, not round-pointer native.
|
||||||
- Projects progress through explicit `ProjectStageState` records.
|
- Projects progress through explicit `ProjectStageState` records.
|
||||||
- Special awards are first-class tracks, not bolt-on side tables.
|
- Special awards are first-class tracks, not bolt-on side tables.
|
||||||
- Routing is rule-driven with explainability payloads.
|
- Routing is rule-driven with explainability payloads.
|
||||||
- Live finals are controlled by an admin cursor as the source of truth.
|
- Live finals are controlled by an admin cursor as the source of truth.
|
||||||
- Every override and decision is reasoned, immutable, and auditable.
|
- Every override and decision is reasoned, immutable, and auditable.
|
||||||
|
|
||||||
## Folder Layout
|
## Folder Layout
|
||||||
|
|
||||||
- `master-implementation-plan.md`: end-to-end execution map
|
- `master-implementation-plan.md`: end-to-end execution map
|
||||||
- `shared/`: cross-phase contracts, governance, test model, risks
|
- `shared/`: cross-phase contracts, governance, test model, risks
|
||||||
- `phase-00-contract-freeze/` to `phase-07-validation-release/`: implementation phases
|
- `phase-00-contract-freeze/` to `phase-07-validation-release/`: implementation phases
|
||||||
- `flowcharts/`: core control and routing diagrams
|
- `flowcharts/`: core control and routing diagrams
|
||||||
|
|
||||||
## How to Use This Plan
|
## How to Use This Plan
|
||||||
|
|
||||||
1. Start at `master-implementation-plan.md`.
|
1. Start at `master-implementation-plan.md`.
|
||||||
2. Execute phases in order.
|
2. Execute phases in order.
|
||||||
3. Do not start a phase unless all prior acceptance gates are complete.
|
3. Do not start a phase unless all prior acceptance gates are complete.
|
||||||
4. Attach objective evidence for every gate.
|
4. Attach objective evidence for every gate.
|
||||||
5. Treat `phase-06-platform-dependency-refit` as mandatory release work, not cleanup.
|
5. Treat `phase-06-platform-dependency-refit` as mandatory release work, not cleanup.
|
||||||
|
|
||||||
## Non-Negotiable Rules
|
## Non-Negotiable Rules
|
||||||
|
|
||||||
1. No hidden edit-only required settings.
|
1. No hidden edit-only required settings.
|
||||||
2. Deterministic routing and ranking tie-break behavior.
|
2. Deterministic routing and ranking tie-break behavior.
|
||||||
3. Assignment coverage guarantees for eligible projects.
|
3. Assignment coverage guarantees for eligible projects.
|
||||||
4. Explicit voting window control (schedules are advisory only).
|
4. Explicit voting window control (schedules are advisory only).
|
||||||
5. No legacy orchestration contract references at release.
|
5. No legacy orchestration contract references at release.
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
# Dependency Refit Map
|
# Dependency Refit Map
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
A[Schema Contracts] --> B[Router Refit]
|
A[Schema Contracts] --> B[Router Refit]
|
||||||
A --> C[Service Refit]
|
A --> C[Service Refit]
|
||||||
B --> D[Admin UI Refit]
|
B --> D[Admin UI Refit]
|
||||||
B --> E[Jury/Applicant/Public Refit]
|
B --> E[Jury/Applicant/Public Refit]
|
||||||
C --> E
|
C --> E
|
||||||
D --> F[Reporting/Exports]
|
D --> F[Reporting/Exports]
|
||||||
E --> F
|
E --> F
|
||||||
F --> G[Integration Consumer Validation]
|
F --> G[Integration Consumer Validation]
|
||||||
G --> H[Legacy Symbol Sweep]
|
G --> H[Legacy Symbol Sweep]
|
||||||
H --> I[Release Ready]
|
H --> I[Release Ready]
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
# End-to-End Pipeline Flow
|
# End-to-End Pipeline Flow
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A[Intake Stage] --> B[Filter Stage]
|
A[Intake Stage] --> B[Filter Stage]
|
||||||
B -->|pass| C[Main Evaluation Stage]
|
B -->|pass| C[Main Evaluation Stage]
|
||||||
B -->|reject| R[Rejected with Notification]
|
B -->|reject| R[Rejected with Notification]
|
||||||
B -->|award rule: parallel| W1[Award Track Entry]
|
B -->|award rule: parallel| W1[Award Track Entry]
|
||||||
B -->|award rule: exclusive| W2[Award Track Entry + Main Routed Out]
|
B -->|award rule: exclusive| W2[Award Track Entry + Main Routed Out]
|
||||||
|
|
||||||
C --> D[Selection Stage]
|
C --> D[Selection Stage]
|
||||||
D --> E[Live Final Stage]
|
D --> E[Live Final Stage]
|
||||||
E --> F[Results Stage]
|
E --> F[Results Stage]
|
||||||
|
|
||||||
W1 --> W3[Award Evaluation]
|
W1 --> W3[Award Evaluation]
|
||||||
W2 --> W3[Award Evaluation]
|
W2 --> W3[Award Evaluation]
|
||||||
W3 --> W4[Award Winner Decision]
|
W3 --> W4[Award Winner Decision]
|
||||||
|
|
||||||
D -->|manual override| O[Override Action + Audit]
|
D -->|manual override| O[Override Action + Audit]
|
||||||
O --> D
|
O --> D
|
||||||
|
|
||||||
E --> L[Live Cursor + Cohort Windows]
|
E --> L[Live Cursor + Cohort Windows]
|
||||||
L --> V[Jury and Audience Voting]
|
L --> V[Jury and Audience Voting]
|
||||||
V --> F
|
V --> F
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
# Live Stage Controller
|
# Live Stage Controller
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A[Admin Live Panel] --> B[Set Active Project Cursor]
|
A[Admin Live Panel] --> B[Set Active Project Cursor]
|
||||||
B --> C[Persist Cursor Versioned Update]
|
B --> C[Persist Cursor Versioned Update]
|
||||||
C --> D[Broadcast Realtime Event]
|
C --> D[Broadcast Realtime Event]
|
||||||
D --> E[Jury Clients Sync]
|
D --> E[Jury Clients Sync]
|
||||||
D --> F[Audience Clients Sync]
|
D --> F[Audience Clients Sync]
|
||||||
|
|
||||||
A --> G[Open Cohort Window]
|
A --> G[Open Cohort Window]
|
||||||
A --> H[Close Cohort Window]
|
A --> H[Close Cohort Window]
|
||||||
G --> I[Vote Acceptance On]
|
G --> I[Vote Acceptance On]
|
||||||
H --> J[Vote Acceptance Off]
|
H --> J[Vote Acceptance Off]
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
# Main vs Award Routing
|
# Main vs Award Routing
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
P[Project in Filter Stage] --> Q{Routing Rule Match?}
|
P[Project in Filter Stage] --> Q{Routing Rule Match?}
|
||||||
Q -->|No| M[Remain in Main Track]
|
Q -->|No| M[Remain in Main Track]
|
||||||
Q -->|Yes| Z{Routing Mode}
|
Q -->|Yes| Z{Routing Mode}
|
||||||
|
|
||||||
Z -->|PARALLEL| A[Create Award Stage State]
|
Z -->|PARALLEL| A[Create Award Stage State]
|
||||||
A --> B[Keep Main State Active]
|
A --> B[Keep Main State Active]
|
||||||
|
|
||||||
Z -->|EXCLUSIVE| C[Create Award Stage State]
|
Z -->|EXCLUSIVE| C[Create Award Stage State]
|
||||||
C --> D[Mark Main State Routed]
|
C --> D[Mark Main State Routed]
|
||||||
|
|
||||||
Z -->|POST_MAIN| E[Defer Route Until Gate Stage]
|
Z -->|POST_MAIN| E[Defer Route Until Gate Stage]
|
||||||
E --> F[Route After Main Gate Condition]
|
E --> F[Route After Main Gate Condition]
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
# Override Audit Flow
|
# Override Audit Flow
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A[Override Request] --> B{Authz + Scope Check}
|
A[Override Request] --> B{Authz + Scope Check}
|
||||||
B -->|fail| X[FORBIDDEN]
|
B -->|fail| X[FORBIDDEN]
|
||||||
B -->|pass| C{Reason Fields Present?}
|
B -->|pass| C{Reason Fields Present?}
|
||||||
C -->|no| Y[BAD_REQUEST]
|
C -->|no| Y[BAD_REQUEST]
|
||||||
C -->|yes| D[Fetch Current Value Snapshot]
|
C -->|yes| D[Fetch Current Value Snapshot]
|
||||||
D --> E[Apply Override Mutation]
|
D --> E[Apply Override Mutation]
|
||||||
E --> F[Persist Immutable OverrideAction]
|
E --> F[Persist Immutable OverrideAction]
|
||||||
F --> G[Append DecisionAuditLog]
|
F --> G[Append DecisionAuditLog]
|
||||||
G --> H[Return Updated Entity + Audit Ref]
|
G --> H[Return Updated Entity + Audit Ref]
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,95 +1,95 @@
|
||||||
# Master Implementation Plan
|
# Master Implementation Plan
|
||||||
|
|
||||||
## Program Objective
|
## 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.
|
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
|
## Program Constraints
|
||||||
|
|
||||||
- Preserve existing visual language and core UI component style.
|
- Preserve existing visual language and core UI component style.
|
||||||
- Complete architecture rebuild is allowed and encouraged.
|
- Complete architecture rebuild is allowed and encouraged.
|
||||||
- Delivery must be production-safe and verifiable.
|
- Delivery must be production-safe and verifiable.
|
||||||
- Release requires one atomic cutover commit after full validation.
|
- Release requires one atomic cutover commit after full validation.
|
||||||
|
|
||||||
## Hard Invariants
|
## Hard Invariants
|
||||||
|
|
||||||
1. Every state transition is explicit, validated, and auditable.
|
1. Every state transition is explicit, validated, and auditable.
|
||||||
2. Every override action captures `reasonCode` + `reasonText` + actor metadata.
|
2. Every override action captures `reasonCode` + `reasonText` + actor metadata.
|
||||||
3. No eligible project is left unassigned unless explicitly flagged as overflow with admin visibility.
|
3. No eligible project is left unassigned unless explicitly flagged as overflow with admin visibility.
|
||||||
4. Live active project state is admin-cursor driven.
|
4. Live active project state is admin-cursor driven.
|
||||||
5. Award routing behavior is explicit per award (`parallel`, `exclusive`, `post_main`).
|
5. Award routing behavior is explicit per award (`parallel`, `exclusive`, `post_main`).
|
||||||
6. Event contracts are deterministic and machine-readable.
|
6. Event contracts are deterministic and machine-readable.
|
||||||
7. At release, no runtime dependency on legacy `roundId` orchestration semantics remains.
|
7. At release, no runtime dependency on legacy `roundId` orchestration semantics remains.
|
||||||
|
|
||||||
## Phase Chain
|
## Phase Chain
|
||||||
|
|
||||||
1. Phase 00: Contract freeze
|
1. Phase 00: Contract freeze
|
||||||
2. Phase 01: Schema and runtime foundation
|
2. Phase 01: Schema and runtime foundation
|
||||||
3. Phase 02: Backend orchestration engine
|
3. Phase 02: Backend orchestration engine
|
||||||
4. Phase 03: Admin control-plane UX
|
4. Phase 03: Admin control-plane UX
|
||||||
5. Phase 04: Participant journeys
|
5. Phase 04: Participant journeys
|
||||||
6. Phase 05: Special awards governance
|
6. Phase 05: Special awards governance
|
||||||
7. Phase 06: Platform dependency refit
|
7. Phase 06: Platform dependency refit
|
||||||
8. Phase 07: Validation and release
|
8. Phase 07: Validation and release
|
||||||
|
|
||||||
## Required Deliverables by Phase
|
## Required Deliverables by Phase
|
||||||
|
|
||||||
- Phase 00: locked contracts, decision log, authz matrix, initial risk register
|
- Phase 00: locked contracts, decision log, authz matrix, initial risk register
|
||||||
- Phase 01: canonical schema spec, migration/cutover scripts, reseed spec, integrity checks
|
- Phase 01: canonical schema spec, migration/cutover scripts, reseed spec, integrity checks
|
||||||
- Phase 02: transition/routing/filtering/assignment/live runtime implementation specs
|
- Phase 02: transition/routing/filtering/assignment/live runtime implementation specs
|
||||||
- Phase 03: wizard IA, advanced editor spec, form behavior and safety guardrails
|
- Phase 03: wizard IA, advanced editor spec, form behavior and safety guardrails
|
||||||
- Phase 04: applicant/jury/audience runtime and UX contracts
|
- Phase 04: applicant/jury/audience runtime and UX contracts
|
||||||
- Phase 05: award governance modes and decision workflow implementation
|
- Phase 05: award governance modes and decision workflow implementation
|
||||||
- Phase 06: module-by-module refit completion + legacy symbol sweeps
|
- Phase 06: module-by-module refit completion + legacy symbol sweeps
|
||||||
- Phase 07: full test evidence, performance evidence, release runbook and sign-off
|
- Phase 07: full test evidence, performance evidence, release runbook and sign-off
|
||||||
|
|
||||||
## Entry and Exit Criteria (Program Level)
|
## Entry and Exit Criteria (Program Level)
|
||||||
|
|
||||||
### Entry
|
### Entry
|
||||||
|
|
||||||
- Shared contracts and decisions are locked.
|
- Shared contracts and decisions are locked.
|
||||||
- Team alignment on cutover model and no-compatibility policy.
|
- Team alignment on cutover model and no-compatibility policy.
|
||||||
|
|
||||||
### Exit
|
### Exit
|
||||||
|
|
||||||
- All phase acceptance gates complete.
|
- All phase acceptance gates complete.
|
||||||
- Test matrix green for U/I/E/P suites.
|
- Test matrix green for U/I/E/P suites.
|
||||||
- Performance and resilience evidence approved.
|
- Performance and resilience evidence approved.
|
||||||
- Legacy symbol sweeps are empty.
|
- Legacy symbol sweeps are empty.
|
||||||
- Release evidence report signed by Engineering + Product + Operations.
|
- Release evidence report signed by Engineering + Product + Operations.
|
||||||
|
|
||||||
## Release Blockers
|
## Release Blockers
|
||||||
|
|
||||||
1. Any failing acceptance gate.
|
1. Any failing acceptance gate.
|
||||||
2. Any unresolved CRITICAL or HIGH risk without approved mitigation.
|
2. Any unresolved CRITICAL or HIGH risk without approved mitigation.
|
||||||
3. Any missing test evidence for mandatory scenario IDs.
|
3. Any missing test evidence for mandatory scenario IDs.
|
||||||
4. Any legacy orchestration symbol found in runtime code paths.
|
4. Any legacy orchestration symbol found in runtime code paths.
|
||||||
|
|
||||||
## Timeline Model
|
## Timeline Model
|
||||||
|
|
||||||
- Phase 00: 2-3 days
|
- Phase 00: 2-3 days
|
||||||
- Phase 01: 1-1.5 weeks
|
- Phase 01: 1-1.5 weeks
|
||||||
- Phase 02: 1.5-2.5 weeks
|
- Phase 02: 1.5-2.5 weeks
|
||||||
- Phase 03: 1-1.5 weeks
|
- Phase 03: 1-1.5 weeks
|
||||||
- Phase 04: 1-1.5 weeks
|
- Phase 04: 1-1.5 weeks
|
||||||
- Phase 05: 0.75-1.25 weeks
|
- Phase 05: 0.75-1.25 weeks
|
||||||
- Phase 06: 1-1.5 weeks
|
- Phase 06: 1-1.5 weeks
|
||||||
- Phase 07: 1 week
|
- Phase 07: 1 week
|
||||||
|
|
||||||
Total estimate: 8-11 weeks depending on test depth and refit complexity.
|
Total estimate: 8-11 weeks depending on test depth and refit complexity.
|
||||||
|
|
||||||
## Evidence Standards
|
## Evidence Standards
|
||||||
|
|
||||||
Every acceptance gate requires at least one of:
|
Every acceptance gate requires at least one of:
|
||||||
|
|
||||||
- Unit/integration/E2E output
|
- Unit/integration/E2E output
|
||||||
- API response captures
|
- API response captures
|
||||||
- deterministic symbol sweeps
|
- deterministic symbol sweeps
|
||||||
- migration integrity query output
|
- migration integrity query output
|
||||||
- performance benchmark output
|
- performance benchmark output
|
||||||
- release runbook logs
|
- release runbook logs
|
||||||
|
|
||||||
## Enforcement Notes
|
## Enforcement Notes
|
||||||
|
|
||||||
- No phase skipping.
|
- No phase skipping.
|
||||||
- No deferred blocker carry-forward.
|
- No deferred blocker carry-forward.
|
||||||
- No "ship and patch later" for contract-level gaps.
|
- No "ship and patch later" for contract-level gaps.
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
# Phase 00 Acceptance Gates
|
# Phase 00 Acceptance Gates
|
||||||
|
|
||||||
- [ ] G-00-1 Decision log locked (`shared/decision-log.md` signed by Eng + Product)
|
- [ ] G-00-1 Decision log locked (`shared/decision-log.md` signed by Eng + Product)
|
||||||
- [ ] G-00-2 Domain and API contracts approved
|
- [ ] G-00-2 Domain and API contracts approved
|
||||||
- [ ] G-00-3 Authz matrix approved
|
- [ ] G-00-3 Authz matrix approved
|
||||||
- [ ] G-00-4 Test matrix approved and mapped to owners
|
- [ ] G-00-4 Test matrix approved and mapped to owners
|
||||||
- [ ] G-00-5 Risk register initialized with owners and mitigation targets
|
- [ ] G-00-5 Risk register initialized with owners and mitigation targets
|
||||||
|
|
||||||
## Required Evidence
|
## Required Evidence
|
||||||
|
|
||||||
- contract review notes
|
- contract review notes
|
||||||
- sign-off comments or approval records
|
- sign-off comments or approval records
|
||||||
- updated risk register with owners
|
- updated risk register with owners
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,35 @@
|
||||||
# Phase 00 Overview: Contract Freeze
|
# Phase 00 Overview: Contract Freeze
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
Lock all cross-phase contracts before implementation so the program executes with stable boundaries and no semantic drift.
|
Lock all cross-phase contracts before implementation so the program executes with stable boundaries and no semantic drift.
|
||||||
|
|
||||||
## In Scope
|
## In Scope
|
||||||
|
|
||||||
- decision locking
|
- decision locking
|
||||||
- API/type contract baseline
|
- API/type contract baseline
|
||||||
- authorization baseline
|
- authorization baseline
|
||||||
- gate and evidence baseline
|
- gate and evidence baseline
|
||||||
- initial risk baseline
|
- initial risk baseline
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
- schema implementation
|
- schema implementation
|
||||||
- runtime implementation
|
- runtime implementation
|
||||||
- UI implementation
|
- UI implementation
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
- `shared/program-charter.md`
|
- `shared/program-charter.md`
|
||||||
- `shared/decision-log.md`
|
- `shared/decision-log.md`
|
||||||
- `shared/domain-model.md`
|
- `shared/domain-model.md`
|
||||||
- `shared/api-contracts.md`
|
- `shared/api-contracts.md`
|
||||||
- `shared/authz-matrix.md`
|
- `shared/authz-matrix.md`
|
||||||
- `shared/test-matrix.md`
|
- `shared/test-matrix.md`
|
||||||
|
|
||||||
## Exit Criteria
|
## Exit Criteria
|
||||||
|
|
||||||
1. Decision log marked locked with no unresolved critical decision.
|
1. Decision log marked locked with no unresolved critical decision.
|
||||||
2. API/type contracts accepted by backend and frontend owners.
|
2. API/type contracts accepted by backend and frontend owners.
|
||||||
3. Authz matrix accepted by security owner.
|
3. Authz matrix accepted by security owner.
|
||||||
4. Risk register initialized with owners.
|
4. Risk register initialized with owners.
|
||||||
5. Phase 00 acceptance gates complete with evidence.
|
5. Phase 00 acceptance gates complete with evidence.
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,31 @@
|
||||||
# Phase 00 Tasks
|
# Phase 00 Tasks
|
||||||
|
|
||||||
## Task Set A: Contract Alignment
|
## Task Set A: Contract Alignment
|
||||||
|
|
||||||
- [ ] Validate `shared/domain-model.md` against current repository constraints.
|
- [ ] Validate `shared/domain-model.md` against current repository constraints.
|
||||||
- [ ] Validate `shared/api-contracts.md` names and payload conventions with router ownership.
|
- [ ] Validate `shared/api-contracts.md` names and payload conventions with router ownership.
|
||||||
- [ ] Validate event naming strategy with notification and webhook owners.
|
- [ ] Validate event naming strategy with notification and webhook owners.
|
||||||
|
|
||||||
## Task Set B: Governance Lock
|
## Task Set B: Governance Lock
|
||||||
|
|
||||||
- [ ] Confirm `shared/decision-log.md` with Product + Engineering.
|
- [ ] Confirm `shared/decision-log.md` with Product + Engineering.
|
||||||
- [ ] Confirm cutover/no-compatibility policy in writing.
|
- [ ] Confirm cutover/no-compatibility policy in writing.
|
||||||
- [ ] Confirm override governance requirements and mandatory reason fields.
|
- [ ] Confirm override governance requirements and mandatory reason fields.
|
||||||
|
|
||||||
## Task Set C: Access and Security
|
## Task Set C: Access and Security
|
||||||
|
|
||||||
- [ ] Validate `shared/authz-matrix.md` for each role.
|
- [ ] Validate `shared/authz-matrix.md` for each role.
|
||||||
- [ ] Define scope enforcement standard for program-scoped admin actions.
|
- [ ] Define scope enforcement standard for program-scoped admin actions.
|
||||||
- [ ] Confirm audience vote abuse controls (token, rate-limit, dedupe key).
|
- [ ] Confirm audience vote abuse controls (token, rate-limit, dedupe key).
|
||||||
|
|
||||||
## Task Set D: Validation Baseline
|
## Task Set D: Validation Baseline
|
||||||
|
|
||||||
- [ ] Validate `shared/test-matrix.md` coverage and practicality.
|
- [ ] Validate `shared/test-matrix.md` coverage and practicality.
|
||||||
- [ ] Map each test ID to ownership.
|
- [ ] Map each test ID to ownership.
|
||||||
- [ ] Confirm CI entry strategy for U/I/E/P layers.
|
- [ ] Confirm CI entry strategy for U/I/E/P layers.
|
||||||
|
|
||||||
## Task Set E: Risk Baseline
|
## Task Set E: Risk Baseline
|
||||||
|
|
||||||
- [ ] Review `shared/risk-register.md` with owners.
|
- [ ] Review `shared/risk-register.md` with owners.
|
||||||
- [ ] Add any repository-specific risks identified during contract review.
|
- [ ] Add any repository-specific risks identified during contract review.
|
||||||
- [ ] Mark mitigation action owner and due phase per risk.
|
- [ ] Mark mitigation action owner and due phase per risk.
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
# Phase 01 Acceptance Gates
|
# Phase 01 Acceptance Gates
|
||||||
|
|
||||||
- [ ] G-01-1 `prisma generate` succeeds
|
- [ ] G-01-1 `prisma generate` succeeds
|
||||||
- [ ] G-01-2 reset/reseed succeeds in local and staging
|
- [ ] G-01-2 reset/reseed succeeds in local and staging
|
||||||
- [ ] G-01-3 integrity queries return expected zero-error results
|
- [ ] G-01-3 integrity queries return expected zero-error results
|
||||||
- [ ] G-01-4 required indexes confirmed in DB metadata
|
- [ ] G-01-4 required indexes confirmed in DB metadata
|
||||||
- [ ] G-01-5 phase artifacts stored and linked
|
- [ ] G-01-5 phase artifacts stored and linked
|
||||||
|
|
||||||
## Required Evidence
|
## Required Evidence
|
||||||
|
|
||||||
- migration command output
|
- migration command output
|
||||||
- reseed logs
|
- reseed logs
|
||||||
- integrity query result captures
|
- integrity query result captures
|
||||||
- schema diff summary
|
- schema diff summary
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,48 @@
|
||||||
# Phase 01 Migration and Cutover Plan
|
# Phase 01 Migration and Cutover Plan
|
||||||
|
|
||||||
## Strategy
|
## Strategy
|
||||||
Perform architecture rebuild with reset/reseed as the official path.
|
Perform architecture rebuild with reset/reseed as the official path.
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. Finalize schema migration scripts.
|
1. Finalize schema migration scripts.
|
||||||
2. Run local reset/reseed rehearsal.
|
2. Run local reset/reseed rehearsal.
|
||||||
3. Run staging reset/reseed rehearsal.
|
3. Run staging reset/reseed rehearsal.
|
||||||
4. Execute integrity verification suite.
|
4. Execute integrity verification suite.
|
||||||
5. Lock schema contracts and produce baseline snapshot.
|
5. Lock schema contracts and produce baseline snapshot.
|
||||||
|
|
||||||
## Verification Script Requirements
|
## Verification Script Requirements
|
||||||
|
|
||||||
- count checks for canonical entities
|
- count checks for canonical entities
|
||||||
- FK integrity checks
|
- FK integrity checks
|
||||||
- expected stage graph checks
|
- expected stage graph checks
|
||||||
- expected project intake state checks
|
- expected project intake state checks
|
||||||
|
|
||||||
## Example Verification Queries
|
## Example Verification Queries
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- orphan project stage states
|
-- orphan project stage states
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM "ProjectStageState" pss
|
FROM "ProjectStageState" pss
|
||||||
LEFT JOIN "Project" p ON p.id = pss."projectId"
|
LEFT JOIN "Project" p ON p.id = pss."projectId"
|
||||||
LEFT JOIN "Stage" s ON s.id = pss."stageId"
|
LEFT JOIN "Stage" s ON s.id = pss."stageId"
|
||||||
LEFT JOIN "Track" t ON t.id = pss."trackId"
|
LEFT JOIN "Track" t ON t.id = pss."trackId"
|
||||||
WHERE p.id IS NULL OR s.id IS NULL OR t.id IS NULL;
|
WHERE p.id IS NULL OR s.id IS NULL OR t.id IS NULL;
|
||||||
|
|
||||||
-- project intake state coverage
|
-- project intake state coverage
|
||||||
SELECT COUNT(DISTINCT p.id) AS projects_without_intake
|
SELECT COUNT(DISTINCT p.id) AS projects_without_intake
|
||||||
FROM "Project" p
|
FROM "Project" p
|
||||||
LEFT JOIN "ProjectStageState" pss
|
LEFT JOIN "ProjectStageState" pss
|
||||||
ON pss."projectId" = p.id
|
ON pss."projectId" = p.id
|
||||||
LEFT JOIN "Stage" s
|
LEFT JOIN "Stage" s
|
||||||
ON s.id = pss."stageId"
|
ON s.id = pss."stageId"
|
||||||
WHERE s."stageType" = 'INTAKE'
|
WHERE s."stageType" = 'INTAKE'
|
||||||
AND pss.id IS NULL;
|
AND pss.id IS NULL;
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cutover Readiness Artifacts Produced in Phase 01
|
## Cutover Readiness Artifacts Produced in Phase 01
|
||||||
|
|
||||||
- schema migration files
|
- schema migration files
|
||||||
- seed scripts
|
- seed scripts
|
||||||
- integrity query scripts
|
- integrity query scripts
|
||||||
- reset/reseed execution logs
|
- reset/reseed execution logs
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
# Phase 01 Overview: Schema and Runtime Foundation
|
# Phase 01 Overview: Schema and Runtime Foundation
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
Implement the canonical schema and reset/reseed capability that supports stage-native orchestration with award and live runtime primitives.
|
Implement the canonical schema and reset/reseed capability that supports stage-native orchestration with award and live runtime primitives.
|
||||||
|
|
||||||
## In Scope
|
## In Scope
|
||||||
|
|
||||||
- prisma schema rebuild for canonical entities
|
- prisma schema rebuild for canonical entities
|
||||||
- indexes and constraints for hot paths
|
- indexes and constraints for hot paths
|
||||||
- reset/reseed strategy and scripts
|
- reset/reseed strategy and scripts
|
||||||
- data integrity verification scripts
|
- data integrity verification scripts
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
- end-user UI behavior
|
- end-user UI behavior
|
||||||
- full router refit
|
- full router refit
|
||||||
|
|
||||||
## Key Design Choice
|
## Key Design Choice
|
||||||
|
|
||||||
This phase uses full reset/reseed and does not attempt compatibility bridges.
|
This phase uses full reset/reseed and does not attempt compatibility bridges.
|
||||||
|
|
||||||
## Exit Criteria
|
## Exit Criteria
|
||||||
|
|
||||||
1. Schema compiles and generates client successfully.
|
1. Schema compiles and generates client successfully.
|
||||||
2. Reset/reseed produces runnable dataset.
|
2. Reset/reseed produces runnable dataset.
|
||||||
3. Integrity verification passes for FK/index and state initialization rules.
|
3. Integrity verification passes for FK/index and state initialization rules.
|
||||||
4. Phase 01 gates complete.
|
4. Phase 01 gates complete.
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,59 @@
|
||||||
# Phase 01 Schema Specification
|
# Phase 01 Schema Specification
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
Introduce the canonical orchestration entities and remove legacy dependency assumptions around single `roundId` progression.
|
Introduce the canonical orchestration entities and remove legacy dependency assumptions around single `roundId` progression.
|
||||||
|
|
||||||
## New Canonical Tables
|
## New Canonical Tables
|
||||||
|
|
||||||
1. `Pipeline`
|
1. `Pipeline`
|
||||||
2. `Track`
|
2. `Track`
|
||||||
3. `Stage`
|
3. `Stage`
|
||||||
4. `StageTransition`
|
4. `StageTransition`
|
||||||
5. `ProjectStageState`
|
5. `ProjectStageState`
|
||||||
6. `RoutingRule`
|
6. `RoutingRule`
|
||||||
7. `Cohort`
|
7. `Cohort`
|
||||||
8. `CohortProject`
|
8. `CohortProject`
|
||||||
9. `LiveProgressCursor`
|
9. `LiveProgressCursor`
|
||||||
10. `NotificationPolicy`
|
10. `NotificationPolicy`
|
||||||
11. `OverrideAction`
|
11. `OverrideAction`
|
||||||
12. `DecisionAuditLog`
|
12. `DecisionAuditLog`
|
||||||
|
|
||||||
## Award Governance Extensions
|
## Award Governance Extensions
|
||||||
|
|
||||||
- Add `DecisionMode = JURY_VOTE | AWARD_MASTER | ADMIN`
|
- Add `DecisionMode = JURY_VOTE | AWARD_MASTER | ADMIN`
|
||||||
- Add award-scoped governance metadata to award track configs
|
- Add award-scoped governance metadata to award track configs
|
||||||
- Add award winner finalization audit event contracts
|
- Add award winner finalization audit event contracts
|
||||||
|
|
||||||
## Migration Model
|
## Migration Model
|
||||||
|
|
||||||
- Build new schema directly as canonical target.
|
- Build new schema directly as canonical target.
|
||||||
- Keep migration files deterministic and replay-safe.
|
- Keep migration files deterministic and replay-safe.
|
||||||
- Do not implement dual-write or compatibility tables.
|
- Do not implement dual-write or compatibility tables.
|
||||||
|
|
||||||
## Required Constraints
|
## Required Constraints
|
||||||
|
|
||||||
1. `trackId + sortOrder` unique in `Stage`
|
1. `trackId + sortOrder` unique in `Stage`
|
||||||
2. `projectId + trackId + stageId` unique in `ProjectStageState`
|
2. `projectId + trackId + stageId` unique in `ProjectStageState`
|
||||||
3. `fromStageId + toStageId` unique in `StageTransition`
|
3. `fromStageId + toStageId` unique in `StageTransition`
|
||||||
4. `cohortId + projectId` unique in `CohortProject`
|
4. `cohortId + projectId` unique in `CohortProject`
|
||||||
|
|
||||||
## Required Indexes
|
## Required Indexes
|
||||||
|
|
||||||
- `ProjectStageState(projectId, trackId, state)`
|
- `ProjectStageState(projectId, trackId, state)`
|
||||||
- `ProjectStageState(stageId, state)`
|
- `ProjectStageState(stageId, state)`
|
||||||
- `RoutingRule(pipelineId, isActive, priority)`
|
- `RoutingRule(pipelineId, isActive, priority)`
|
||||||
- `StageTransition(fromStageId, priority)`
|
- `StageTransition(fromStageId, priority)`
|
||||||
- `DecisionAuditLog(entityType, entityId, createdAt)`
|
- `DecisionAuditLog(entityType, entityId, createdAt)`
|
||||||
- `LiveProgressCursor(stageId, sessionId)`
|
- `LiveProgressCursor(stageId, sessionId)`
|
||||||
|
|
||||||
## Data Initialization Rules
|
## Data Initialization Rules
|
||||||
|
|
||||||
- Every seeded project must start with one intake-stage state.
|
- 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 main track plus at least two award tracks with different routing modes.
|
||||||
- Seed must include representative roles: admins, jury, applicants, observer, audience contexts.
|
- Seed must include representative roles: admins, jury, applicants, observer, audience contexts.
|
||||||
|
|
||||||
## Integrity Checks
|
## Integrity Checks
|
||||||
|
|
||||||
- No orphan states.
|
- No orphan states.
|
||||||
- No invalid transition targets across pipelines.
|
- No invalid transition targets across pipelines.
|
||||||
- No duplicate active state rows for same `(project, track, stage)`.
|
- No duplicate active state rows for same `(project, track, stage)`.
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
# Phase 01 Tasks
|
# Phase 01 Tasks
|
||||||
|
|
||||||
## Schema Build
|
## Schema Build
|
||||||
|
|
||||||
- [ ] Implement canonical entities and enums in `prisma/schema.prisma`.
|
- [ ] Implement canonical entities and enums in `prisma/schema.prisma`.
|
||||||
- [ ] Add required constraints and indexes.
|
- [ ] Add required constraints and indexes.
|
||||||
- [ ] Remove or isolate legacy-only orchestration semantics from canonical paths.
|
- [ ] Remove or isolate legacy-only orchestration semantics from canonical paths.
|
||||||
|
|
||||||
## Seed and Fixtures
|
## Seed and Fixtures
|
||||||
|
|
||||||
- [ ] Implement reseed script with realistic data volumes and edge cases.
|
- [ ] Implement reseed script with realistic data volumes and edge cases.
|
||||||
- [ ] Include parallel, exclusive, and post-main award routing seed examples.
|
- [ ] Include parallel, exclusive, and post-main award routing seed examples.
|
||||||
- [ ] Include live cohort seed data.
|
- [ ] Include live cohort seed data.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- [ ] Implement integrity SQL scripts.
|
- [ ] Implement integrity SQL scripts.
|
||||||
- [ ] Implement automated verification command wrapper.
|
- [ ] Implement automated verification command wrapper.
|
||||||
- [ ] Record baseline output and attach to gate evidence.
|
- [ ] Record baseline output and attach to gate evidence.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [ ] Update schema change notes.
|
- [ ] Update schema change notes.
|
||||||
- [ ] Document reset/reseed assumptions.
|
- [ ] Document reset/reseed assumptions.
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
# Phase 02 Acceptance Gates
|
# Phase 02 Acceptance Gates
|
||||||
|
|
||||||
- [ ] G-02-1 transition engine tests pass
|
- [ ] G-02-1 transition engine tests pass
|
||||||
- [ ] G-02-2 routing determinism tests pass
|
- [ ] G-02-2 routing determinism tests pass
|
||||||
- [ ] G-02-3 filtering policy tests pass
|
- [ ] G-02-3 filtering policy tests pass
|
||||||
- [ ] G-02-4 assignment guarantee tests pass
|
- [ ] G-02-4 assignment guarantee tests pass
|
||||||
- [ ] G-02-5 live cursor and cohort window tests pass
|
- [ ] G-02-5 live cursor and cohort window tests pass
|
||||||
- [ ] G-02-6 override/audit tests pass
|
- [ ] G-02-6 override/audit tests pass
|
||||||
|
|
||||||
## Required Evidence
|
## Required Evidence
|
||||||
|
|
||||||
- U/I test output for all mapped IDs
|
- U/I test output for all mapped IDs
|
||||||
- sample API responses for major mutation endpoints
|
- sample API responses for major mutation endpoints
|
||||||
- audit payload examples for transition and override flows
|
- audit payload examples for transition and override flows
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,43 @@
|
||||||
# Assignment Engine Specification
|
# Assignment Engine Specification
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
Generate high-quality, fair assignments while guaranteeing eligible project coverage.
|
Generate high-quality, fair assignments while guaranteeing eligible project coverage.
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
- stage ID
|
- stage ID
|
||||||
- eligible project set
|
- eligible project set
|
||||||
- assignee pool
|
- assignee pool
|
||||||
- required reviews per project
|
- required reviews per project
|
||||||
- assignment strategy config
|
- assignment strategy config
|
||||||
- availability and COI policies
|
- availability and COI policies
|
||||||
|
|
||||||
## Hard Constraints
|
## Hard Constraints
|
||||||
|
|
||||||
1. COI exclusion
|
1. COI exclusion
|
||||||
2. role/status eligibility
|
2. role/status eligibility
|
||||||
3. explicit max-load cap
|
3. explicit max-load cap
|
||||||
4. minimum review floor
|
4. minimum review floor
|
||||||
|
|
||||||
## Soft Scoring Dimensions
|
## Soft Scoring Dimensions
|
||||||
|
|
||||||
- expertise overlap
|
- expertise overlap
|
||||||
- bio/project similarity
|
- bio/project similarity
|
||||||
- availability weighting
|
- availability weighting
|
||||||
- workload balancing
|
- workload balancing
|
||||||
- optional geo diversity
|
- optional geo diversity
|
||||||
- optional prior-familiarity weighting
|
- optional prior-familiarity weighting
|
||||||
|
|
||||||
## Guarantee Rules
|
## Guarantee Rules
|
||||||
|
|
||||||
1. No eligible project left uncovered.
|
1. No eligible project left uncovered.
|
||||||
2. If capacity insufficient, create overflow assignments with warning markers.
|
2. If capacity insufficient, create overflow assignments with warning markers.
|
||||||
3. Preview and execution must match constraints and scoring semantics.
|
3. Preview and execution must match constraints and scoring semantics.
|
||||||
|
|
||||||
## Output Contract
|
## Output Contract
|
||||||
|
|
||||||
- assigned count
|
- assigned count
|
||||||
- uncovered count (must be zero unless in explicit error mode)
|
- uncovered count (must be zero unless in explicit error mode)
|
||||||
- overflow assignment list
|
- overflow assignment list
|
||||||
- conflict skips list
|
- conflict skips list
|
||||||
- fairness metrics (median load, max load)
|
- fairness metrics (median load, max load)
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,55 @@
|
||||||
# Filtering and Routing Specification
|
# Filtering and Routing Specification
|
||||||
|
|
||||||
## Filtering Pipeline
|
## Filtering Pipeline
|
||||||
|
|
||||||
1. deterministic gates
|
1. deterministic gates
|
||||||
2. AI rubric evaluation
|
2. AI rubric evaluation
|
||||||
3. confidence band decisioning
|
3. confidence band decisioning
|
||||||
4. manual queue resolution
|
4. manual queue resolution
|
||||||
|
|
||||||
## Deterministic Gates First Rule
|
## Deterministic Gates First Rule
|
||||||
|
|
||||||
AI execution is prohibited unless deterministic gates pass.
|
AI execution is prohibited unless deterministic gates pass.
|
||||||
|
|
||||||
## AI Output Contract
|
## AI Output Contract
|
||||||
|
|
||||||
- criteria scores
|
- criteria scores
|
||||||
- overall recommendation
|
- overall recommendation
|
||||||
- confidence
|
- confidence
|
||||||
- rationale
|
- rationale
|
||||||
- risk flags
|
- risk flags
|
||||||
|
|
||||||
## Confidence Bands
|
## Confidence Bands
|
||||||
|
|
||||||
- `high`: auto decision path
|
- `high`: auto decision path
|
||||||
- `medium`: manual queue
|
- `medium`: manual queue
|
||||||
- `low`: reject or manual based on stage policy
|
- `low`: reject or manual based on stage policy
|
||||||
|
|
||||||
## Routing Rules
|
## Routing Rules
|
||||||
|
|
||||||
### Evaluation Order
|
### Evaluation Order
|
||||||
|
|
||||||
1. stage-scoped rules
|
1. stage-scoped rules
|
||||||
2. track-scoped rules
|
2. track-scoped rules
|
||||||
3. global rules
|
3. global rules
|
||||||
4. default fallback
|
4. default fallback
|
||||||
|
|
||||||
### Deterministic Tie-Break
|
### Deterministic Tie-Break
|
||||||
|
|
||||||
- highest priority wins
|
- highest priority wins
|
||||||
- if equal, lexical rule ID fallback
|
- if equal, lexical rule ID fallback
|
||||||
|
|
||||||
### Explainability Persisted
|
### Explainability Persisted
|
||||||
|
|
||||||
Each route persists:
|
Each route persists:
|
||||||
|
|
||||||
- matched rule ID
|
- matched rule ID
|
||||||
- predicate snapshot
|
- predicate snapshot
|
||||||
- mode (`AUTO|MANUAL`)
|
- mode (`AUTO|MANUAL`)
|
||||||
- destination track/stage
|
- destination track/stage
|
||||||
|
|
||||||
## Award Routing Modes
|
## Award Routing Modes
|
||||||
|
|
||||||
- `PARALLEL`: keep main progression and add award state
|
- `PARALLEL`: keep main progression and add award state
|
||||||
- `EXCLUSIVE`: route out of main progression into award track only
|
- `EXCLUSIVE`: route out of main progression into award track only
|
||||||
- `POST_MAIN`: route only after configured main gate stage
|
- `POST_MAIN`: route only after configured main gate stage
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,32 @@
|
||||||
# Live Control Specification
|
# Live Control Specification
|
||||||
|
|
||||||
## Source of Truth
|
## Source of Truth
|
||||||
Admin cursor state is the single source of truth for active project context during live stages.
|
Admin cursor state is the single source of truth for active project context during live stages.
|
||||||
|
|
||||||
## Core Controls
|
## Core Controls
|
||||||
|
|
||||||
- start session
|
- start session
|
||||||
- next/previous
|
- next/previous
|
||||||
- jump to project
|
- jump to project
|
||||||
- reorder queue
|
- reorder queue
|
||||||
- open/close cohort windows
|
- open/close cohort windows
|
||||||
- pause/resume session
|
- pause/resume session
|
||||||
|
|
||||||
## Runtime Requirements
|
## Runtime Requirements
|
||||||
|
|
||||||
1. cursor updates are versioned
|
1. cursor updates are versioned
|
||||||
2. race conditions return `CONFLICT` and require refresh/retry
|
2. race conditions return `CONFLICT` and require refresh/retry
|
||||||
3. real-time propagation to jury and audience clients
|
3. real-time propagation to jury and audience clients
|
||||||
4. reconnect path converges to current cursor/window state
|
4. reconnect path converges to current cursor/window state
|
||||||
|
|
||||||
## Vote Acceptance Rules
|
## Vote Acceptance Rules
|
||||||
|
|
||||||
- stage and cohort windows must be open
|
- stage and cohort windows must be open
|
||||||
- dedupe key policy enforced (`session/cohort/project/voter/window`)
|
- dedupe key policy enforced (`session/cohort/project/voter/window`)
|
||||||
- closed windows reject submissions deterministically
|
- closed windows reject submissions deterministically
|
||||||
|
|
||||||
## Event Contract
|
## Event Contract
|
||||||
|
|
||||||
- `live.cursor.updated`
|
- `live.cursor.updated`
|
||||||
- `cohort.window.changed`
|
- `cohort.window.changed`
|
||||||
- `live.session.state.changed`
|
- `live.session.state.changed`
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
# Phase 02 Overview: Backend Orchestration Engine
|
# Phase 02 Overview: Backend Orchestration Engine
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
Implement deterministic runtime behavior for stage transitions, routing, filtering, assignment, live cursor control, and notification/audit emission.
|
Implement deterministic runtime behavior for stage transitions, routing, filtering, assignment, live cursor control, and notification/audit emission.
|
||||||
|
|
||||||
## In Scope
|
## In Scope
|
||||||
|
|
||||||
- transition engine
|
- transition engine
|
||||||
- routing engine
|
- routing engine
|
||||||
- filtering orchestration (gates + AI + manual queue)
|
- filtering orchestration (gates + AI + manual queue)
|
||||||
- assignment orchestration with coverage guarantees
|
- assignment orchestration with coverage guarantees
|
||||||
- live cursor and cohort window controls
|
- live cursor and cohort window controls
|
||||||
- event and audit emission
|
- event and audit emission
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
- full admin UI and participant UI implementation
|
- full admin UI and participant UI implementation
|
||||||
|
|
||||||
## Exit Criteria
|
## Exit Criteria
|
||||||
|
|
||||||
1. Runtime contracts implemented and integration-tested.
|
1. Runtime contracts implemented and integration-tested.
|
||||||
2. Determinism and idempotency guarantees proven for critical mutations.
|
2. Determinism and idempotency guarantees proven for critical mutations.
|
||||||
3. Mandatory phase gates complete with test evidence.
|
3. Mandatory phase gates complete with test evidence.
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,46 @@
|
||||||
# Stage Engine Specification
|
# Stage Engine Specification
|
||||||
|
|
||||||
## Responsibilities
|
## Responsibilities
|
||||||
|
|
||||||
1. Validate transition legality.
|
1. Validate transition legality.
|
||||||
2. Move project states transactionally.
|
2. Move project states transactionally.
|
||||||
3. Emit transition events and audit entries.
|
3. Emit transition events and audit entries.
|
||||||
4. Enforce concurrency safety.
|
4. Enforce concurrency safety.
|
||||||
|
|
||||||
## State Machine
|
## State Machine
|
||||||
|
|
||||||
`PENDING -> IN_PROGRESS -> PASSED|REJECTED -> ROUTED -> COMPLETED`
|
`PENDING -> IN_PROGRESS -> PASSED|REJECTED -> ROUTED -> COMPLETED`
|
||||||
|
|
||||||
`WITHDRAWN` is terminal for participant-triggered withdrawal paths.
|
`WITHDRAWN` is terminal for participant-triggered withdrawal paths.
|
||||||
|
|
||||||
## Transition Guards
|
## Transition Guards
|
||||||
|
|
||||||
- source state row exists and is active
|
- source state row exists and is active
|
||||||
- destination stage is active and in same pipeline (unless routing rule applies)
|
- destination stage is active and in same pipeline (unless routing rule applies)
|
||||||
- stage window and guard conditions satisfied
|
- stage window and guard conditions satisfied
|
||||||
- no concurrent conflicting transition
|
- no concurrent conflicting transition
|
||||||
|
|
||||||
## Mutation Semantics
|
## Mutation Semantics
|
||||||
|
|
||||||
- transactional updates per batch slice
|
- transactional updates per batch slice
|
||||||
- optimistic locking/version checks
|
- optimistic locking/version checks
|
||||||
- per-project result collection for partial failure reporting
|
- per-project result collection for partial failure reporting
|
||||||
|
|
||||||
## Failure Codes
|
## Failure Codes
|
||||||
|
|
||||||
- `PRECONDITION_FAILED`: guard not satisfied
|
- `PRECONDITION_FAILED`: guard not satisfied
|
||||||
- `CONFLICT`: state moved after read
|
- `CONFLICT`: state moved after read
|
||||||
- `BAD_REQUEST`: invalid transition target
|
- `BAD_REQUEST`: invalid transition target
|
||||||
|
|
||||||
## Audit Contract
|
## Audit Contract
|
||||||
|
|
||||||
`eventType = stage.transitioned`
|
`eventType = stage.transitioned`
|
||||||
|
|
||||||
Payload includes:
|
Payload includes:
|
||||||
|
|
||||||
- actor
|
- actor
|
||||||
- source stage
|
- source stage
|
||||||
- destination stage
|
- destination stage
|
||||||
- old/new state
|
- old/new state
|
||||||
- reason/context
|
- reason/context
|
||||||
- timestamp
|
- timestamp
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
# Phase 02 Tasks
|
# Phase 02 Tasks
|
||||||
|
|
||||||
## Transition Engine
|
## Transition Engine
|
||||||
|
|
||||||
- [ ] Implement transition guard and mutation logic.
|
- [ ] Implement transition guard and mutation logic.
|
||||||
- [ ] Implement optimistic concurrency handling.
|
- [ ] Implement optimistic concurrency handling.
|
||||||
- [ ] Implement transition event and audit emission.
|
- [ ] Implement transition event and audit emission.
|
||||||
|
|
||||||
## Filtering and Routing
|
## Filtering and Routing
|
||||||
|
|
||||||
- [ ] Implement deterministic gate-first pipeline.
|
- [ ] Implement deterministic gate-first pipeline.
|
||||||
- [ ] Implement confidence band decision handling.
|
- [ ] Implement confidence band decision handling.
|
||||||
- [ ] Implement routing rule engine with explainability payloads.
|
- [ ] Implement routing rule engine with explainability payloads.
|
||||||
|
|
||||||
## Assignment
|
## Assignment
|
||||||
|
|
||||||
- [ ] Implement assignment preview and execute parity.
|
- [ ] Implement assignment preview and execute parity.
|
||||||
- [ ] Implement coverage guarantee and overflow semantics.
|
- [ ] Implement coverage guarantee and overflow semantics.
|
||||||
- [ ] Implement assignment metrics output.
|
- [ ] Implement assignment metrics output.
|
||||||
|
|
||||||
## Live Runtime
|
## Live Runtime
|
||||||
|
|
||||||
- [ ] Implement admin cursor operations and conflict-safe update model.
|
- [ ] Implement admin cursor operations and conflict-safe update model.
|
||||||
- [ ] Implement cohort window control.
|
- [ ] Implement cohort window control.
|
||||||
- [ ] Implement real-time event propagation path.
|
- [ ] Implement real-time event propagation path.
|
||||||
|
|
||||||
## Notifications and Audit
|
## Notifications and Audit
|
||||||
|
|
||||||
- [ ] Implement default event producers for stage transitions and outcomes.
|
- [ ] Implement default event producers for stage transitions and outcomes.
|
||||||
- [ ] Implement immutable audit payload structure.
|
- [ ] Implement immutable audit payload structure.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
# Phase 03 Acceptance Gates
|
# Phase 03 Acceptance Gates
|
||||||
|
|
||||||
- [ ] G-03-1 wizard can complete full required setup (E-001)
|
- [ ] G-03-1 wizard can complete full required setup (E-001)
|
||||||
- [ ] G-03-2 no hidden edit-only required settings remain
|
- [ ] G-03-2 no hidden edit-only required settings remain
|
||||||
- [ ] G-03-3 advanced editor enforces graph/config guardrails
|
- [ ] G-03-3 advanced editor enforces graph/config guardrails
|
||||||
- [ ] G-03-4 modal and form safety regressions pass
|
- [ ] G-03-4 modal and form safety regressions pass
|
||||||
|
|
||||||
## Required Evidence
|
## Required Evidence
|
||||||
|
|
||||||
- E2E wizard completion evidence
|
- E2E wizard completion evidence
|
||||||
- parity checklist artifacts
|
- parity checklist artifacts
|
||||||
- targeted UI regression test output
|
- targeted UI regression test output
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,34 @@
|
||||||
# Advanced Editor Specification
|
# Advanced Editor Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Provide direct manipulation of tracks, stages, transitions, and routing without polluting the default wizard path.
|
Provide direct manipulation of tracks, stages, transitions, and routing without polluting the default wizard path.
|
||||||
|
|
||||||
## Panels
|
## Panels
|
||||||
|
|
||||||
1. Track/Stage List Panel
|
1. Track/Stage List Panel
|
||||||
2. Stage Config Panel
|
2. Stage Config Panel
|
||||||
3. Transition Graph Panel
|
3. Transition Graph Panel
|
||||||
4. Routing Rule Inspector
|
4. Routing Rule Inspector
|
||||||
5. Simulation Panel
|
5. Simulation Panel
|
||||||
|
|
||||||
## Required Capabilities
|
## Required Capabilities
|
||||||
|
|
||||||
- reorder stages within track
|
- reorder stages within track
|
||||||
- move valid stages across tracks
|
- move valid stages across tracks
|
||||||
- create/delete transitions
|
- create/delete transitions
|
||||||
- edit rule predicates and priorities
|
- edit rule predicates and priorities
|
||||||
- simulate outcomes for sample project IDs
|
- simulate outcomes for sample project IDs
|
||||||
|
|
||||||
## Guardrails
|
## Guardrails
|
||||||
|
|
||||||
1. Block disconnected required paths.
|
1. Block disconnected required paths.
|
||||||
2. Block orphan stage deletion.
|
2. Block orphan stage deletion.
|
||||||
3. Warn before destructive transition/rule removal.
|
3. Warn before destructive transition/rule removal.
|
||||||
4. Enforce schema validation for stage config payloads.
|
4. Enforce schema validation for stage config payloads.
|
||||||
|
|
||||||
## Save Model
|
## Save Model
|
||||||
|
|
||||||
- draft buffer
|
- draft buffer
|
||||||
- validation run
|
- validation run
|
||||||
- transactional persist
|
- transactional persist
|
||||||
- validation report artifact
|
- validation report artifact
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
# Form Behavior and Validation Rules
|
# Form Behavior and Validation Rules
|
||||||
|
|
||||||
## Universal Rules
|
## Universal Rules
|
||||||
|
|
||||||
1. Every required field has inline validation.
|
1. Every required field has inline validation.
|
||||||
2. Every select has deterministic default value.
|
2. Every select has deterministic default value.
|
||||||
3. Save actions are idempotent and disabled while pending.
|
3. Save actions are idempotent and disabled while pending.
|
||||||
4. Unsafe changes surface explicit impact warnings.
|
4. Unsafe changes surface explicit impact warnings.
|
||||||
|
|
||||||
## Create/Edit Parity Requirements
|
## Create/Edit Parity Requirements
|
||||||
|
|
||||||
- intake windows
|
- intake windows
|
||||||
- upload policy
|
- upload policy
|
||||||
- file requirements
|
- file requirements
|
||||||
- assignment policy
|
- assignment policy
|
||||||
- filtering policy
|
- filtering policy
|
||||||
- routing policy
|
- routing policy
|
||||||
- live policy
|
- live policy
|
||||||
|
|
||||||
## Modal Safety Rules
|
## Modal Safety Rules
|
||||||
|
|
||||||
1. Modal close must not mutate persisted state.
|
1. Modal close must not mutate persisted state.
|
||||||
2. Non-submit buttons must explicitly set `type="button"`.
|
2. Non-submit buttons must explicitly set `type="button"`.
|
||||||
3. Escape/cancel should only dismiss local draft state.
|
3. Escape/cancel should only dismiss local draft state.
|
||||||
|
|
||||||
## Payload Safety
|
## Payload Safety
|
||||||
|
|
||||||
- replace raw free-text config where structured selectors exist
|
- replace raw free-text config where structured selectors exist
|
||||||
- normalize serialization format for config payloads
|
- normalize serialization format for config payloads
|
||||||
- reject unknown keys in strict mode contracts
|
- reject unknown keys in strict mode contracts
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
# Phase 03 Overview: Admin Control-Plane UX
|
# Phase 03 Overview: Admin Control-Plane UX
|
||||||
|
|
||||||
## Objective
|
## 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.
|
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
|
## In Scope
|
||||||
|
|
||||||
- setup wizard IA and behavior
|
- setup wizard IA and behavior
|
||||||
- advanced stage and routing editor
|
- advanced stage and routing editor
|
||||||
- simulation and validation panel
|
- simulation and validation panel
|
||||||
- create/edit parity
|
- create/edit parity
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
- visual redesign
|
- visual redesign
|
||||||
- participant-facing workflows
|
- participant-facing workflows
|
||||||
|
|
||||||
## Exit Criteria
|
## Exit Criteria
|
||||||
|
|
||||||
1. Full required setup possible from create flow.
|
1. Full required setup possible from create flow.
|
||||||
2. No hidden edit-only required fields.
|
2. No hidden edit-only required fields.
|
||||||
3. Validation and simulation guardrails implemented.
|
3. Validation and simulation guardrails implemented.
|
||||||
4. Phase gates complete.
|
4. Phase gates complete.
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
# Phase 03 Tasks
|
# Phase 03 Tasks
|
||||||
|
|
||||||
## Wizard
|
## Wizard
|
||||||
|
|
||||||
- [ ] Implement 8-step setup flow.
|
- [ ] Implement 8-step setup flow.
|
||||||
- [ ] Implement step-level validation and progress state.
|
- [ ] Implement step-level validation and progress state.
|
||||||
- [ ] Implement review/publish summary and blockers.
|
- [ ] Implement review/publish summary and blockers.
|
||||||
|
|
||||||
## Advanced Editor
|
## Advanced Editor
|
||||||
|
|
||||||
- [ ] Implement stage/transition/routing editing surfaces.
|
- [ ] Implement stage/transition/routing editing surfaces.
|
||||||
- [ ] Implement simulation runner and result panel.
|
- [ ] Implement simulation runner and result panel.
|
||||||
- [ ] Implement destructive action confirmations.
|
- [ ] Implement destructive action confirmations.
|
||||||
|
|
||||||
## Behavior and Safety
|
## Behavior and Safety
|
||||||
|
|
||||||
- [ ] Enforce create/edit parity checklist.
|
- [ ] Enforce create/edit parity checklist.
|
||||||
- [ ] Enforce modal safety rules.
|
- [ ] Enforce modal safety rules.
|
||||||
- [ ] Enforce strict payload validation.
|
- [ ] Enforce strict payload validation.
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1,78 @@
|
||||||
# Admin Wizard IA
|
# Admin Wizard IA
|
||||||
|
|
||||||
## Step Sequence
|
## Step Sequence
|
||||||
|
|
||||||
1. Intake Setup
|
1. Intake Setup
|
||||||
2. Main Track Stage Setup
|
2. Main Track Stage Setup
|
||||||
3. Filtering Strategy
|
3. Filtering Strategy
|
||||||
4. Assignment Strategy
|
4. Assignment Strategy
|
||||||
5. Special Awards
|
5. Special Awards
|
||||||
6. Live Finals Configuration
|
6. Live Finals Configuration
|
||||||
7. Notifications and Overrides
|
7. Notifications and Overrides
|
||||||
8. Review + Publish
|
8. Review + Publish
|
||||||
|
|
||||||
## Step Details
|
## Step Details
|
||||||
|
|
||||||
### 1) Intake Setup
|
### 1) Intake Setup
|
||||||
|
|
||||||
- submission windows
|
- submission windows
|
||||||
- late policy
|
- late policy
|
||||||
- file requirements
|
- file requirements
|
||||||
- MIME/size constraints
|
- MIME/size constraints
|
||||||
- applicant communication policy
|
- applicant communication policy
|
||||||
|
|
||||||
### 2) Main Track Stage Setup
|
### 2) Main Track Stage Setup
|
||||||
|
|
||||||
- stage list and ordering
|
- stage list and ordering
|
||||||
- stage type assignment
|
- stage type assignment
|
||||||
- status defaults
|
- status defaults
|
||||||
- selection stage presets
|
- selection stage presets
|
||||||
|
|
||||||
### 3) Filtering Strategy
|
### 3) Filtering Strategy
|
||||||
|
|
||||||
- deterministic gate definition
|
- deterministic gate definition
|
||||||
- AI rubric configuration
|
- AI rubric configuration
|
||||||
- confidence thresholds
|
- confidence thresholds
|
||||||
- manual queue owners
|
- manual queue owners
|
||||||
|
|
||||||
### 4) Assignment Strategy
|
### 4) Assignment Strategy
|
||||||
|
|
||||||
- required reviews
|
- required reviews
|
||||||
- max/min load settings
|
- max/min load settings
|
||||||
- availability weighting
|
- availability weighting
|
||||||
- overflow handling policy
|
- overflow handling policy
|
||||||
|
|
||||||
### 5) Special Awards
|
### 5) Special Awards
|
||||||
|
|
||||||
- award track enablement
|
- award track enablement
|
||||||
- routing mode per award
|
- routing mode per award
|
||||||
- decision mode per award
|
- decision mode per award
|
||||||
- award jury restrictions
|
- award jury restrictions
|
||||||
|
|
||||||
### 6) Live Finals
|
### 6) Live Finals
|
||||||
|
|
||||||
- cursor control mode
|
- cursor control mode
|
||||||
- jury vote config
|
- jury vote config
|
||||||
- audience vote config
|
- audience vote config
|
||||||
- cohort setup
|
- cohort setup
|
||||||
- reveal policy
|
- reveal policy
|
||||||
|
|
||||||
### 7) Notifications and Overrides
|
### 7) Notifications and Overrides
|
||||||
|
|
||||||
- default-on event toggles
|
- default-on event toggles
|
||||||
- template overrides
|
- template overrides
|
||||||
- override governance policy
|
- override governance policy
|
||||||
|
|
||||||
### 8) Review + Publish
|
### 8) Review + Publish
|
||||||
|
|
||||||
- summary diff
|
- summary diff
|
||||||
- warnings/blockers
|
- warnings/blockers
|
||||||
- simulation output
|
- simulation output
|
||||||
- publish action
|
- publish action
|
||||||
|
|
||||||
## UX Requirements
|
## UX Requirements
|
||||||
|
|
||||||
- mobile-safe interaction and layout
|
- mobile-safe interaction and layout
|
||||||
- explicit required field indicators
|
- explicit required field indicators
|
||||||
- deterministic defaults for every select
|
- deterministic defaults for every select
|
||||||
- inline validation without hidden blockers
|
- inline validation without hidden blockers
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
# Phase 04 Acceptance Gates
|
# Phase 04 Acceptance Gates
|
||||||
|
|
||||||
- [ ] G-04-1 applicant flow tests pass (E-002)
|
- [ ] G-04-1 applicant flow tests pass (E-002)
|
||||||
- [ ] G-04-2 jury flow tests pass (E-004)
|
- [ ] G-04-2 jury flow tests pass (E-004)
|
||||||
- [ ] G-04-3 live audience tests pass (E-006/E-007)
|
- [ ] G-04-3 live audience tests pass (E-006/E-007)
|
||||||
- [ ] G-04-4 reconnect and realtime resilience evidence passes (P-004)
|
- [ ] G-04-4 reconnect and realtime resilience evidence passes (P-004)
|
||||||
|
|
||||||
## Required Evidence
|
## Required Evidence
|
||||||
|
|
||||||
- E2E artifacts for applicant/jury/audience scenarios
|
- E2E artifacts for applicant/jury/audience scenarios
|
||||||
- realtime and reconnect test captures
|
- realtime and reconnect test captures
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,29 @@
|
||||||
# Applicant Experience Specification
|
# Applicant Experience Specification
|
||||||
|
|
||||||
## Required Views
|
## Required Views
|
||||||
|
|
||||||
1. current stage and timeline
|
1. current stage and timeline
|
||||||
2. stage-specific requirements
|
2. stage-specific requirements
|
||||||
3. deadlines and late policy status
|
3. deadlines and late policy status
|
||||||
4. team invite/account status
|
4. team invite/account status
|
||||||
5. decision history (policy-scoped)
|
5. decision history (policy-scoped)
|
||||||
|
|
||||||
## Behavior Requirements
|
## Behavior Requirements
|
||||||
|
|
||||||
- requirement upload slots are stage-aware
|
- requirement upload slots are stage-aware
|
||||||
- accepted MIME/size and deadline checks enforced at submit time
|
- accepted MIME/size and deadline checks enforced at submit time
|
||||||
- timeline updates reflect transition and decision events quickly
|
- timeline updates reflect transition and decision events quickly
|
||||||
- role-scoped team collaboration controls enforced
|
- role-scoped team collaboration controls enforced
|
||||||
|
|
||||||
## Error States
|
## Error States
|
||||||
|
|
||||||
- missing requirement definition
|
- missing requirement definition
|
||||||
- expired upload window
|
- expired upload window
|
||||||
- invalid MIME/size
|
- invalid MIME/size
|
||||||
- stale session/permission mismatch
|
- stale session/permission mismatch
|
||||||
|
|
||||||
## Notification Expectations
|
## Notification Expectations
|
||||||
|
|
||||||
- intake submitted confirmation
|
- intake submitted confirmation
|
||||||
- advanced/rejected updates
|
- advanced/rejected updates
|
||||||
- additional requirement requests when policy allows
|
- additional requirement requests when policy allows
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
# Audience Live Vote Specification
|
# Audience Live Vote Specification
|
||||||
|
|
||||||
## Core Rules
|
## Core Rules
|
||||||
|
|
||||||
1. Audience sees only projects within active cohort/window policy.
|
1. Audience sees only projects within active cohort/window policy.
|
||||||
2. Vote submission requires valid session eligibility and dedupe key check.
|
2. Vote submission requires valid session eligibility and dedupe key check.
|
||||||
3. Closed windows reject submissions with typed error.
|
3. Closed windows reject submissions with typed error.
|
||||||
|
|
||||||
## Voting Modes
|
## Voting Modes
|
||||||
|
|
||||||
- per-project window
|
- per-project window
|
||||||
- per-cohort window
|
- per-cohort window
|
||||||
- optional criteria mode or simple score mode
|
- optional criteria mode or simple score mode
|
||||||
|
|
||||||
## Safety and Abuse Controls
|
## Safety and Abuse Controls
|
||||||
|
|
||||||
- tokenized access policy
|
- tokenized access policy
|
||||||
- optional identity requirement
|
- optional identity requirement
|
||||||
- rate-limit and dedupe enforcement
|
- rate-limit and dedupe enforcement
|
||||||
|
|
||||||
## Realtime Requirements
|
## Realtime Requirements
|
||||||
|
|
||||||
- active project state and window state sync in near real-time
|
- active project state and window state sync in near real-time
|
||||||
- reconnect path restores current eligible ballot context
|
- reconnect path restores current eligible ballot context
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
# Jury Experience Specification
|
# Jury Experience Specification
|
||||||
|
|
||||||
## Assignment View
|
## Assignment View
|
||||||
|
|
||||||
- grouped by stage
|
- grouped by stage
|
||||||
- explicit open/close window indicators
|
- explicit open/close window indicators
|
||||||
- progress and completion states
|
- progress and completion states
|
||||||
|
|
||||||
## Evaluation View
|
## Evaluation View
|
||||||
|
|
||||||
- criteria loaded from stage config
|
- criteria loaded from stage config
|
||||||
- required criteria enforcement
|
- required criteria enforcement
|
||||||
- draft autosave and submit lock behavior
|
- draft autosave and submit lock behavior
|
||||||
- COI declaration flow integrated
|
- COI declaration flow integrated
|
||||||
|
|
||||||
## Access Rules
|
## Access Rules
|
||||||
|
|
||||||
- only assigned projects visible
|
- only assigned projects visible
|
||||||
- voting restricted to open windows
|
- voting restricted to open windows
|
||||||
- prior-stage material visibility policy respected
|
- prior-stage material visibility policy respected
|
||||||
|
|
||||||
## Live Jury Behavior
|
## Live Jury Behavior
|
||||||
|
|
||||||
- active project context sync via realtime updates
|
- active project context sync via realtime updates
|
||||||
- vote actions gated by cursor and window state
|
- vote actions gated by cursor and window state
|
||||||
- reconnect restores current live context
|
- reconnect restores current live context
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
# Phase 04 Overview: Participant Journeys
|
# Phase 04 Overview: Participant Journeys
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
Refit applicant, jury, observer, and audience experiences to stage-native contracts with correct realtime behavior.
|
Refit applicant, jury, observer, and audience experiences to stage-native contracts with correct realtime behavior.
|
||||||
|
|
||||||
## In Scope
|
## In Scope
|
||||||
|
|
||||||
- applicant intake/status flows
|
- applicant intake/status flows
|
||||||
- jury assignment/evaluation/live flows
|
- jury assignment/evaluation/live flows
|
||||||
- audience voting and live score flows
|
- audience voting and live score flows
|
||||||
- observer read-only reporting alignment
|
- observer read-only reporting alignment
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
- admin config internals
|
- admin config internals
|
||||||
|
|
||||||
## Exit Criteria
|
## Exit Criteria
|
||||||
|
|
||||||
1. End-to-end participant paths pass mandatory E2E tests.
|
1. End-to-end participant paths pass mandatory E2E tests.
|
||||||
2. Realtime behavior converges under reconnect and window changes.
|
2. Realtime behavior converges under reconnect and window changes.
|
||||||
3. Policy enforcement matches authz and stage contracts.
|
3. Policy enforcement matches authz and stage contracts.
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
# Phase 04 Tasks
|
# Phase 04 Tasks
|
||||||
|
|
||||||
## Applicant
|
## Applicant
|
||||||
|
|
||||||
- [ ] Implement stage-native timeline and requirement resolver.
|
- [ ] Implement stage-native timeline and requirement resolver.
|
||||||
- [ ] Implement strict upload gating and policy enforcement.
|
- [ ] Implement strict upload gating and policy enforcement.
|
||||||
|
|
||||||
## Jury
|
## Jury
|
||||||
|
|
||||||
- [ ] Implement stage-scoped assignment and evaluation surfaces.
|
- [ ] Implement stage-scoped assignment and evaluation surfaces.
|
||||||
- [ ] Implement live jury context sync and voting constraints.
|
- [ ] Implement live jury context sync and voting constraints.
|
||||||
|
|
||||||
## Audience and Observer
|
## Audience and Observer
|
||||||
|
|
||||||
- [ ] Implement cohort-scoped audience ballot visibility.
|
- [ ] Implement cohort-scoped audience ballot visibility.
|
||||||
- [ ] Implement observer read-only stage/track reporting alignment.
|
- [ ] Implement observer read-only stage/track reporting alignment.
|
||||||
|
|
||||||
## Realtime and Resilience
|
## Realtime and Resilience
|
||||||
|
|
||||||
- [ ] Implement reconnect-state convergence behavior.
|
- [ ] Implement reconnect-state convergence behavior.
|
||||||
- [ ] Validate realtime event consistency under cursor updates.
|
- [ ] Validate realtime event consistency under cursor updates.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
# Phase 05 Acceptance Gates
|
# Phase 05 Acceptance Gates
|
||||||
|
|
||||||
- [ ] G-05-1 routing mode behavior validated (`parallel`, `exclusive`, `post_main`)
|
- [ ] 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-2 governance auth tests pass (`JURY_VOTE`, `AWARD_MASTER`, `ADMIN`)
|
||||||
- [ ] G-05-3 winner decision timeline and audit output validated
|
- [ ] G-05-3 winner decision timeline and audit output validated
|
||||||
|
|
||||||
## Required Evidence
|
## Required Evidence
|
||||||
|
|
||||||
- integration test outputs for routing and governance
|
- integration test outputs for routing and governance
|
||||||
- audit timeline payload captures
|
- audit timeline payload captures
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,39 @@
|
||||||
# Award Track and Governance Specification
|
# Award Track and Governance Specification
|
||||||
|
|
||||||
## Award Track Principle
|
## Award Track Principle
|
||||||
Awards share the same orchestration engine as the main competition; they are tracks, not detached side workflows.
|
Awards share the same orchestration engine as the main competition; they are tracks, not detached side workflows.
|
||||||
|
|
||||||
## Routing Modes
|
## Routing Modes
|
||||||
|
|
||||||
- `PARALLEL`: award path runs while main path continues
|
- `PARALLEL`: award path runs while main path continues
|
||||||
- `EXCLUSIVE`: project exits main continuation path and runs award-only
|
- `EXCLUSIVE`: project exits main continuation path and runs award-only
|
||||||
- `POST_MAIN`: award route starts after configured main gate
|
- `POST_MAIN`: award route starts after configured main gate
|
||||||
|
|
||||||
## Governance Modes
|
## Governance Modes
|
||||||
|
|
||||||
- `JURY_VOTE`: assigned award jurors vote
|
- `JURY_VOTE`: assigned award jurors vote
|
||||||
- `AWARD_MASTER`: designated award owner decides within scope
|
- `AWARD_MASTER`: designated award owner decides within scope
|
||||||
- `ADMIN`: program/super admin decides
|
- `ADMIN`: program/super admin decides
|
||||||
|
|
||||||
## Decision Requirements
|
## Decision Requirements
|
||||||
|
|
||||||
- every winner/finalist decision emits audit entry
|
- every winner/finalist decision emits audit entry
|
||||||
- manual overrides require reason code and text
|
- manual overrides require reason code and text
|
||||||
- tie-break policy explicit and deterministic
|
- tie-break policy explicit and deterministic
|
||||||
|
|
||||||
## Permission Enforcement
|
## Permission Enforcement
|
||||||
|
|
||||||
- governance mode checked server-side on every decision mutation
|
- governance mode checked server-side on every decision mutation
|
||||||
- unauthorized attempts return `FORBIDDEN`
|
- unauthorized attempts return `FORBIDDEN`
|
||||||
|
|
||||||
## Representative Decision Payload
|
## Representative Decision Payload
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"awardId": "award_123",
|
"awardId": "award_123",
|
||||||
"decisionMode": "AWARD_MASTER",
|
"decisionMode": "AWARD_MASTER",
|
||||||
"winnerProjectId": "project_789",
|
"winnerProjectId": "project_789",
|
||||||
"reasonCode": "SPONSOR_DECISION",
|
"reasonCode": "SPONSOR_DECISION",
|
||||||
"reasonText": "Award sponsor selected based on category fit"
|
"reasonText": "Award sponsor selected based on category fit"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
# Phase 05 Overview: Special Awards and Governance
|
# Phase 05 Overview: Special Awards and Governance
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
Implement special awards as first-class tracks with explicit routing and governance modes, including `AWARD_MASTER`.
|
Implement special awards as first-class tracks with explicit routing and governance modes, including `AWARD_MASTER`.
|
||||||
|
|
||||||
## In Scope
|
## In Scope
|
||||||
|
|
||||||
- award track lifecycle
|
- award track lifecycle
|
||||||
- routing semantics
|
- routing semantics
|
||||||
- governance modes and permissions
|
- governance modes and permissions
|
||||||
- award decision and winner finalization workflows
|
- award decision and winner finalization workflows
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
- sponsor legal/contract process documentation
|
- sponsor legal/contract process documentation
|
||||||
|
|
||||||
## Exit Criteria
|
## Exit Criteria
|
||||||
|
|
||||||
1. Mixed award modes run without collision in a single edition.
|
1. Mixed award modes run without collision in a single edition.
|
||||||
2. Governance modes enforce correct permissions server-side.
|
2. Governance modes enforce correct permissions server-side.
|
||||||
3. Winner decision audit trails are complete and immutable.
|
3. Winner decision audit trails are complete and immutable.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Phase 05 Tasks
|
# Phase 05 Tasks
|
||||||
|
|
||||||
- [ ] Implement award track CRUD on canonical contracts.
|
- [ ] Implement award track CRUD on canonical contracts.
|
||||||
- [ ] Implement award routing mode behaviors and edge-case handling.
|
- [ ] Implement award routing mode behaviors and edge-case handling.
|
||||||
- [ ] Implement governance mode permission checks.
|
- [ ] Implement governance mode permission checks.
|
||||||
- [ ] Implement winner finalization and audit timeline entries.
|
- [ ] Implement winner finalization and audit timeline entries.
|
||||||
- [ ] Implement award-specific reporting outputs.
|
- [ ] Implement award-specific reporting outputs.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
# Phase 06 Acceptance Gates
|
# Phase 06 Acceptance Gates
|
||||||
|
|
||||||
- [ ] G-06-1 dependency refit inventory fully signed off
|
- [ ] G-06-1 dependency refit inventory fully signed off
|
||||||
- [ ] G-06-2 symbol sweeps clean (no runtime legacy hits)
|
- [ ] G-06-2 symbol sweeps clean (no runtime legacy hits)
|
||||||
- [ ] G-06-3 integration consumer payload checks pass
|
- [ ] G-06-3 integration consumer payload checks pass
|
||||||
- [ ] G-06-4 cross-role smoke tests pass
|
- [ ] G-06-4 cross-role smoke tests pass
|
||||||
|
|
||||||
## Required Evidence
|
## Required Evidence
|
||||||
|
|
||||||
- module sign-off checklist with owners
|
- module sign-off checklist with owners
|
||||||
- sweep outputs
|
- sweep outputs
|
||||||
- webhook/export consumer validation logs
|
- webhook/export consumer validation logs
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
# Module Refit Map
|
# Module Refit Map
|
||||||
|
|
||||||
## Router Layer Actions
|
## Router Layer Actions
|
||||||
|
|
||||||
- Rewrite orchestration endpoints to `pipeline/stage/routing` contracts.
|
- Rewrite orchestration endpoints to `pipeline/stage/routing` contracts.
|
||||||
- Refactor filtering, assignment, and live endpoints to stage-scoped semantics.
|
- Refactor filtering, assignment, and live endpoints to stage-scoped semantics.
|
||||||
- Replace award detached flows with award-track-native contracts.
|
- Replace award detached flows with award-track-native contracts.
|
||||||
|
|
||||||
## Service Layer Actions
|
## Service Layer Actions
|
||||||
|
|
||||||
- Refactor AI filtering context to stage-native payloads.
|
- Refactor AI filtering context to stage-native payloads.
|
||||||
- Refactor assignment engine to consume stage eligibility and availability.
|
- Refactor assignment engine to consume stage eligibility and availability.
|
||||||
- Refactor notification producers to new event taxonomy.
|
- Refactor notification producers to new event taxonomy.
|
||||||
- Refactor reminders and summaries to stage references.
|
- Refactor reminders and summaries to stage references.
|
||||||
|
|
||||||
## UI Layer Actions
|
## UI Layer Actions
|
||||||
|
|
||||||
- Admin round pages become pipeline/stage control-plane pages.
|
- Admin round pages become pipeline/stage control-plane pages.
|
||||||
- Jury and applicant pages consume stage timeline and stage requirement contracts.
|
- Jury and applicant pages consume stage timeline and stage requirement contracts.
|
||||||
- Public vote/live pages consume cohort and live cursor state.
|
- Public vote/live pages consume cohort and live cursor state.
|
||||||
|
|
||||||
## Reporting and Export Actions
|
## Reporting and Export Actions
|
||||||
|
|
||||||
- Replace round-grouped aggregations with stage/track aggregations.
|
- Replace round-grouped aggregations with stage/track aggregations.
|
||||||
- Update CSV/PDF payload field names to new contracts.
|
- Update CSV/PDF payload field names to new contracts.
|
||||||
- Update observer dashboards and chart dimensions.
|
- Update observer dashboards and chart dimensions.
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
# Phase 06 Overview: Platform Dependency Refit
|
# Phase 06 Overview: Platform Dependency Refit
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
Refit every platform dependency from legacy round semantics to canonical stage contracts.
|
Refit every platform dependency from legacy round semantics to canonical stage contracts.
|
||||||
|
|
||||||
## In Scope
|
## In Scope
|
||||||
|
|
||||||
- module-by-module refit execution
|
- module-by-module refit execution
|
||||||
- stale symbol removal
|
- stale symbol removal
|
||||||
- integration payload consumer updates
|
- integration payload consumer updates
|
||||||
- cross-role smoke validation
|
- cross-role smoke validation
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
- new feature expansion not required for contract migration
|
- new feature expansion not required for contract migration
|
||||||
|
|
||||||
## Exit Criteria
|
## Exit Criteria
|
||||||
|
|
||||||
1. dependency checklist complete
|
1. dependency checklist complete
|
||||||
2. legacy symbol sweeps clean
|
2. legacy symbol sweeps clean
|
||||||
3. external integration consumers validated
|
3. external integration consumers validated
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
# Symbol Sweep Checklist
|
# Symbol Sweep Checklist
|
||||||
|
|
||||||
All commands must return zero actionable runtime hits.
|
All commands must return zero actionable runtime hits.
|
||||||
|
|
||||||
- [ ] `rg "trpc\.round" src`
|
- [ ] `rg "trpc\.round" src`
|
||||||
- [ ] `rg "\broundId\b" src/server src/components src/app`
|
- [ ] `rg "\broundId\b" src/server src/components src/app`
|
||||||
- [ ] `rg "round\.settingsJson|roundType" src/server src/components src/app`
|
- [ ] `rg "round\.settingsJson|roundType" src/server src/components src/app`
|
||||||
- [ ] `rg "model Round|enum RoundType" prisma/schema.prisma`
|
- [ ] `rg "model Round|enum RoundType" prisma/schema.prisma`
|
||||||
|
|
||||||
## Exceptions
|
## Exceptions
|
||||||
|
|
||||||
- documentation-only references may be allowed with explicit annotation
|
- documentation-only references may be allowed with explicit annotation
|
||||||
- any code-path exception is release-blocking unless approved
|
- any code-path exception is release-blocking unless approved
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Phase 06 Tasks
|
# Phase 06 Tasks
|
||||||
|
|
||||||
- [ ] Execute router-layer refit checklist.
|
- [ ] Execute router-layer refit checklist.
|
||||||
- [ ] Execute service-layer refit checklist.
|
- [ ] Execute service-layer refit checklist.
|
||||||
- [ ] Execute UI-layer refit checklist.
|
- [ ] Execute UI-layer refit checklist.
|
||||||
- [ ] Execute reporting/export integration checklist.
|
- [ ] Execute reporting/export integration checklist.
|
||||||
- [ ] Run and document legacy symbol sweeps.
|
- [ ] Run and document legacy symbol sweeps.
|
||||||
- [ ] Resolve all remaining contract drift findings.
|
- [ ] Resolve all remaining contract drift findings.
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
# Phase 07 Acceptance Gates
|
# Phase 07 Acceptance Gates
|
||||||
|
|
||||||
- [ ] G-07-1 U/I/E/P matrix all green
|
- [ ] G-07-1 U/I/E/P matrix all green
|
||||||
- [ ] G-07-2 performance and resilience evidence accepted
|
- [ ] G-07-2 performance and resilience evidence accepted
|
||||||
- [ ] G-07-3 release evidence report complete and signed
|
- [ ] G-07-3 release evidence report complete and signed
|
||||||
- [ ] G-07-4 atomic cutover executed with successful post-checks
|
- [ ] G-07-4 atomic cutover executed with successful post-checks
|
||||||
|
|
||||||
## Required Evidence
|
## Required Evidence
|
||||||
|
|
||||||
- consolidated test reports
|
- consolidated test reports
|
||||||
- benchmark output captures
|
- benchmark output captures
|
||||||
- signed release evidence report
|
- signed release evidence report
|
||||||
- runbook execution logs
|
- runbook execution logs
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
# Phase 07 Overview: Validation and Release
|
# Phase 07 Overview: Validation and Release
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
Execute complete validation suite, run final reset/reseed rehearsal, and perform atomic release cutover.
|
Execute complete validation suite, run final reset/reseed rehearsal, and perform atomic release cutover.
|
||||||
|
|
||||||
## In Scope
|
## In Scope
|
||||||
|
|
||||||
- full U/I/E/P test execution
|
- full U/I/E/P test execution
|
||||||
- release evidence collation
|
- release evidence collation
|
||||||
- performance and resilience validation
|
- performance and resilience validation
|
||||||
- atomic release runbook execution
|
- atomic release runbook execution
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
- post-release enhancements
|
- post-release enhancements
|
||||||
|
|
||||||
## Exit Criteria
|
## Exit Criteria
|
||||||
|
|
||||||
1. full test matrix green
|
1. full test matrix green
|
||||||
2. release evidence signed
|
2. release evidence signed
|
||||||
3. atomic cutover and post-cutover smoke checks complete
|
3. atomic cutover and post-cutover smoke checks complete
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
# Performance and Resilience Plan
|
# Performance and Resilience Plan
|
||||||
|
|
||||||
## Scenarios
|
## Scenarios
|
||||||
|
|
||||||
### Assignment Throughput
|
### Assignment Throughput
|
||||||
|
|
||||||
- workload: 1000+ eligible projects
|
- workload: 1000+ eligible projects
|
||||||
- metrics: runtime, coverage latency, overload count
|
- metrics: runtime, coverage latency, overload count
|
||||||
|
|
||||||
### Filtering Throughput
|
### Filtering Throughput
|
||||||
|
|
||||||
- workload: high-volume gate + AI queue
|
- workload: high-volume gate + AI queue
|
||||||
- metrics: gate throughput, queue completion, retry/error rate
|
- metrics: gate throughput, queue completion, retry/error rate
|
||||||
|
|
||||||
### Live Voting Burst
|
### Live Voting Burst
|
||||||
|
|
||||||
- workload: peak audience voting during active cursor changes
|
- workload: peak audience voting during active cursor changes
|
||||||
- metrics: vote latency p50/p95/p99, event drop count, cursor propagation delay
|
- metrics: vote latency p50/p95/p99, event drop count, cursor propagation delay
|
||||||
|
|
||||||
### Reconnect Recovery
|
### Reconnect Recovery
|
||||||
|
|
||||||
- workload: intentional network interruptions
|
- workload: intentional network interruptions
|
||||||
- metrics: time to state convergence, stale cursor mismatch rate
|
- metrics: time to state convergence, stale cursor mismatch rate
|
||||||
|
|
||||||
## Acceptance Policy
|
## Acceptance Policy
|
||||||
|
|
||||||
Thresholds set before run and documented in release evidence.
|
Thresholds set before run and documented in release evidence.
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,39 @@
|
||||||
# Release Runbook (Atomic Cutover)
|
# Release Runbook (Atomic Cutover)
|
||||||
|
|
||||||
## Preconditions
|
## Preconditions
|
||||||
|
|
||||||
- all prior phase gates complete
|
- all prior phase gates complete
|
||||||
- signed release checklist
|
- signed release checklist
|
||||||
- rollback owner and communication owner assigned
|
- rollback owner and communication owner assigned
|
||||||
|
|
||||||
## Cutover Sequence
|
## Cutover Sequence
|
||||||
|
|
||||||
1. freeze non-release writes and announce maintenance window
|
1. freeze non-release writes and announce maintenance window
|
||||||
2. execute final backup snapshot
|
2. execute final backup snapshot
|
||||||
3. deploy release candidate build
|
3. deploy release candidate build
|
||||||
4. run reset/reseed as planned for production state model
|
4. run reset/reseed as planned for production state model
|
||||||
5. run post-deploy integrity and smoke checks
|
5. run post-deploy integrity and smoke checks
|
||||||
6. run mandatory critical-path E2E subset
|
6. run mandatory critical-path E2E subset
|
||||||
7. publish completion and monitor
|
7. publish completion and monitor
|
||||||
|
|
||||||
## Immediate Post-Cutover Checks
|
## Immediate Post-Cutover Checks
|
||||||
|
|
||||||
- auth and role gating paths
|
- auth and role gating paths
|
||||||
- transition mutation sanity
|
- transition mutation sanity
|
||||||
- assignment preview/execute path
|
- assignment preview/execute path
|
||||||
- live cursor operations
|
- live cursor operations
|
||||||
- audience vote acceptance and dedupe
|
- audience vote acceptance and dedupe
|
||||||
- reporting endpoint correctness
|
- reporting endpoint correctness
|
||||||
|
|
||||||
## Rollback Trigger Conditions
|
## Rollback Trigger Conditions
|
||||||
|
|
||||||
- integrity check failures
|
- integrity check failures
|
||||||
- critical mutation path failure
|
- critical mutation path failure
|
||||||
- unacceptable error-rate spike
|
- unacceptable error-rate spike
|
||||||
|
|
||||||
## Rollback Plan (High Level)
|
## Rollback Plan (High Level)
|
||||||
|
|
||||||
- restore backup snapshot
|
- restore backup snapshot
|
||||||
- redeploy previous stable build
|
- redeploy previous stable build
|
||||||
- validate critical-path smoke tests
|
- validate critical-path smoke tests
|
||||||
- issue incident communication and postmortem schedule
|
- issue incident communication and postmortem schedule
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Phase 07 Tasks
|
# Phase 07 Tasks
|
||||||
|
|
||||||
- [ ] Execute full test matrix and store artifacts.
|
- [ ] Execute full test matrix and store artifacts.
|
||||||
- [ ] Execute performance and resilience scenarios.
|
- [ ] Execute performance and resilience scenarios.
|
||||||
- [ ] Complete release evidence report.
|
- [ ] Complete release evidence report.
|
||||||
- [ ] Run atomic cutover rehearsal and production runbook.
|
- [ ] Run atomic cutover rehearsal and production runbook.
|
||||||
- [ ] Complete post-cutover smoke suite.
|
- [ ] Complete post-cutover smoke suite.
|
||||||
|
|
|
||||||
|
|
@ -1,97 +1,97 @@
|
||||||
# API Contracts
|
# API Contracts
|
||||||
|
|
||||||
## Contract Conventions
|
## Contract Conventions
|
||||||
|
|
||||||
- All mutations return typed `errorCode` and machine-readable `details` on failure.
|
- All mutations return typed `errorCode` and machine-readable `details` on failure.
|
||||||
- All state-changing operations emit deterministic audit events.
|
- All state-changing operations emit deterministic audit events.
|
||||||
- All response shapes include stable identifiers for client cache invalidation.
|
- All response shapes include stable identifiers for client cache invalidation.
|
||||||
|
|
||||||
## Router Families
|
## Router Families
|
||||||
|
|
||||||
### `pipeline`
|
### `pipeline`
|
||||||
|
|
||||||
- `pipeline.create`
|
- `pipeline.create`
|
||||||
- `pipeline.update`
|
- `pipeline.update`
|
||||||
- `pipeline.simulate`
|
- `pipeline.simulate`
|
||||||
- `pipeline.publish`
|
- `pipeline.publish`
|
||||||
- `pipeline.getSummary`
|
- `pipeline.getSummary`
|
||||||
|
|
||||||
### `stage`
|
### `stage`
|
||||||
|
|
||||||
- `stage.create`
|
- `stage.create`
|
||||||
- `stage.updateConfig`
|
- `stage.updateConfig`
|
||||||
- `stage.list`
|
- `stage.list`
|
||||||
- `stage.transition`
|
- `stage.transition`
|
||||||
- `stage.openWindow`
|
- `stage.openWindow`
|
||||||
- `stage.closeWindow`
|
- `stage.closeWindow`
|
||||||
|
|
||||||
### `routing`
|
### `routing`
|
||||||
|
|
||||||
- `routing.preview`
|
- `routing.preview`
|
||||||
- `routing.execute`
|
- `routing.execute`
|
||||||
- `routing.listRules`
|
- `routing.listRules`
|
||||||
- `routing.upsertRule`
|
- `routing.upsertRule`
|
||||||
- `routing.toggleRule`
|
- `routing.toggleRule`
|
||||||
|
|
||||||
### `filtering`
|
### `filtering`
|
||||||
|
|
||||||
- `filtering.previewBatch`
|
- `filtering.previewBatch`
|
||||||
- `filtering.runStageFiltering`
|
- `filtering.runStageFiltering`
|
||||||
- `filtering.getManualQueue`
|
- `filtering.getManualQueue`
|
||||||
- `filtering.resolveManualDecision`
|
- `filtering.resolveManualDecision`
|
||||||
|
|
||||||
### `assignment`
|
### `assignment`
|
||||||
|
|
||||||
- `assignment.previewStageProjects`
|
- `assignment.previewStageProjects`
|
||||||
- `assignment.assignStageProjects`
|
- `assignment.assignStageProjects`
|
||||||
- `assignment.getCoverageReport`
|
- `assignment.getCoverageReport`
|
||||||
- `assignment.rebalance`
|
- `assignment.rebalance`
|
||||||
|
|
||||||
### `cohort`
|
### `cohort`
|
||||||
|
|
||||||
- `cohort.create`
|
- `cohort.create`
|
||||||
- `cohort.assignProjects`
|
- `cohort.assignProjects`
|
||||||
- `cohort.openVoting`
|
- `cohort.openVoting`
|
||||||
- `cohort.closeVoting`
|
- `cohort.closeVoting`
|
||||||
|
|
||||||
### `live`
|
### `live`
|
||||||
|
|
||||||
- `live.start`
|
- `live.start`
|
||||||
- `live.setActiveProject`
|
- `live.setActiveProject`
|
||||||
- `live.jump`
|
- `live.jump`
|
||||||
- `live.reorder`
|
- `live.reorder`
|
||||||
- `live.pause`
|
- `live.pause`
|
||||||
- `live.resume`
|
- `live.resume`
|
||||||
|
|
||||||
### `decision`
|
### `decision`
|
||||||
|
|
||||||
- `decision.override`
|
- `decision.override`
|
||||||
- `decision.auditTimeline`
|
- `decision.auditTimeline`
|
||||||
|
|
||||||
### `award`
|
### `award`
|
||||||
|
|
||||||
- `award.createTrack`
|
- `award.createTrack`
|
||||||
- `award.configureGovernance`
|
- `award.configureGovernance`
|
||||||
- `award.routeProjects`
|
- `award.routeProjects`
|
||||||
- `award.finalizeWinners`
|
- `award.finalizeWinners`
|
||||||
|
|
||||||
## Error Contract
|
## Error Contract
|
||||||
|
|
||||||
- `BAD_REQUEST`
|
- `BAD_REQUEST`
|
||||||
- `UNAUTHORIZED`
|
- `UNAUTHORIZED`
|
||||||
- `FORBIDDEN`
|
- `FORBIDDEN`
|
||||||
- `NOT_FOUND`
|
- `NOT_FOUND`
|
||||||
- `CONFLICT`
|
- `CONFLICT`
|
||||||
- `PRECONDITION_FAILED`
|
- `PRECONDITION_FAILED`
|
||||||
- `INTERNAL_SERVER_ERROR`
|
- `INTERNAL_SERVER_ERROR`
|
||||||
|
|
||||||
## Event Contract (Representative)
|
## Event Contract (Representative)
|
||||||
|
|
||||||
- `stage.transitioned`
|
- `stage.transitioned`
|
||||||
- `routing.executed`
|
- `routing.executed`
|
||||||
- `filtering.completed`
|
- `filtering.completed`
|
||||||
- `assignment.generated`
|
- `assignment.generated`
|
||||||
- `live.cursor.updated`
|
- `live.cursor.updated`
|
||||||
- `cohort.window.changed`
|
- `cohort.window.changed`
|
||||||
- `decision.overridden`
|
- `decision.overridden`
|
||||||
- `award.winner.finalized`
|
- `award.winner.finalized`
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,32 @@
|
||||||
# Authorization Matrix
|
# Authorization Matrix
|
||||||
|
|
||||||
Roles:
|
Roles:
|
||||||
|
|
||||||
- `SUPER_ADMIN`
|
- `SUPER_ADMIN`
|
||||||
- `PROGRAM_ADMIN`
|
- `PROGRAM_ADMIN`
|
||||||
- `AWARD_MASTER`
|
- `AWARD_MASTER`
|
||||||
- `JURY_MEMBER`
|
- `JURY_MEMBER`
|
||||||
- `APPLICANT`
|
- `APPLICANT`
|
||||||
- `OBSERVER`
|
- `OBSERVER`
|
||||||
- `AUDIENCE` (public voting context)
|
- `AUDIENCE` (public voting context)
|
||||||
|
|
||||||
| Capability | Super Admin | Program Admin | Award Master | Jury | Applicant | Observer | Audience |
|
| Capability | Super Admin | Program Admin | Award Master | Jury | Applicant | Observer | Audience |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
| Create/Edit Pipeline | Yes | Yes (scoped) | No | No | No | No | No |
|
| Create/Edit Pipeline | Yes | Yes (scoped) | No | No | No | No | No |
|
||||||
| Publish 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 |
|
| Configure Stage Rules | Yes | Yes (scoped) | No | No | No | No | No |
|
||||||
| Execute Manual Transition | Yes | Yes (scoped) | Limited (award scoped) | 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 |
|
| 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 |
|
| 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 |
|
| Assign Jurors | Yes | Yes (scoped) | Award scoped | No | No | No | No |
|
||||||
| Submit Evaluation | No | No | Optional (if configured) | Yes (assigned only) | 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 |
|
| Upload Intake Docs | No | No | No | No | Yes | No | No |
|
||||||
| Control Live Cursor | Yes | Yes (scoped) | No | No | No | No | No |
|
| Control Live Cursor | Yes | Yes (scoped) | No | No | No | No | No |
|
||||||
| Cast Audience Vote | No | No | No | No | Optional | No | Yes |
|
| Cast Audience Vote | No | No | No | No | Optional | No | Yes |
|
||||||
|
|
||||||
## Policy Notes
|
## Policy Notes
|
||||||
|
|
||||||
1. Program scoping applies to all admin operations.
|
1. Program scoping applies to all admin operations.
|
||||||
2. `AWARD_MASTER` permissions are explicitly award-scoped and only active when governance mode allows it.
|
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.
|
3. Jury endpoints always enforce assignment ownership and window constraints.
|
||||||
4. Audience endpoints enforce cohort membership + window state + dedupe key policy.
|
4. Audience endpoints enforce cohort membership + window state + dedupe key policy.
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
# Decision Log (Locked)
|
# Decision Log (Locked)
|
||||||
|
|
||||||
| ID | Decision | Status | Rationale | Impacted Phases |
|
| ID | Decision | Status | Rationale | Impacted Phases |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| MX-001 | Canonical model is `Pipeline -> Track -> Stage` | Locked | Supports multi-track orchestration cleanly | 01-07 |
|
| 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-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-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-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-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-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-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-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-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-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-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 |
|
| 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
|
# Dependency Refit Inventory
|
||||||
|
|
||||||
This inventory is release-blocking. Every listed module must be validated against the new contracts.
|
This inventory is release-blocking. Every listed module must be validated against the new contracts.
|
||||||
|
|
||||||
## Backend Routers
|
## Backend Routers
|
||||||
|
|
||||||
- `src/server/routers/_app.ts`
|
- `src/server/routers/_app.ts`
|
||||||
- `src/server/routers/round.ts`
|
- `src/server/routers/round.ts`
|
||||||
- `src/server/routers/filtering.ts`
|
- `src/server/routers/filtering.ts`
|
||||||
- `src/server/routers/live-voting.ts`
|
- `src/server/routers/live-voting.ts`
|
||||||
- `src/server/routers/specialAward.ts`
|
- `src/server/routers/specialAward.ts`
|
||||||
- `src/server/routers/assignment.ts`
|
- `src/server/routers/assignment.ts`
|
||||||
- `src/server/routers/evaluation.ts`
|
- `src/server/routers/evaluation.ts`
|
||||||
- `src/server/routers/file.ts`
|
- `src/server/routers/file.ts`
|
||||||
- `src/server/routers/project.ts`
|
- `src/server/routers/project.ts`
|
||||||
- `src/server/routers/project-pool.ts`
|
- `src/server/routers/project-pool.ts`
|
||||||
- `src/server/routers/application.ts`
|
- `src/server/routers/application.ts`
|
||||||
- `src/server/routers/applicant.ts`
|
- `src/server/routers/applicant.ts`
|
||||||
- `src/server/routers/export.ts`
|
- `src/server/routers/export.ts`
|
||||||
- `src/server/routers/analytics.ts`
|
- `src/server/routers/analytics.ts`
|
||||||
- `src/server/routers/program.ts`
|
- `src/server/routers/program.ts`
|
||||||
- `src/server/routers/roundTemplate.ts`
|
- `src/server/routers/roundTemplate.ts`
|
||||||
- `src/server/routers/gracePeriod.ts`
|
- `src/server/routers/gracePeriod.ts`
|
||||||
- `src/server/routers/webhook.ts`
|
- `src/server/routers/webhook.ts`
|
||||||
|
|
||||||
## Backend Services
|
## Backend Services
|
||||||
|
|
||||||
- `src/server/services/smart-assignment.ts`
|
- `src/server/services/smart-assignment.ts`
|
||||||
- `src/server/services/ai-filtering.ts`
|
- `src/server/services/ai-filtering.ts`
|
||||||
- `src/server/services/ai-evaluation-summary.ts`
|
- `src/server/services/ai-evaluation-summary.ts`
|
||||||
- `src/server/services/evaluation-reminders.ts`
|
- `src/server/services/evaluation-reminders.ts`
|
||||||
- `src/server/services/in-app-notification.ts`
|
- `src/server/services/in-app-notification.ts`
|
||||||
- `src/server/services/award-eligibility-job.ts`
|
- `src/server/services/award-eligibility-job.ts`
|
||||||
- `src/server/services/webhook-dispatcher.ts`
|
- `src/server/services/webhook-dispatcher.ts`
|
||||||
|
|
||||||
## Admin Surfaces
|
## Admin Surfaces
|
||||||
|
|
||||||
- `src/app/(admin)/admin/rounds/**`
|
- `src/app/(admin)/admin/rounds/**`
|
||||||
- `src/app/(admin)/admin/awards/**`
|
- `src/app/(admin)/admin/awards/**`
|
||||||
- `src/app/(admin)/admin/reports/page.tsx`
|
- `src/app/(admin)/admin/reports/page.tsx`
|
||||||
- `src/components/admin/round-pipeline.tsx`
|
- `src/components/admin/round-pipeline.tsx`
|
||||||
- `src/components/admin/assign-projects-dialog.tsx`
|
- `src/components/admin/assign-projects-dialog.tsx`
|
||||||
- `src/components/admin/advance-projects-dialog.tsx`
|
- `src/components/admin/advance-projects-dialog.tsx`
|
||||||
- `src/components/admin/remove-projects-dialog.tsx`
|
- `src/components/admin/remove-projects-dialog.tsx`
|
||||||
- `src/components/admin/file-requirements-editor.tsx`
|
- `src/components/admin/file-requirements-editor.tsx`
|
||||||
- `src/components/forms/round-type-settings.tsx`
|
- `src/components/forms/round-type-settings.tsx`
|
||||||
|
|
||||||
## Jury, Applicant, Public
|
## Jury, Applicant, Public
|
||||||
|
|
||||||
- `src/app/(jury)/jury/**`
|
- `src/app/(jury)/jury/**`
|
||||||
- `src/components/jury/**`
|
- `src/components/jury/**`
|
||||||
- `src/app/(applicant)/applicant/**`
|
- `src/app/(applicant)/applicant/**`
|
||||||
- `src/app/(public)/apply/**`
|
- `src/app/(public)/apply/**`
|
||||||
- `src/app/(public)/my-submission/**`
|
- `src/app/(public)/my-submission/**`
|
||||||
- `src/app/(public)/vote/**`
|
- `src/app/(public)/vote/**`
|
||||||
- `src/app/(public)/live-scores/**`
|
- `src/app/(public)/live-scores/**`
|
||||||
|
|
||||||
## Reporting and Exports
|
## Reporting and Exports
|
||||||
|
|
||||||
- chart and observer modules under `src/components/charts/**` and `src/components/observer/**`
|
- 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`
|
- 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
|
## Schema and Seed Paths
|
||||||
|
|
||||||
- `prisma/schema.prisma`
|
- `prisma/schema.prisma`
|
||||||
- relevant migrations and seed scripts under `prisma/`
|
- relevant migrations and seed scripts under `prisma/`
|
||||||
|
|
||||||
## Mandatory Legacy Sweep Queries (Release Blockers)
|
## Mandatory Legacy Sweep Queries (Release Blockers)
|
||||||
|
|
||||||
1. `rg "trpc\.round" src`
|
1. `rg "trpc\.round" src`
|
||||||
2. `rg "\broundId\b" src/server src/components src/app`
|
2. `rg "\broundId\b" src/server src/components src/app`
|
||||||
3. `rg "round\.settingsJson|roundType" 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`
|
4. `rg "model Round|enum RoundType" prisma/schema.prisma`
|
||||||
|
|
||||||
Allowlist exceptions (if any) must be explicit and approved in Phase 06 gates.
|
Allowlist exceptions (if any) must be explicit and approved in Phase 06 gates.
|
||||||
|
|
|
||||||
|
|
@ -1,156 +1,156 @@
|
||||||
# Domain Model and Contracts
|
# Domain Model and Contracts
|
||||||
|
|
||||||
## Canonical Enums
|
## Canonical Enums
|
||||||
|
|
||||||
- `StageType = INTAKE | FILTER | EVALUATION | SELECTION | LIVE_FINAL | RESULTS`
|
- `StageType = INTAKE | FILTER | EVALUATION | SELECTION | LIVE_FINAL | RESULTS`
|
||||||
- `TrackKind = MAIN | AWARD | SHOWCASE`
|
- `TrackKind = MAIN | AWARD | SHOWCASE`
|
||||||
- `RoutingMode = PARALLEL | EXCLUSIVE | POST_MAIN`
|
- `RoutingMode = PARALLEL | EXCLUSIVE | POST_MAIN`
|
||||||
- `StageStatus = DRAFT | ACTIVE | CLOSED | ARCHIVED`
|
- `StageStatus = DRAFT | ACTIVE | CLOSED | ARCHIVED`
|
||||||
- `ProjectStageStateValue = PENDING | IN_PROGRESS | PASSED | REJECTED | ROUTED | COMPLETED | WITHDRAWN`
|
- `ProjectStageStateValue = PENDING | IN_PROGRESS | PASSED | REJECTED | ROUTED | COMPLETED | WITHDRAWN`
|
||||||
- `DecisionMode = JURY_VOTE | AWARD_MASTER | ADMIN`
|
- `DecisionMode = JURY_VOTE | AWARD_MASTER | ADMIN`
|
||||||
- `OverrideReasonCode = DATA_CORRECTION | POLICY_EXCEPTION | JURY_CONFLICT | SPONSOR_DECISION | ADMIN_DISCRETION`
|
- `OverrideReasonCode = DATA_CORRECTION | POLICY_EXCEPTION | JURY_CONFLICT | SPONSOR_DECISION | ADMIN_DISCRETION`
|
||||||
|
|
||||||
## Core Entities
|
## Core Entities
|
||||||
|
|
||||||
### Pipeline
|
### Pipeline
|
||||||
|
|
||||||
- `id`
|
- `id`
|
||||||
- `programId`
|
- `programId`
|
||||||
- `name`
|
- `name`
|
||||||
- `slug`
|
- `slug`
|
||||||
- `status`
|
- `status`
|
||||||
- `settingsJson`
|
- `settingsJson`
|
||||||
- `createdAt`, `updatedAt`
|
- `createdAt`, `updatedAt`
|
||||||
|
|
||||||
### Track
|
### Track
|
||||||
|
|
||||||
- `id`
|
- `id`
|
||||||
- `pipelineId`
|
- `pipelineId`
|
||||||
- `kind`
|
- `kind`
|
||||||
- `specialAwardId?`
|
- `specialAwardId?`
|
||||||
- `name`
|
- `name`
|
||||||
- `slug`
|
- `slug`
|
||||||
- `sortOrder`
|
- `sortOrder`
|
||||||
- `routingModeDefault?`
|
- `routingModeDefault?`
|
||||||
- `decisionMode?`
|
- `decisionMode?`
|
||||||
|
|
||||||
### Stage
|
### Stage
|
||||||
|
|
||||||
- `id`
|
- `id`
|
||||||
- `trackId`
|
- `trackId`
|
||||||
- `stageType`
|
- `stageType`
|
||||||
- `name`
|
- `name`
|
||||||
- `slug`
|
- `slug`
|
||||||
- `sortOrder`
|
- `sortOrder`
|
||||||
- `status`
|
- `status`
|
||||||
- `configVersion`
|
- `configVersion`
|
||||||
- `configJson`
|
- `configJson`
|
||||||
- `windowOpenAt?`, `windowCloseAt?`
|
- `windowOpenAt?`, `windowCloseAt?`
|
||||||
|
|
||||||
### StageTransition
|
### StageTransition
|
||||||
|
|
||||||
- `id`
|
- `id`
|
||||||
- `fromStageId`
|
- `fromStageId`
|
||||||
- `toStageId`
|
- `toStageId`
|
||||||
- `priority`
|
- `priority`
|
||||||
- `isDefault`
|
- `isDefault`
|
||||||
- `guardJson`
|
- `guardJson`
|
||||||
- `actionJson`
|
- `actionJson`
|
||||||
|
|
||||||
### ProjectStageState
|
### ProjectStageState
|
||||||
|
|
||||||
- `id`
|
- `id`
|
||||||
- `projectId`
|
- `projectId`
|
||||||
- `trackId`
|
- `trackId`
|
||||||
- `stageId`
|
- `stageId`
|
||||||
- `state`
|
- `state`
|
||||||
- `enteredAt`, `exitedAt`
|
- `enteredAt`, `exitedAt`
|
||||||
- `decisionRef?`
|
- `decisionRef?`
|
||||||
- `outcomeJson`
|
- `outcomeJson`
|
||||||
|
|
||||||
### RoutingRule
|
### RoutingRule
|
||||||
|
|
||||||
- `id`
|
- `id`
|
||||||
- `pipelineId`
|
- `pipelineId`
|
||||||
- `scope` (`GLOBAL|TRACK|STAGE`)
|
- `scope` (`GLOBAL|TRACK|STAGE`)
|
||||||
- `predicateJson`
|
- `predicateJson`
|
||||||
- `destinationTrackId`
|
- `destinationTrackId`
|
||||||
- `destinationStageId?`
|
- `destinationStageId?`
|
||||||
- `priority`
|
- `priority`
|
||||||
- `isActive`
|
- `isActive`
|
||||||
|
|
||||||
### Cohort and Live Runtime
|
### Cohort and Live Runtime
|
||||||
|
|
||||||
- `Cohort(id, stageId, name, votingMode, isOpen, windowOpenAt?, windowCloseAt?)`
|
- `Cohort(id, stageId, name, votingMode, isOpen, windowOpenAt?, windowCloseAt?)`
|
||||||
- `CohortProject(cohortId, projectId, sortOrder)`
|
- `CohortProject(cohortId, projectId, sortOrder)`
|
||||||
- `LiveProgressCursor(id, stageId, sessionId, activeProjectId?, activeOrderIndex?, updatedBy, updatedAt)`
|
- `LiveProgressCursor(id, stageId, sessionId, activeProjectId?, activeOrderIndex?, updatedBy, updatedAt)`
|
||||||
|
|
||||||
### Governance Entities
|
### Governance Entities
|
||||||
|
|
||||||
- `OverrideAction(id, entityType, entityId, oldValueJson, newValueJson, reasonCode, reasonText, actedBy, actedAt)`
|
- `OverrideAction(id, entityType, entityId, oldValueJson, newValueJson, reasonCode, reasonText, actedBy, actedAt)`
|
||||||
- `DecisionAuditLog(id, entityType, entityId, eventType, payloadJson, actorId?, createdAt)`
|
- `DecisionAuditLog(id, entityType, entityId, eventType, payloadJson, actorId?, createdAt)`
|
||||||
|
|
||||||
## Stage Config Union Contracts
|
## Stage Config Union Contracts
|
||||||
|
|
||||||
### IntakeConfig
|
### IntakeConfig
|
||||||
|
|
||||||
- file requirements
|
- file requirements
|
||||||
- accepted MIME and size constraints
|
- accepted MIME and size constraints
|
||||||
- deadline and late policy
|
- deadline and late policy
|
||||||
- team invite policy
|
- team invite policy
|
||||||
|
|
||||||
### FilterConfig
|
### FilterConfig
|
||||||
|
|
||||||
- deterministic gates
|
- deterministic gates
|
||||||
- AI rubric
|
- AI rubric
|
||||||
- confidence thresholds
|
- confidence thresholds
|
||||||
- manual queue policy
|
- manual queue policy
|
||||||
- rejection notification policy
|
- rejection notification policy
|
||||||
|
|
||||||
### EvaluationConfig
|
### EvaluationConfig
|
||||||
|
|
||||||
- criteria schema
|
- criteria schema
|
||||||
- assignment strategy
|
- assignment strategy
|
||||||
- review thresholds
|
- review thresholds
|
||||||
- COI policy
|
- COI policy
|
||||||
- visibility rules
|
- visibility rules
|
||||||
|
|
||||||
### SelectionConfig
|
### SelectionConfig
|
||||||
|
|
||||||
- ranking source
|
- ranking source
|
||||||
- finalist target
|
- finalist target
|
||||||
- override permissions
|
- override permissions
|
||||||
- promotion mode (`auto_top_n`, `hybrid`, `manual`)
|
- promotion mode (`auto_top_n`, `hybrid`, `manual`)
|
||||||
|
|
||||||
### LiveFinalConfig
|
### LiveFinalConfig
|
||||||
|
|
||||||
- session behavior
|
- session behavior
|
||||||
- jury voting config
|
- jury voting config
|
||||||
- audience voting config
|
- audience voting config
|
||||||
- cohort policy
|
- cohort policy
|
||||||
- reveal policy
|
- reveal policy
|
||||||
- schedule hints (advisory)
|
- schedule hints (advisory)
|
||||||
|
|
||||||
### ResultsConfig
|
### ResultsConfig
|
||||||
|
|
||||||
- ranking weight rules
|
- ranking weight rules
|
||||||
- publication policy
|
- publication policy
|
||||||
- winner override rules
|
- winner override rules
|
||||||
|
|
||||||
## Constraint Rules
|
## Constraint Rules
|
||||||
|
|
||||||
1. Stage ordering unique per track (`trackId + sortOrder`).
|
1. Stage ordering unique per track (`trackId + sortOrder`).
|
||||||
2. `ProjectStageState` unique on (`projectId`, `trackId`, `stageId`).
|
2. `ProjectStageState` unique on (`projectId`, `trackId`, `stageId`).
|
||||||
3. `StageTransition` unique on (`fromStageId`, `toStageId`).
|
3. `StageTransition` unique on (`fromStageId`, `toStageId`).
|
||||||
4. Transition destination must remain in same pipeline unless explicit routing rule applies.
|
4. Transition destination must remain in same pipeline unless explicit routing rule applies.
|
||||||
5. Override records immutable after insert.
|
5. Override records immutable after insert.
|
||||||
6. Decision audit log append-only.
|
6. Decision audit log append-only.
|
||||||
|
|
||||||
## Index Priorities
|
## Index Priorities
|
||||||
|
|
||||||
1. `ProjectStageState(projectId, trackId, state)`
|
1. `ProjectStageState(projectId, trackId, state)`
|
||||||
2. `ProjectStageState(stageId, state)`
|
2. `ProjectStageState(stageId, state)`
|
||||||
3. `RoutingRule(pipelineId, isActive, priority)`
|
3. `RoutingRule(pipelineId, isActive, priority)`
|
||||||
4. `StageTransition(fromStageId, priority)`
|
4. `StageTransition(fromStageId, priority)`
|
||||||
5. `LiveProgressCursor(stageId, sessionId)`
|
5. `LiveProgressCursor(stageId, sessionId)`
|
||||||
6. `DecisionAuditLog(entityType, entityId, createdAt)`
|
6. `DecisionAuditLog(entityType, entityId, createdAt)`
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,32 @@
|
||||||
# Phase Gate Traceability
|
# Phase Gate Traceability
|
||||||
|
|
||||||
| Phase | Gate ID | Evidence Required | Test IDs / Checks | Blocking |
|
| Phase | Gate ID | Evidence Required | Test IDs / Checks | Blocking |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 00 | G-00-1 | decision lock snapshot | decision-log review | Yes |
|
| 00 | G-00-1 | decision lock snapshot | decision-log review | Yes |
|
||||||
| 00 | G-00-2 | contract alignment review | API/type contract diff | 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-1 | schema compile output | `prisma generate` | Yes |
|
||||||
| 01 | G-01-2 | reset/reseed output | seed logs + integrity queries | 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 |
|
| 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-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-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-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-4 | assignment guarantees proof | U-006/U-007/I-005 | Yes |
|
||||||
| 02 | G-02-5 | audit/override proof | U-008/I-008 | 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-1 | create/edit parity proof | parity checklist | Yes |
|
||||||
| 03 | G-03-2 | wizard completion proof | E-001 | Yes |
|
| 03 | G-03-2 | wizard completion proof | E-001 | Yes |
|
||||||
| 03 | G-03-3 | modal safety proof | targeted UI regressions | 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-1 | applicant flow proof | E-002 | Yes |
|
||||||
| 04 | G-04-2 | jury flow proof | E-004 | 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 |
|
| 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-1 | award routing proof | I-003/I-004 | Yes |
|
||||||
| 05 | G-05-2 | governance auth proof | U-010 + auth tests | 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 |
|
| 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-1 | dependency checklist complete | module sign-off evidence | Yes |
|
||||||
| 06 | G-06-2 | legacy sweeps clean | mandatory rg sweeps | Yes |
|
| 06 | G-06-2 | legacy sweeps clean | mandatory rg sweeps | Yes |
|
||||||
| 06 | G-06-3 | external consumer validation | webhook/export checks | 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-1 | full test report | full matrix results | Yes |
|
||||||
| 07 | G-07-2 | performance report | P-001..P-004 evidence | 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-3 | release evidence package | signed report template | Yes |
|
||||||
| 07 | G-07-4 | atomic cutover proof | release runbook logs | Yes |
|
| 07 | G-07-4 | atomic cutover proof | release runbook logs | Yes |
|
||||||
|
|
||||||
Rule: no phase closes until all gates are complete with linked artifacts.
|
Rule: no phase closes until all gates are complete with linked artifacts.
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,43 @@
|
||||||
# Program Charter
|
# Program Charter
|
||||||
|
|
||||||
## Mission
|
## Mission
|
||||||
Deliver a complete, stage-native orchestration platform for MOPC that supports:
|
Deliver a complete, stage-native orchestration platform for MOPC that supports:
|
||||||
|
|
||||||
- edition-scoped intake and progression
|
- edition-scoped intake and progression
|
||||||
- deterministic filtering and assignment
|
- deterministic filtering and assignment
|
||||||
- parallel and exclusive award flows
|
- parallel and exclusive award flows
|
||||||
- admin-driven live finals operations
|
- admin-driven live finals operations
|
||||||
- full auditability and release-grade validation
|
- full auditability and release-grade validation
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
### In Scope
|
### In Scope
|
||||||
|
|
||||||
- Canonical data model rebuild around pipeline/track/stage.
|
- Canonical data model rebuild around pipeline/track/stage.
|
||||||
- Backend orchestration engine (transition, routing, filtering, assignment, live, notifications, audit).
|
- Backend orchestration engine (transition, routing, filtering, assignment, live, notifications, audit).
|
||||||
- Admin setup and control-plane UX refit.
|
- Admin setup and control-plane UX refit.
|
||||||
- Applicant, jury, observer, and audience flow refit to new contracts.
|
- Applicant, jury, observer, and audience flow refit to new contracts.
|
||||||
- Special award governance modes including `AWARD_MASTER`.
|
- Special award governance modes including `AWARD_MASTER`.
|
||||||
- Platform-wide dependency refit of schema/runtime consumers.
|
- Platform-wide dependency refit of schema/runtime consumers.
|
||||||
- Full validation and atomic release process.
|
- Full validation and atomic release process.
|
||||||
|
|
||||||
### Out of Scope
|
### Out of Scope
|
||||||
|
|
||||||
- Legacy contract compatibility bridges.
|
- Legacy contract compatibility bridges.
|
||||||
- Cosmetic redesign or major brand refresh.
|
- Cosmetic redesign or major brand refresh.
|
||||||
- Non-orchestration feature expansion unrelated to competition lifecycle.
|
- Non-orchestration feature expansion unrelated to competition lifecycle.
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
1. Admin setup can fully configure required competition behavior in create-time flow.
|
1. Admin setup can fully configure required competition behavior in create-time flow.
|
||||||
2. Stage progression and routing are deterministic and explainable.
|
2. Stage progression and routing are deterministic and explainable.
|
||||||
3. Award tracks run without ad hoc side logic.
|
3. Award tracks run without ad hoc side logic.
|
||||||
4. Live event operations are resilient under reconnect and burst traffic.
|
4. Live event operations are resilient under reconnect and burst traffic.
|
||||||
5. All platform dependencies are migrated and verified before release.
|
5. All platform dependencies are migrated and verified before release.
|
||||||
|
|
||||||
## Quality Bar
|
## Quality Bar
|
||||||
|
|
||||||
- Typed contracts at schema, API, and UI boundaries.
|
- Typed contracts at schema, API, and UI boundaries.
|
||||||
- Idempotent mutation semantics for high-risk operations.
|
- Idempotent mutation semantics for high-risk operations.
|
||||||
- Strong audit trails for every governance-sensitive action.
|
- Strong audit trails for every governance-sensitive action.
|
||||||
- Mobile-safe interaction quality for live audience and jury experiences.
|
- Mobile-safe interaction quality for live audience and jury experiences.
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,65 @@
|
||||||
# Release Evidence Report Template
|
# Release Evidence Report Template
|
||||||
|
|
||||||
## Build Metadata
|
## Build Metadata
|
||||||
|
|
||||||
- Date:
|
- Date:
|
||||||
- Commit SHA:
|
- Commit SHA:
|
||||||
- Environment:
|
- Environment:
|
||||||
- Operator:
|
- Operator:
|
||||||
|
|
||||||
## Phase Completion Summary
|
## Phase Completion Summary
|
||||||
|
|
||||||
- Phase 00:
|
- Phase 00:
|
||||||
- Phase 01:
|
- Phase 01:
|
||||||
- Phase 02:
|
- Phase 02:
|
||||||
- Phase 03:
|
- Phase 03:
|
||||||
- Phase 04:
|
- Phase 04:
|
||||||
- Phase 05:
|
- Phase 05:
|
||||||
- Phase 06:
|
- Phase 06:
|
||||||
- Phase 07:
|
- Phase 07:
|
||||||
|
|
||||||
## Test Summary
|
## Test Summary
|
||||||
|
|
||||||
- Unit: pass/fail counts
|
- Unit: pass/fail counts
|
||||||
- Integration: pass/fail counts
|
- Integration: pass/fail counts
|
||||||
- E2E: pass/fail counts
|
- E2E: pass/fail counts
|
||||||
- Performance: pass/fail counts
|
- Performance: pass/fail counts
|
||||||
|
|
||||||
## Mandatory Scenario Results
|
## Mandatory Scenario Results
|
||||||
|
|
||||||
| ID | Result | Evidence Link | Notes |
|
| ID | Result | Evidence Link | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| E-001 | | | |
|
| E-001 | | | |
|
||||||
| E-002 | | | |
|
| E-002 | | | |
|
||||||
| E-003 | | | |
|
| E-003 | | | |
|
||||||
| E-004 | | | |
|
| E-004 | | | |
|
||||||
| E-005 | | | |
|
| E-005 | | | |
|
||||||
| E-006 | | | |
|
| E-006 | | | |
|
||||||
| E-007 | | | |
|
| E-007 | | | |
|
||||||
| E-008 | | | |
|
| E-008 | | | |
|
||||||
|
|
||||||
## Performance Results
|
## Performance Results
|
||||||
|
|
||||||
| ID | Result | Evidence Link | Notes |
|
| ID | Result | Evidence Link | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| P-001 | | | |
|
| P-001 | | | |
|
||||||
| P-002 | | | |
|
| P-002 | | | |
|
||||||
| P-003 | | | |
|
| P-003 | | | |
|
||||||
| P-004 | | | |
|
| P-004 | | | |
|
||||||
|
|
||||||
## Legacy Sweep Results
|
## Legacy Sweep Results
|
||||||
|
|
||||||
- `trpc.round` references:
|
- `trpc.round` references:
|
||||||
- `roundId` orchestration references:
|
- `roundId` orchestration references:
|
||||||
- `round.settingsJson` behavior references:
|
- `round.settingsJson` behavior references:
|
||||||
- schema `Round` references:
|
- schema `Round` references:
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
|
|
||||||
- None / list with severity and owner
|
- None / list with severity and owner
|
||||||
|
|
||||||
## Sign-Off
|
## Sign-Off
|
||||||
|
|
||||||
- Engineering:
|
- Engineering:
|
||||||
- Product:
|
- Product:
|
||||||
- Operations:
|
- Operations:
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
# Risk Register
|
# Risk Register
|
||||||
|
|
||||||
| ID | Risk | Probability | Impact | Mitigation | Owner | Status |
|
| 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-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-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-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-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-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-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-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 |
|
| R-008 | Scope creep during dependency refit | High | Medium | strict out-of-scope policy, defer noncritical features | PM | Open |
|
||||||
|
|
||||||
## Risk Handling Policy
|
## Risk Handling Policy
|
||||||
|
|
||||||
- `High impact` items require explicit mitigation evidence before phase close.
|
- `High impact` items require explicit mitigation evidence before phase close.
|
||||||
- `Open` high/high risks block release in Phase 07.
|
- `Open` high/high risks block release in Phase 07.
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,57 @@
|
||||||
# Test Matrix
|
# Test Matrix
|
||||||
|
|
||||||
All IDs are mandatory unless explicitly marked non-blocking with sign-off.
|
All IDs are mandatory unless explicitly marked non-blocking with sign-off.
|
||||||
|
|
||||||
## Unit Tests
|
## Unit Tests
|
||||||
|
|
||||||
| ID | Area | Scenario | Expected |
|
| ID | Area | Scenario | Expected |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| U-001 | Transition Engine | legal transition | persisted with audit event |
|
| U-001 | Transition Engine | legal transition | persisted with audit event |
|
||||||
| U-002 | Transition Engine | illegal transition | typed validation error |
|
| U-002 | Transition Engine | illegal transition | typed validation error |
|
||||||
| U-003 | Routing | multiple rule match | deterministic priority winner |
|
| U-003 | Routing | multiple rule match | deterministic priority winner |
|
||||||
| U-004 | Filtering Gates | missing required docs | blocked before AI pass |
|
| U-004 | Filtering Gates | missing required docs | blocked before AI pass |
|
||||||
| U-005 | AI Banding | uncertain confidence band | routed to manual queue |
|
| U-005 | AI Banding | uncertain confidence band | routed to manual queue |
|
||||||
| U-006 | Assignment | COI conflict | excluded from pool |
|
| U-006 | Assignment | COI conflict | excluded from pool |
|
||||||
| U-007 | Assignment | insufficient capacity | overflow flagged + coverage preserved |
|
| U-007 | Assignment | insufficient capacity | overflow flagged + coverage preserved |
|
||||||
| U-008 | Override | missing reason fields | mutation rejected |
|
| U-008 | Override | missing reason fields | mutation rejected |
|
||||||
| U-009 | Live Cursor | concurrent cursor update | conflict handled and retried |
|
| U-009 | Live Cursor | concurrent cursor update | conflict handled and retried |
|
||||||
| U-010 | Award Governance | `AWARD_MASTER` on unauthorized award | forbidden |
|
| U-010 | Award Governance | `AWARD_MASTER` on unauthorized award | forbidden |
|
||||||
|
|
||||||
## Integration Tests
|
## Integration Tests
|
||||||
|
|
||||||
| ID | Area | Scenario | Expected |
|
| ID | Area | Scenario | Expected |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| I-001 | Pipeline CRUD | create/update/publish | graph integrity maintained |
|
| I-001 | Pipeline CRUD | create/update/publish | graph integrity maintained |
|
||||||
| I-002 | Stage Config | invalid config schema | rejected |
|
| I-002 | Stage Config | invalid config schema | rejected |
|
||||||
| I-003 | Transition + Routing | filter pass to main + award parallel | dual states created |
|
| 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-004 | Award Exclusive Routing | exclusive route | removed from main continuation |
|
||||||
| I-005 | Assignment API | preview vs execute parity | same constraints and outcomes |
|
| 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-006 | Live Runtime | jump + reorder + open/close windows | consistent cursor state |
|
||||||
| I-007 | Cohort Voting | closed window submit | vote rejected |
|
| I-007 | Cohort Voting | closed window submit | vote rejected |
|
||||||
| I-008 | Decision Audit | override applied | complete immutable timeline |
|
| I-008 | Decision Audit | override applied | complete immutable timeline |
|
||||||
|
|
||||||
## End-to-End Tests
|
## End-to-End Tests
|
||||||
|
|
||||||
| ID | Persona | Scenario | Expected |
|
| ID | Persona | Scenario | Expected |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| E-001 | Admin | complete setup via wizard | no hidden edit-only blockers |
|
| E-001 | Admin | complete setup via wizard | no hidden edit-only blockers |
|
||||||
| E-002 | Applicant | upload intake requirements | status and deadlines enforced |
|
| E-002 | Applicant | upload intake requirements | status and deadlines enforced |
|
||||||
| E-003 | Admin | run filtering stage | gates + AI + manual queue behave |
|
| E-003 | Admin | run filtering stage | gates + AI + manual queue behave |
|
||||||
| E-004 | Jury | complete evaluation workflow | criteria and lock policy enforced |
|
| E-004 | Jury | complete evaluation workflow | criteria and lock policy enforced |
|
||||||
| E-005 | Admin | selection + override | finalists and audit aligned |
|
| E-005 | Admin | selection + override | finalists and audit aligned |
|
||||||
| E-006 | Live Admin | advance/back/jump + reorder | jury and audience sync realtime |
|
| 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-007 | Audience | vote by cohort on mobile | visibility and dedupe enforced |
|
||||||
| E-008 | Admin | finalize results | ranking and publish outputs valid |
|
| E-008 | Admin | finalize results | ranking and publish outputs valid |
|
||||||
|
|
||||||
## Performance and Resilience
|
## Performance and Resilience
|
||||||
|
|
||||||
| ID | Area | Scenario | Threshold |
|
| ID | Area | Scenario | Threshold |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| P-001 | Assignment | 1000+ project batch | under agreed SLA |
|
| P-001 | Assignment | 1000+ project batch | under agreed SLA |
|
||||||
| P-002 | Filtering | large AI queue | deterministic retry, no dropped jobs |
|
| 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-003 | Live Voting | peak audience burst | acceptable p95 and no data loss |
|
||||||
| P-004 | Reconnect | disconnect/reconnect | state converges quickly |
|
| P-004 | Reconnect | disconnect/reconnect | state converges quickly |
|
||||||
|
|
||||||
## Release Block Rule
|
## Release Block Rule
|
||||||
|
|
||||||
Any failing `U-*`, `I-*`, `E-*`, or `P-*` is release-blocking unless signed waiver exists.
|
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'
|
import type { NextConfig } from 'next'
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
typedRoutes: true,
|
typedRoutes: true,
|
||||||
serverExternalPackages: ['@prisma/client', 'minio'],
|
serverExternalPackages: ['@prisma/client', 'minio'],
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ['lucide-react'],
|
optimizePackageImports: ['lucide-react'],
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: '*.minio.local',
|
hostname: '*.minio.local',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
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",
|
"name": "mopc-platform",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:migrate:deploy": "prisma migrate deploy",
|
"db:migrate:deploy": "prisma migrate deploy",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"db:seed:candidatures": "tsx prisma/seed-candidatures.ts",
|
"db:seed:candidatures": "tsx prisma/seed-candidatures.ts",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.7.4",
|
"@auth/prisma-adapter": "^2.7.4",
|
||||||
"@blocknote/core": "^0.46.2",
|
"@blocknote/core": "^0.46.2",
|
||||||
"@blocknote/mantine": "^0.46.2",
|
"@blocknote/mantine": "^0.46.2",
|
||||||
"@blocknote/react": "^0.46.2",
|
"@blocknote/react": "^0.46.2",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@mantine/core": "^8.3.13",
|
"@mantine/core": "^8.3.13",
|
||||||
"@mantine/hooks": "^8.3.13",
|
"@mantine/hooks": "^8.3.13",
|
||||||
"@notionhq/client": "^2.3.0",
|
"@notionhq/client": "^2.3.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
"@radix-ui/react-checkbox": "^1.1.3",
|
"@radix-ui/react-checkbox": "^1.1.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.2",
|
"@radix-ui/react-collapsible": "^1.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-popover": "^1.1.4",
|
"@radix-ui/react-popover": "^1.1.4",
|
||||||
"@radix-ui/react-progress": "^1.1.1",
|
"@radix-ui/react-progress": "^1.1.1",
|
||||||
"@radix-ui/react-radio-group": "^1.2.2",
|
"@radix-ui/react-radio-group": "^1.2.2",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.1.4",
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
"@radix-ui/react-separator": "^1.1.1",
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slider": "^1.2.2",
|
"@radix-ui/react-slider": "^1.2.2",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"@trpc/client": "^11.0.0-rc.678",
|
"@trpc/client": "^11.0.0-rc.678",
|
||||||
"@trpc/react-query": "^11.0.0-rc.678",
|
"@trpc/react-query": "^11.0.0-rc.678",
|
||||||
"@trpc/server": "^11.0.0-rc.678",
|
"@trpc/server": "^11.0.0-rc.678",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.4",
|
"cmdk": "^1.0.4",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^4.1.0",
|
"jspdf": "^4.1.0",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"minio": "^8.0.2",
|
"minio": "^8.0.2",
|
||||||
"motion": "^11.15.0",
|
"motion": "^11.15.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nodemailer": "^7.0.7",
|
"nodemailer": "^7.0.7",
|
||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-easy-crop": "^5.5.6",
|
"react-easy-crop": "^5.5.6",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-phone-number-input": "^3.4.14",
|
"react-phone-number-input": "^3.4.14",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"superjson": "^2.2.2",
|
"superjson": "^2.2.2",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.49.1",
|
"@playwright/test": "^1.49.1",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/papaparse": "^5.3.15",
|
"@types/papaparse": "^5.3.15",
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-config-next": "^15.1.0",
|
"eslint-config-next": "^15.1.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,246 +1,246 @@
|
||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
const prisma = new PrismaClient()
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
interface CheckResult {
|
interface CheckResult {
|
||||||
name: string
|
name: string
|
||||||
passed: boolean
|
passed: boolean
|
||||||
details: string
|
details: string
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runChecks(): Promise<CheckResult[]> {
|
async function runChecks(): Promise<CheckResult[]> {
|
||||||
const results: CheckResult[] = []
|
const results: CheckResult[] = []
|
||||||
|
|
||||||
// 1. No orphan ProjectStageState (every PSS references valid project, track, stage)
|
// 1. No orphan ProjectStageState (every PSS references valid project, track, stage)
|
||||||
const orphanStates = await prisma.$queryRaw<{ count: bigint }[]>`
|
const orphanStates = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||||
SELECT COUNT(*) as count FROM "ProjectStageState" pss
|
SELECT COUNT(*) as count FROM "ProjectStageState" pss
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM "Project" p WHERE p.id = pss."projectId")
|
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 "Track" t WHERE t.id = pss."trackId")
|
||||||
OR NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = pss."stageId")
|
OR NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = pss."stageId")
|
||||||
`
|
`
|
||||||
const orphanCount = Number(orphanStates[0]?.count ?? 0)
|
const orphanCount = Number(orphanStates[0]?.count ?? 0)
|
||||||
results.push({
|
results.push({
|
||||||
name: 'No orphan ProjectStageState',
|
name: 'No orphan ProjectStageState',
|
||||||
passed: orphanCount === 0,
|
passed: orphanCount === 0,
|
||||||
details: orphanCount === 0 ? 'All PSS records reference valid entities' : `Found ${orphanCount} orphan records`,
|
details: orphanCount === 0 ? 'All PSS records reference valid entities' : `Found ${orphanCount} orphan records`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2. Every project has at least one stage state
|
// 2. Every project has at least one stage state
|
||||||
const projectsWithoutState = await prisma.$queryRaw<{ count: bigint }[]>`
|
const projectsWithoutState = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||||
SELECT COUNT(*) as count FROM "Project" p
|
SELECT COUNT(*) as count FROM "Project" p
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM "ProjectStageState" pss WHERE pss."projectId" = p.id)
|
WHERE NOT EXISTS (SELECT 1 FROM "ProjectStageState" pss WHERE pss."projectId" = p.id)
|
||||||
`
|
`
|
||||||
const noStateCount = Number(projectsWithoutState[0]?.count ?? 0)
|
const noStateCount = Number(projectsWithoutState[0]?.count ?? 0)
|
||||||
const totalProjects = await prisma.project.count()
|
const totalProjects = await prisma.project.count()
|
||||||
results.push({
|
results.push({
|
||||||
name: 'Every project has at least one stage state',
|
name: 'Every project has at least one stage state',
|
||||||
passed: noStateCount === 0,
|
passed: noStateCount === 0,
|
||||||
details: noStateCount === 0
|
details: noStateCount === 0
|
||||||
? `All ${totalProjects} projects have stage states`
|
? `All ${totalProjects} projects have stage states`
|
||||||
: `${noStateCount} projects missing stage states`,
|
: `${noStateCount} projects missing stage states`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 3. No duplicate active states per (project, track, stage)
|
// 3. No duplicate active states per (project, track, stage)
|
||||||
const duplicateStates = await prisma.$queryRaw<{ count: bigint }[]>`
|
const duplicateStates = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||||
SELECT COUNT(*) as count FROM (
|
SELECT COUNT(*) as count FROM (
|
||||||
SELECT "projectId", "trackId", "stageId", COUNT(*) as cnt
|
SELECT "projectId", "trackId", "stageId", COUNT(*) as cnt
|
||||||
FROM "ProjectStageState"
|
FROM "ProjectStageState"
|
||||||
WHERE "exitedAt" IS NULL
|
WHERE "exitedAt" IS NULL
|
||||||
GROUP BY "projectId", "trackId", "stageId"
|
GROUP BY "projectId", "trackId", "stageId"
|
||||||
HAVING COUNT(*) > 1
|
HAVING COUNT(*) > 1
|
||||||
) dupes
|
) dupes
|
||||||
`
|
`
|
||||||
const dupeCount = Number(duplicateStates[0]?.count ?? 0)
|
const dupeCount = Number(duplicateStates[0]?.count ?? 0)
|
||||||
results.push({
|
results.push({
|
||||||
name: 'No duplicate active states per (project, track, stage)',
|
name: 'No duplicate active states per (project, track, stage)',
|
||||||
passed: dupeCount === 0,
|
passed: dupeCount === 0,
|
||||||
details: dupeCount === 0 ? 'No duplicates found' : `Found ${dupeCount} duplicate active states`,
|
details: dupeCount === 0 ? 'No duplicates found' : `Found ${dupeCount} duplicate active states`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 4. All transitions stay within same pipeline
|
// 4. All transitions stay within same pipeline
|
||||||
const crossPipelineTransitions = await prisma.$queryRaw<{ count: bigint }[]>`
|
const crossPipelineTransitions = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||||
SELECT COUNT(*) as count FROM "StageTransition" st
|
SELECT COUNT(*) as count FROM "StageTransition" st
|
||||||
JOIN "Stage" sf ON sf.id = st."fromStageId"
|
JOIN "Stage" sf ON sf.id = st."fromStageId"
|
||||||
JOIN "Track" tf ON tf.id = sf."trackId"
|
JOIN "Track" tf ON tf.id = sf."trackId"
|
||||||
JOIN "Stage" sto ON sto.id = st."toStageId"
|
JOIN "Stage" sto ON sto.id = st."toStageId"
|
||||||
JOIN "Track" tt ON tt.id = sto."trackId"
|
JOIN "Track" tt ON tt.id = sto."trackId"
|
||||||
WHERE tf."pipelineId" != tt."pipelineId"
|
WHERE tf."pipelineId" != tt."pipelineId"
|
||||||
`
|
`
|
||||||
const crossCount = Number(crossPipelineTransitions[0]?.count ?? 0)
|
const crossCount = Number(crossPipelineTransitions[0]?.count ?? 0)
|
||||||
results.push({
|
results.push({
|
||||||
name: 'All transitions stay within same pipeline',
|
name: 'All transitions stay within same pipeline',
|
||||||
passed: crossCount === 0,
|
passed: crossCount === 0,
|
||||||
details: crossCount === 0 ? 'All transitions are within pipeline' : `Found ${crossCount} cross-pipeline transitions`,
|
details: crossCount === 0 ? 'All transitions are within pipeline' : `Found ${crossCount} cross-pipeline transitions`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 5. Stage sortOrder unique per track
|
// 5. Stage sortOrder unique per track
|
||||||
const duplicateSortOrders = await prisma.$queryRaw<{ count: bigint }[]>`
|
const duplicateSortOrders = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||||
SELECT COUNT(*) as count FROM (
|
SELECT COUNT(*) as count FROM (
|
||||||
SELECT "trackId", "sortOrder", COUNT(*) as cnt
|
SELECT "trackId", "sortOrder", COUNT(*) as cnt
|
||||||
FROM "Stage"
|
FROM "Stage"
|
||||||
GROUP BY "trackId", "sortOrder"
|
GROUP BY "trackId", "sortOrder"
|
||||||
HAVING COUNT(*) > 1
|
HAVING COUNT(*) > 1
|
||||||
) dupes
|
) dupes
|
||||||
`
|
`
|
||||||
const dupeSortCount = Number(duplicateSortOrders[0]?.count ?? 0)
|
const dupeSortCount = Number(duplicateSortOrders[0]?.count ?? 0)
|
||||||
results.push({
|
results.push({
|
||||||
name: 'Stage sortOrder unique per track',
|
name: 'Stage sortOrder unique per track',
|
||||||
passed: dupeSortCount === 0,
|
passed: dupeSortCount === 0,
|
||||||
details: dupeSortCount === 0 ? 'All sort orders unique' : `Found ${dupeSortCount} duplicate sort orders`,
|
details: dupeSortCount === 0 ? 'All sort orders unique' : `Found ${dupeSortCount} duplicate sort orders`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 6. Track sortOrder unique per pipeline
|
// 6. Track sortOrder unique per pipeline
|
||||||
const duplicateTrackOrders = await prisma.$queryRaw<{ count: bigint }[]>`
|
const duplicateTrackOrders = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||||
SELECT COUNT(*) as count FROM (
|
SELECT COUNT(*) as count FROM (
|
||||||
SELECT "pipelineId", "sortOrder", COUNT(*) as cnt
|
SELECT "pipelineId", "sortOrder", COUNT(*) as cnt
|
||||||
FROM "Track"
|
FROM "Track"
|
||||||
GROUP BY "pipelineId", "sortOrder"
|
GROUP BY "pipelineId", "sortOrder"
|
||||||
HAVING COUNT(*) > 1
|
HAVING COUNT(*) > 1
|
||||||
) dupes
|
) dupes
|
||||||
`
|
`
|
||||||
const dupeTrackCount = Number(duplicateTrackOrders[0]?.count ?? 0)
|
const dupeTrackCount = Number(duplicateTrackOrders[0]?.count ?? 0)
|
||||||
results.push({
|
results.push({
|
||||||
name: 'Track sortOrder unique per pipeline',
|
name: 'Track sortOrder unique per pipeline',
|
||||||
passed: dupeTrackCount === 0,
|
passed: dupeTrackCount === 0,
|
||||||
details: dupeTrackCount === 0 ? 'All track orders unique' : `Found ${dupeTrackCount} duplicate track orders`,
|
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
|
// 7. Every Pipeline has at least one Track; every Track has at least one Stage
|
||||||
const emptyPipelines = await prisma.$queryRaw<{ count: bigint }[]>`
|
const emptyPipelines = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||||
SELECT COUNT(*) as count FROM "Pipeline" p
|
SELECT COUNT(*) as count FROM "Pipeline" p
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM "Track" t WHERE t."pipelineId" = p.id)
|
WHERE NOT EXISTS (SELECT 1 FROM "Track" t WHERE t."pipelineId" = p.id)
|
||||||
`
|
`
|
||||||
const emptyPipelineCount = Number(emptyPipelines[0]?.count ?? 0)
|
const emptyPipelineCount = Number(emptyPipelines[0]?.count ?? 0)
|
||||||
const emptyTracks = await prisma.$queryRaw<{ count: bigint }[]>`
|
const emptyTracks = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||||
SELECT COUNT(*) as count FROM "Track" t
|
SELECT COUNT(*) as count FROM "Track" t
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s."trackId" = t.id)
|
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s."trackId" = t.id)
|
||||||
`
|
`
|
||||||
const emptyTrackCount = Number(emptyTracks[0]?.count ?? 0)
|
const emptyTrackCount = Number(emptyTracks[0]?.count ?? 0)
|
||||||
results.push({
|
results.push({
|
||||||
name: 'Every Pipeline has Tracks; every Track has Stages',
|
name: 'Every Pipeline has Tracks; every Track has Stages',
|
||||||
passed: emptyPipelineCount === 0 && emptyTrackCount === 0,
|
passed: emptyPipelineCount === 0 && emptyTrackCount === 0,
|
||||||
details: emptyPipelineCount === 0 && emptyTrackCount === 0
|
details: emptyPipelineCount === 0 && emptyTrackCount === 0
|
||||||
? 'All pipelines have tracks and all tracks have stages'
|
? 'All pipelines have tracks and all tracks have stages'
|
||||||
: `${emptyPipelineCount} empty pipelines, ${emptyTrackCount} empty tracks`,
|
: `${emptyPipelineCount} empty pipelines, ${emptyTrackCount} empty tracks`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 8. RoutingRule destinations reference valid tracks in same pipeline
|
// 8. RoutingRule destinations reference valid tracks in same pipeline
|
||||||
const badRoutingRules = await prisma.$queryRaw<{ count: bigint }[]>`
|
const badRoutingRules = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||||
SELECT COUNT(*) as count FROM "RoutingRule" rr
|
SELECT COUNT(*) as count FROM "RoutingRule" rr
|
||||||
WHERE rr."destinationTrackId" IS NOT NULL
|
WHERE rr."destinationTrackId" IS NOT NULL
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM "Track" t
|
SELECT 1 FROM "Track" t
|
||||||
WHERE t.id = rr."destinationTrackId"
|
WHERE t.id = rr."destinationTrackId"
|
||||||
AND t."pipelineId" = rr."pipelineId"
|
AND t."pipelineId" = rr."pipelineId"
|
||||||
)
|
)
|
||||||
`
|
`
|
||||||
const badRouteCount = Number(badRoutingRules[0]?.count ?? 0)
|
const badRouteCount = Number(badRoutingRules[0]?.count ?? 0)
|
||||||
results.push({
|
results.push({
|
||||||
name: 'RoutingRule destinations reference valid tracks in same pipeline',
|
name: 'RoutingRule destinations reference valid tracks in same pipeline',
|
||||||
passed: badRouteCount === 0,
|
passed: badRouteCount === 0,
|
||||||
details: badRouteCount === 0
|
details: badRouteCount === 0
|
||||||
? 'All routing rules reference valid destination tracks'
|
? 'All routing rules reference valid destination tracks'
|
||||||
: `Found ${badRouteCount} routing rules with invalid destinations`,
|
: `Found ${badRouteCount} routing rules with invalid destinations`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 9. LiveProgressCursor references valid stage
|
// 9. LiveProgressCursor references valid stage
|
||||||
const badCursors = await prisma.$queryRaw<{ count: bigint }[]>`
|
const badCursors = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||||
SELECT COUNT(*) as count FROM "LiveProgressCursor" lpc
|
SELECT COUNT(*) as count FROM "LiveProgressCursor" lpc
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = lpc."stageId")
|
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = lpc."stageId")
|
||||||
`
|
`
|
||||||
const badCursorCount = Number(badCursors[0]?.count ?? 0)
|
const badCursorCount = Number(badCursors[0]?.count ?? 0)
|
||||||
results.push({
|
results.push({
|
||||||
name: 'LiveProgressCursor references valid stage',
|
name: 'LiveProgressCursor references valid stage',
|
||||||
passed: badCursorCount === 0,
|
passed: badCursorCount === 0,
|
||||||
details: badCursorCount === 0
|
details: badCursorCount === 0
|
||||||
? 'All cursors reference valid stages'
|
? 'All cursors reference valid stages'
|
||||||
: `Found ${badCursorCount} cursors with invalid stage references`,
|
: `Found ${badCursorCount} cursors with invalid stage references`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 10. Cohort references valid stage
|
// 10. Cohort references valid stage
|
||||||
const badCohorts = await prisma.$queryRaw<{ count: bigint }[]>`
|
const badCohorts = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||||
SELECT COUNT(*) as count FROM "Cohort" c
|
SELECT COUNT(*) as count FROM "Cohort" c
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = c."stageId")
|
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = c."stageId")
|
||||||
`
|
`
|
||||||
const badCohortCount = Number(badCohorts[0]?.count ?? 0)
|
const badCohortCount = Number(badCohorts[0]?.count ?? 0)
|
||||||
results.push({
|
results.push({
|
||||||
name: 'Cohort references valid stage',
|
name: 'Cohort references valid stage',
|
||||||
passed: badCohortCount === 0,
|
passed: badCohortCount === 0,
|
||||||
details: badCohortCount === 0
|
details: badCohortCount === 0
|
||||||
? 'All cohorts reference valid stages'
|
? 'All cohorts reference valid stages'
|
||||||
: `Found ${badCohortCount} cohorts with invalid stage references`,
|
: `Found ${badCohortCount} cohorts with invalid stage references`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 11. Every EvaluationForm has a valid stageId
|
// 11. Every EvaluationForm has a valid stageId
|
||||||
const badEvalForms = await prisma.$queryRaw<{ count: bigint }[]>`
|
const badEvalForms = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||||
SELECT COUNT(*) as count FROM "EvaluationForm" ef
|
SELECT COUNT(*) as count FROM "EvaluationForm" ef
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = ef."stageId")
|
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = ef."stageId")
|
||||||
`
|
`
|
||||||
const badFormCount = Number(badEvalForms[0]?.count ?? 0)
|
const badFormCount = Number(badEvalForms[0]?.count ?? 0)
|
||||||
results.push({
|
results.push({
|
||||||
name: 'Every EvaluationForm references valid stage',
|
name: 'Every EvaluationForm references valid stage',
|
||||||
passed: badFormCount === 0,
|
passed: badFormCount === 0,
|
||||||
details: badFormCount === 0
|
details: badFormCount === 0
|
||||||
? 'All evaluation forms reference valid stages'
|
? 'All evaluation forms reference valid stages'
|
||||||
: `Found ${badFormCount} forms with invalid stage references`,
|
: `Found ${badFormCount} forms with invalid stage references`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 12. Every FileRequirement has a valid stageId
|
// 12. Every FileRequirement has a valid stageId
|
||||||
const badFileReqs = await prisma.$queryRaw<{ count: bigint }[]>`
|
const badFileReqs = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||||
SELECT COUNT(*) as count FROM "FileRequirement" fr
|
SELECT COUNT(*) as count FROM "FileRequirement" fr
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = fr."stageId")
|
WHERE NOT EXISTS (SELECT 1 FROM "Stage" s WHERE s.id = fr."stageId")
|
||||||
`
|
`
|
||||||
const badFileReqCount = Number(badFileReqs[0]?.count ?? 0)
|
const badFileReqCount = Number(badFileReqs[0]?.count ?? 0)
|
||||||
results.push({
|
results.push({
|
||||||
name: 'Every FileRequirement references valid stage',
|
name: 'Every FileRequirement references valid stage',
|
||||||
passed: badFileReqCount === 0,
|
passed: badFileReqCount === 0,
|
||||||
details: badFileReqCount === 0
|
details: badFileReqCount === 0
|
||||||
? 'All file requirements reference valid stages'
|
? 'All file requirements reference valid stages'
|
||||||
: `Found ${badFileReqCount} file requirements with invalid stage references`,
|
: `Found ${badFileReqCount} file requirements with invalid stage references`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 13. Count validation
|
// 13. Count validation
|
||||||
const projectCountResult = await prisma.project.count()
|
const projectCountResult = await prisma.project.count()
|
||||||
const stageCount = await prisma.stage.count()
|
const stageCount = await prisma.stage.count()
|
||||||
const trackCount = await prisma.track.count()
|
const trackCount = await prisma.track.count()
|
||||||
const pipelineCount = await prisma.pipeline.count()
|
const pipelineCount = await prisma.pipeline.count()
|
||||||
const pssCount = await prisma.projectStageState.count()
|
const pssCount = await prisma.projectStageState.count()
|
||||||
results.push({
|
results.push({
|
||||||
name: 'Count validation',
|
name: 'Count validation',
|
||||||
passed: projectCountResult > 0 && stageCount > 0 && trackCount > 0,
|
passed: projectCountResult > 0 && stageCount > 0 && trackCount > 0,
|
||||||
details: `Pipelines: ${pipelineCount}, Tracks: ${trackCount}, Stages: ${stageCount}, Projects: ${projectCountResult}, StageStates: ${pssCount}`,
|
details: `Pipelines: ${pipelineCount}, Tracks: ${trackCount}, Stages: ${stageCount}, Projects: ${projectCountResult}, StageStates: ${pssCount}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('🔍 Running integrity checks...\n')
|
console.log('🔍 Running integrity checks...\n')
|
||||||
|
|
||||||
const results = await runChecks()
|
const results = await runChecks()
|
||||||
|
|
||||||
let allPassed = true
|
let allPassed = true
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
const icon = result.passed ? '✅' : '❌'
|
const icon = result.passed ? '✅' : '❌'
|
||||||
console.log(`${icon} ${result.name}`)
|
console.log(`${icon} ${result.name}`)
|
||||||
console.log(` ${result.details}\n`)
|
console.log(` ${result.details}\n`)
|
||||||
if (!result.passed) allPassed = false
|
if (!result.passed) allPassed = false
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('='.repeat(50))
|
console.log('='.repeat(50))
|
||||||
if (allPassed) {
|
if (allPassed) {
|
||||||
console.log('✅ All integrity checks passed!')
|
console.log('✅ All integrity checks passed!')
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ Some integrity checks failed!')
|
console.log('❌ Some integrity checks failed!')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error('❌ Integrity check failed:', e)
|
console.error('❌ Integrity check failed:', e)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
.finally(async () => {
|
.finally(async () => {
|
||||||
await prisma.$disconnect()
|
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
|
-- 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
|
-- 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)
|
-- 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)
|
-- Step 1: Add Program.slug for edition-wide apply URLs (nullable for existing programs)
|
||||||
ALTER TABLE "Program" ADD COLUMN IF NOT EXISTS "slug" TEXT;
|
ALTER TABLE "Program" ADD COLUMN IF NOT EXISTS "slug" TEXT;
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS "Program_slug_key" ON "Program"("slug");
|
CREATE UNIQUE INDEX IF NOT EXISTS "Program_slug_key" ON "Program"("slug");
|
||||||
|
|
||||||
-- Step 2: Add programId column (nullable initially to handle existing data)
|
-- Step 2: Add programId column (nullable initially to handle existing data)
|
||||||
ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "programId" TEXT;
|
ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "programId" TEXT;
|
||||||
|
|
||||||
-- Step 3: Backfill programId from existing round relationships
|
-- Step 3: Backfill programId from existing round relationships
|
||||||
-- Only update rows where programId is still NULL (idempotent)
|
-- Only update rows where programId is still NULL (idempotent)
|
||||||
UPDATE "Project" p
|
UPDATE "Project" p
|
||||||
SET "programId" = r."programId"
|
SET "programId" = r."programId"
|
||||||
FROM "Round" r
|
FROM "Round" r
|
||||||
WHERE p."roundId" = r.id
|
WHERE p."roundId" = r.id
|
||||||
AND p."programId" IS NULL;
|
AND p."programId" IS NULL;
|
||||||
|
|
||||||
-- Step 4: Handle orphaned projects (no roundId = no way to derive programId)
|
-- 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
|
-- Assign them to the first available program, or delete them if no program exists
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
null_count INTEGER;
|
null_count INTEGER;
|
||||||
fallback_program_id TEXT;
|
fallback_program_id TEXT;
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT COUNT(*) INTO null_count FROM "Project" WHERE "programId" IS NULL;
|
SELECT COUNT(*) INTO null_count FROM "Project" WHERE "programId" IS NULL;
|
||||||
IF null_count > 0 THEN
|
IF null_count > 0 THEN
|
||||||
SELECT id INTO fallback_program_id FROM "Program" ORDER BY "createdAt" ASC LIMIT 1;
|
SELECT id INTO fallback_program_id FROM "Program" ORDER BY "createdAt" ASC LIMIT 1;
|
||||||
IF fallback_program_id IS NOT NULL THEN
|
IF fallback_program_id IS NOT NULL THEN
|
||||||
UPDATE "Project" SET "programId" = fallback_program_id WHERE "programId" IS NULL;
|
UPDATE "Project" SET "programId" = fallback_program_id WHERE "programId" IS NULL;
|
||||||
RAISE NOTICE 'Assigned % orphaned projects to fallback program %', null_count, fallback_program_id;
|
RAISE NOTICE 'Assigned % orphaned projects to fallback program %', null_count, fallback_program_id;
|
||||||
ELSE
|
ELSE
|
||||||
DELETE FROM "Project" WHERE "programId" IS NULL;
|
DELETE FROM "Project" WHERE "programId" IS NULL;
|
||||||
RAISE NOTICE 'Deleted % orphaned projects (no program exists to assign them to)', null_count;
|
RAISE NOTICE 'Deleted % orphaned projects (no program exists to assign them to)', null_count;
|
||||||
END IF;
|
END IF;
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- Step 5: Make programId required (NOT NULL constraint) - safe if already NOT NULL
|
-- Step 5: Make programId required (NOT NULL constraint) - safe if already NOT NULL
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_name = 'Project' AND column_name = 'programId' AND is_nullable = 'YES'
|
WHERE table_name = 'Project' AND column_name = 'programId' AND is_nullable = 'YES'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE "Project" ALTER COLUMN "programId" SET NOT NULL;
|
ALTER TABLE "Project" ALTER COLUMN "programId" SET NOT NULL;
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- Step 6: Add foreign key constraint for programId (skip if already exists)
|
-- Step 6: Add foreign key constraint for programId (skip if already exists)
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.table_constraints
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
WHERE constraint_name = 'Project_programId_fkey' AND table_name = 'Project'
|
WHERE constraint_name = 'Project_programId_fkey' AND table_name = 'Project'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_programId_fkey"
|
ALTER TABLE "Project" ADD CONSTRAINT "Project_programId_fkey"
|
||||||
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE;
|
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE;
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- Step 7: Make roundId nullable (allow projects without round assignment)
|
-- Step 7: Make roundId nullable (allow projects without round assignment)
|
||||||
-- Safe: DROP NOT NULL is idempotent if already nullable
|
-- Safe: DROP NOT NULL is idempotent if already nullable
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_name = 'Project' AND column_name = 'roundId' AND is_nullable = 'NO'
|
WHERE table_name = 'Project' AND column_name = 'roundId' AND is_nullable = 'NO'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE "Project" ALTER COLUMN "roundId" DROP NOT NULL;
|
ALTER TABLE "Project" ALTER COLUMN "roundId" DROP NOT NULL;
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- Step 8: Update round FK to SET NULL on delete (instead of CASCADE)
|
-- Step 8: Update round FK to SET NULL on delete (instead of CASCADE)
|
||||||
-- Projects should remain in the database if their round is deleted
|
-- Projects should remain in the database if their round is deleted
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
SELECT 1 FROM information_schema.table_constraints
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
WHERE constraint_name = 'Project_roundId_fkey' AND table_name = 'Project'
|
WHERE constraint_name = 'Project_roundId_fkey' AND table_name = 'Project'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE "Project" DROP CONSTRAINT "Project_roundId_fkey";
|
ALTER TABLE "Project" DROP CONSTRAINT "Project_roundId_fkey";
|
||||||
END IF;
|
END IF;
|
||||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_roundId_fkey"
|
ALTER TABLE "Project" ADD CONSTRAINT "Project_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- Step 9: Add performance indexes
|
-- Step 9: Add performance indexes
|
||||||
CREATE INDEX IF NOT EXISTS "Project_programId_idx" ON "Project"("programId");
|
CREATE INDEX IF NOT EXISTS "Project_programId_idx" ON "Project"("programId");
|
||||||
CREATE INDEX IF NOT EXISTS "Project_programId_roundId_idx" ON "Project"("programId", "roundId");
|
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
|
-- Reconciliation migration: Add missing foreign keys and indexes
|
||||||
-- The add_15_features migration omitted some FKs and indexes that the schema expects
|
-- 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
|
-- This migration brings the database in line with the Prisma schema
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- Missing Foreign Keys
|
-- Missing Foreign Keys
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|
||||||
-- RoundTemplate → Program
|
-- RoundTemplate → Program
|
||||||
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_programId_fkey"
|
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_programId_fkey"
|
||||||
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- RoundTemplate → User (creator)
|
-- RoundTemplate → User (creator)
|
||||||
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_createdBy_fkey"
|
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_createdBy_fkey"
|
||||||
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- Message → Round
|
-- Message → Round
|
||||||
ALTER TABLE "Message" ADD CONSTRAINT "Message_roundId_fkey"
|
ALTER TABLE "Message" ADD CONSTRAINT "Message_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- EvaluationDiscussion → Round
|
-- EvaluationDiscussion → Round
|
||||||
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_roundId_fkey"
|
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_roundId_fkey"
|
||||||
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- ProjectFile → ProjectFile (self-relation for file versioning)
|
-- ProjectFile → ProjectFile (self-relation for file versioning)
|
||||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_replacedById_fkey"
|
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_replacedById_fkey"
|
||||||
FOREIGN KEY ("replacedById") REFERENCES "ProjectFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
FOREIGN KEY ("replacedById") REFERENCES "ProjectFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- Missing Indexes
|
-- Missing Indexes
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|
||||||
CREATE INDEX "RoundTemplate_roundType_idx" ON "RoundTemplate"("roundType");
|
CREATE INDEX "RoundTemplate_roundType_idx" ON "RoundTemplate"("roundType");
|
||||||
CREATE INDEX "MentorNote_authorId_idx" ON "MentorNote"("authorId");
|
CREATE INDEX "MentorNote_authorId_idx" ON "MentorNote"("authorId");
|
||||||
CREATE INDEX "MentorMilestoneCompletion_completedById_idx" ON "MentorMilestoneCompletion"("completedById");
|
CREATE INDEX "MentorMilestoneCompletion_completedById_idx" ON "MentorMilestoneCompletion"("completedById");
|
||||||
CREATE INDEX "Webhook_createdById_idx" ON "Webhook"("createdById");
|
CREATE INDEX "Webhook_createdById_idx" ON "Webhook"("createdById");
|
||||||
CREATE INDEX "WebhookDelivery_event_idx" ON "WebhookDelivery"("event");
|
CREATE INDEX "WebhookDelivery_event_idx" ON "WebhookDelivery"("event");
|
||||||
CREATE INDEX "Message_roundId_idx" ON "Message"("roundId");
|
CREATE INDEX "Message_roundId_idx" ON "Message"("roundId");
|
||||||
CREATE INDEX "EvaluationDiscussion_closedById_idx" ON "EvaluationDiscussion"("closedById");
|
CREATE INDEX "EvaluationDiscussion_closedById_idx" ON "EvaluationDiscussion"("closedById");
|
||||||
CREATE INDEX "DiscussionComment_discussionId_idx" ON "DiscussionComment"("discussionId");
|
CREATE INDEX "DiscussionComment_discussionId_idx" ON "DiscussionComment"("discussionId");
|
||||||
CREATE INDEX "DiscussionComment_userId_idx" ON "DiscussionComment"("userId");
|
CREATE INDEX "DiscussionComment_userId_idx" ON "DiscussionComment"("userId");
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
-- Fix round deletion FK constraint errors
|
-- Fix round deletion FK constraint errors
|
||||||
-- Add CASCADE on Evaluation.formId so deleting EvaluationForm cascades to Evaluations
|
-- Add CASCADE on Evaluation.formId so deleting EvaluationForm cascades to Evaluations
|
||||||
-- Add SET NULL on ProjectFile.roundId so deleting Round nullifies the reference
|
-- Add SET NULL on ProjectFile.roundId so deleting Round nullifies the reference
|
||||||
|
|
||||||
-- AlterTable: Evaluation.formId -> onDelete CASCADE
|
-- AlterTable: Evaluation.formId -> onDelete CASCADE
|
||||||
ALTER TABLE "Evaluation" DROP CONSTRAINT "Evaluation_formId_fkey";
|
ALTER TABLE "Evaluation" DROP CONSTRAINT "Evaluation_formId_fkey";
|
||||||
ALTER TABLE "Evaluation" ADD CONSTRAINT "Evaluation_formId_fkey"
|
ALTER TABLE "Evaluation" ADD CONSTRAINT "Evaluation_formId_fkey"
|
||||||
FOREIGN KEY ("formId") REFERENCES "EvaluationForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("formId") REFERENCES "EvaluationForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- AlterTable: ProjectFile.roundId -> onDelete SET NULL
|
-- AlterTable: ProjectFile.roundId -> onDelete SET NULL
|
||||||
ALTER TABLE "ProjectFile" DROP CONSTRAINT "ProjectFile_roundId_fkey";
|
ALTER TABLE "ProjectFile" DROP CONSTRAINT "ProjectFile_roundId_fkey";
|
||||||
ALTER TABLE "ProjectFile" ADD 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;
|
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "FileRequirement" (
|
CREATE TABLE "FileRequirement" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"roundId" TEXT NOT NULL,
|
"roundId" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
"description" TEXT,
|
"description" TEXT,
|
||||||
"acceptedMimeTypes" TEXT[],
|
"acceptedMimeTypes" TEXT[],
|
||||||
"maxSizeMB" INTEGER,
|
"maxSizeMB" INTEGER,
|
||||||
"isRequired" BOOLEAN NOT NULL DEFAULT true,
|
"isRequired" BOOLEAN NOT NULL DEFAULT true,
|
||||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
CONSTRAINT "FileRequirement_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "FileRequirement_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "FileRequirement_roundId_idx" ON "FileRequirement"("roundId");
|
CREATE INDEX "FileRequirement_roundId_idx" ON "FileRequirement"("roundId");
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "FileRequirement" ADD CONSTRAINT "FileRequirement_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
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
|
-- AlterTable: add requirementId to ProjectFile
|
||||||
ALTER TABLE "ProjectFile" ADD COLUMN "requirementId" TEXT;
|
ALTER TABLE "ProjectFile" ADD COLUMN "requirementId" TEXT;
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "ProjectFile_requirementId_idx" ON "ProjectFile"("requirementId");
|
CREATE INDEX "ProjectFile_requirementId_idx" ON "ProjectFile"("requirementId");
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_requirementId_fkey" FOREIGN KEY ("requirementId") REFERENCES "FileRequirement"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
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
|
-- Migration: Add all missing schema elements not covered by previous migrations
|
||||||
-- This brings the database fully in line with prisma/schema.prisma
|
-- This brings the database fully in line with prisma/schema.prisma
|
||||||
-- Uses IF NOT EXISTS / DO $$ guards for idempotent execution
|
-- Uses IF NOT EXISTS / DO $$ guards for idempotent execution
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 1. MISSING TABLE: WizardTemplate
|
-- 1. MISSING TABLE: WizardTemplate
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "WizardTemplate" (
|
CREATE TABLE IF NOT EXISTS "WizardTemplate" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
"description" TEXT,
|
"description" TEXT,
|
||||||
"config" JSONB NOT NULL,
|
"config" JSONB NOT NULL,
|
||||||
"isGlobal" BOOLEAN NOT NULL DEFAULT false,
|
"isGlobal" BOOLEAN NOT NULL DEFAULT false,
|
||||||
"programId" TEXT,
|
"programId" TEXT,
|
||||||
"createdBy" TEXT NOT NULL,
|
"createdBy" TEXT NOT NULL,
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
CONSTRAINT "WizardTemplate_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "WizardTemplate_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS "WizardTemplate_programId_idx" ON "WizardTemplate"("programId");
|
CREATE INDEX IF NOT EXISTS "WizardTemplate_programId_idx" ON "WizardTemplate"("programId");
|
||||||
CREATE INDEX IF NOT EXISTS "WizardTemplate_isGlobal_idx" ON "WizardTemplate"("isGlobal");
|
CREATE INDEX IF NOT EXISTS "WizardTemplate_isGlobal_idx" ON "WizardTemplate"("isGlobal");
|
||||||
|
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
ALTER TABLE "WizardTemplate" ADD CONSTRAINT "WizardTemplate_programId_fkey"
|
ALTER TABLE "WizardTemplate" ADD CONSTRAINT "WizardTemplate_programId_fkey"
|
||||||
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
ALTER TABLE "WizardTemplate" ADD CONSTRAINT "WizardTemplate_createdBy_fkey"
|
ALTER TABLE "WizardTemplate" ADD CONSTRAINT "WizardTemplate_createdBy_fkey"
|
||||||
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 2. MISSING COLUMNS ON SpecialAward: eligibility job tracking fields
|
-- 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 "eligibilityJobStatus" TEXT;
|
||||||
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobTotal" INTEGER;
|
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 "eligibilityJobDone" INTEGER;
|
||||||
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobError" TEXT;
|
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobError" TEXT;
|
||||||
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobStarted" TIMESTAMP(3);
|
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "eligibilityJobStarted" TIMESTAMP(3);
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 3. Project.referralSource: Already in init migration. No action needed.
|
-- 3. Project.referralSource: Already in init migration. No action needed.
|
||||||
-- Round.slug: Already in init migration. No action needed.
|
-- Round.slug: Already in init migration. No action needed.
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 5. MISSING INDEXES
|
-- 5. MISSING INDEXES
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
-- 5a. Assignment: @@index([projectId, userId])
|
-- 5a. Assignment: @@index([projectId, userId])
|
||||||
CREATE INDEX IF NOT EXISTS "Assignment_projectId_userId_idx" ON "Assignment"("projectId", "userId");
|
CREATE INDEX IF NOT EXISTS "Assignment_projectId_userId_idx" ON "Assignment"("projectId", "userId");
|
||||||
|
|
||||||
-- 5b. AuditLog: @@index([sessionId])
|
-- 5b. AuditLog: @@index([sessionId])
|
||||||
CREATE INDEX IF NOT EXISTS "AuditLog_sessionId_idx" ON "AuditLog"("sessionId");
|
CREATE INDEX IF NOT EXISTS "AuditLog_sessionId_idx" ON "AuditLog"("sessionId");
|
||||||
|
|
||||||
-- 5c. ProjectFile: @@index([projectId, roundId])
|
-- 5c. ProjectFile: @@index([projectId, roundId])
|
||||||
CREATE INDEX IF NOT EXISTS "ProjectFile_projectId_roundId_idx" ON "ProjectFile"("projectId", "roundId");
|
CREATE INDEX IF NOT EXISTS "ProjectFile_projectId_roundId_idx" ON "ProjectFile"("projectId", "roundId");
|
||||||
|
|
||||||
-- 5d. MessageRecipient: @@index([userId])
|
-- 5d. MessageRecipient: @@index([userId])
|
||||||
CREATE INDEX IF NOT EXISTS "MessageRecipient_userId_idx" ON "MessageRecipient"("userId");
|
CREATE INDEX IF NOT EXISTS "MessageRecipient_userId_idx" ON "MessageRecipient"("userId");
|
||||||
|
|
||||||
-- 5e. MessageRecipient: @@unique([messageId, userId, channel])
|
-- 5e. MessageRecipient: @@unique([messageId, userId, channel])
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS "MessageRecipient_messageId_userId_channel_key" ON "MessageRecipient"("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
|
-- 5f. AwardEligibility: @@index([awardId, eligible]) - composite index
|
||||||
CREATE INDEX IF NOT EXISTS "AwardEligibility_awardId_eligible_idx" ON "AwardEligibility"("awardId", "eligible");
|
CREATE INDEX IF NOT EXISTS "AwardEligibility_awardId_eligible_idx" ON "AwardEligibility"("awardId", "eligible");
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 6. REMOVE STALE INDEX: Message_scheduledAt_idx
|
-- 6. REMOVE STALE INDEX: Message_scheduledAt_idx
|
||||||
-- The schema does NOT have @@index([scheduledAt]) on Message.
|
-- The schema does NOT have @@index([scheduledAt]) on Message.
|
||||||
-- The add_15_features migration created it, but the schema doesn't list it.
|
-- 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.
|
-- Leaving it as-is since it's harmless and could be useful.
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 7. VERIFY: All models from add_15_features are present
|
-- 7. VERIFY: All models from add_15_features are present
|
||||||
-- DigestLog, RoundTemplate, MentorNote, MentorMilestone,
|
-- DigestLog, RoundTemplate, MentorNote, MentorMilestone,
|
||||||
-- MentorMilestoneCompletion, Message, MessageTemplate, MessageRecipient,
|
-- MentorMilestoneCompletion, Message, MessageTemplate, MessageRecipient,
|
||||||
-- Webhook, WebhookDelivery, EvaluationDiscussion, DiscussionComment
|
-- Webhook, WebhookDelivery, EvaluationDiscussion, DiscussionComment
|
||||||
-- -> All confirmed created in 20260205223133_add_15_features migration.
|
-- -> All confirmed created in 20260205223133_add_15_features migration.
|
||||||
-- -> All FKs confirmed in add_15_features + 20260208000000_add_missing_fks_indexes.
|
-- -> All FKs confirmed in add_15_features + 20260208000000_add_missing_fks_indexes.
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 8. VERIFY: Existing tables from init and subsequent migrations
|
-- 8. VERIFY: Existing tables from init and subsequent migrations
|
||||||
-- All core tables (User, Account, Session, VerificationToken, Program, Round,
|
-- All core tables (User, Account, Session, VerificationToken, Program, Round,
|
||||||
-- EvaluationForm, Project, ProjectFile, Assignment, Evaluation, GracePeriod,
|
-- EvaluationForm, Project, ProjectFile, Assignment, Evaluation, GracePeriod,
|
||||||
-- SystemSettings, AuditLog, AIUsageLog, NotificationLog, InAppNotification,
|
-- SystemSettings, AuditLog, AIUsageLog, NotificationLog, InAppNotification,
|
||||||
-- NotificationEmailSetting, LearningResource, ResourceAccess, Partner,
|
-- NotificationEmailSetting, LearningResource, ResourceAccess, Partner,
|
||||||
-- ExpertiseTag, ProjectTag, LiveVotingSession, LiveVote, TeamMember,
|
-- ExpertiseTag, ProjectTag, LiveVotingSession, LiveVote, TeamMember,
|
||||||
-- MentorAssignment, FilteringRule, FilteringResult, FilteringJob,
|
-- MentorAssignment, FilteringRule, FilteringResult, FilteringJob,
|
||||||
-- AssignmentJob, TaggingJob, SpecialAward, AwardEligibility, AwardJuror,
|
-- AssignmentJob, TaggingJob, SpecialAward, AwardEligibility, AwardJuror,
|
||||||
-- AwardVote, ReminderLog, ConflictOfInterest, EvaluationSummary,
|
-- AwardVote, ReminderLog, ConflictOfInterest, EvaluationSummary,
|
||||||
-- ProjectStatusHistory, MentorMessage, FileRequirement)
|
-- ProjectStatusHistory, MentorMessage, FileRequirement)
|
||||||
-- -> All confirmed present in migrations.
|
-- -> All confirmed present in migrations.
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- SUMMARY OF CHANGES IN THIS MIGRATION:
|
-- SUMMARY OF CHANGES IN THIS MIGRATION:
|
||||||
--
|
--
|
||||||
-- NEW TABLE:
|
-- NEW TABLE:
|
||||||
-- - WizardTemplate (with programId FK, createdBy FK, indexes)
|
-- - WizardTemplate (with programId FK, createdBy FK, indexes)
|
||||||
--
|
--
|
||||||
-- NEW COLUMNS:
|
-- NEW COLUMNS:
|
||||||
-- - SpecialAward.eligibilityJobStatus (TEXT, nullable)
|
-- - SpecialAward.eligibilityJobStatus (TEXT, nullable)
|
||||||
-- - SpecialAward.eligibilityJobTotal (INTEGER, nullable)
|
-- - SpecialAward.eligibilityJobTotal (INTEGER, nullable)
|
||||||
-- - SpecialAward.eligibilityJobDone (INTEGER, nullable)
|
-- - SpecialAward.eligibilityJobDone (INTEGER, nullable)
|
||||||
-- - SpecialAward.eligibilityJobError (TEXT, nullable)
|
-- - SpecialAward.eligibilityJobError (TEXT, nullable)
|
||||||
-- - SpecialAward.eligibilityJobStarted (TIMESTAMP, nullable)
|
-- - SpecialAward.eligibilityJobStarted (TIMESTAMP, nullable)
|
||||||
--
|
--
|
||||||
-- NEW INDEXES:
|
-- NEW INDEXES:
|
||||||
-- - Assignment_projectId_userId_idx
|
-- - Assignment_projectId_userId_idx
|
||||||
-- - AuditLog_sessionId_idx
|
-- - AuditLog_sessionId_idx
|
||||||
-- - ProjectFile_projectId_roundId_idx
|
-- - ProjectFile_projectId_roundId_idx
|
||||||
-- - MessageRecipient_userId_idx
|
-- - MessageRecipient_userId_idx
|
||||||
-- - MessageRecipient_messageId_userId_channel_key (UNIQUE)
|
-- - MessageRecipient_messageId_userId_channel_key (UNIQUE)
|
||||||
-- - AwardEligibility_awardId_eligible_idx
|
-- - AwardEligibility_awardId_eligible_idx
|
||||||
-- - WizardTemplate_programId_idx
|
-- - WizardTemplate_programId_idx
|
||||||
-- - WizardTemplate_isGlobal_idx
|
-- - WizardTemplate_isGlobal_idx
|
||||||
--
|
--
|
||||||
-- NEW FOREIGN KEYS:
|
-- NEW FOREIGN KEYS:
|
||||||
-- - WizardTemplate_programId_fkey -> Program(id) ON DELETE CASCADE
|
-- - WizardTemplate_programId_fkey -> Program(id) ON DELETE CASCADE
|
||||||
-- - WizardTemplate_createdBy_fkey -> User(id) ON DELETE RESTRICT
|
-- - WizardTemplate_createdBy_fkey -> User(id) ON DELETE RESTRICT
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "AwardVote_awardId_userId_idx" ON "AwardVote"("awardId", "userId");
|
CREATE INDEX "AwardVote_awardId_userId_idx" ON "AwardVote"("awardId", "userId");
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1,99 @@
|
||||||
-- Migration: Add live voting enhancements (criteria voting, audience voting, AudienceVoter)
|
-- Migration: Add live voting enhancements (criteria voting, audience voting, AudienceVoter)
|
||||||
-- Brings LiveVotingSession, LiveVote, and new AudienceVoter model in sync with schema.prisma
|
-- Brings LiveVotingSession, LiveVote, and new AudienceVoter model in sync with schema.prisma
|
||||||
-- Uses IF NOT EXISTS / DO $$ guards for idempotent execution
|
-- Uses IF NOT EXISTS / DO $$ guards for idempotent execution
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 1. LiveVotingSession: Add criteria-based & audience voting columns
|
-- 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 "votingMode" TEXT NOT NULL DEFAULT 'simple';
|
||||||
ALTER TABLE "LiveVotingSession" ADD COLUMN IF NOT EXISTS "criteriaJson" JSONB;
|
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 "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 "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 "audienceRequireId" BOOLEAN NOT NULL DEFAULT false;
|
||||||
ALTER TABLE "LiveVotingSession" ADD COLUMN IF NOT EXISTS "audienceVotingDuration" INTEGER;
|
ALTER TABLE "LiveVotingSession" ADD COLUMN IF NOT EXISTS "audienceVotingDuration" INTEGER;
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 2. LiveVote: Add criteria scores, audience voter link, make userId nullable
|
-- 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 "criterionScoresJson" JSONB;
|
||||||
ALTER TABLE "LiveVote" ADD COLUMN IF NOT EXISTS "audienceVoterId" TEXT;
|
ALTER TABLE "LiveVote" ADD COLUMN IF NOT EXISTS "audienceVoterId" TEXT;
|
||||||
|
|
||||||
-- Make userId nullable (was NOT NULL in init migration)
|
-- Make userId nullable (was NOT NULL in init migration)
|
||||||
ALTER TABLE "LiveVote" ALTER COLUMN "userId" DROP NOT NULL;
|
ALTER TABLE "LiveVote" ALTER COLUMN "userId" DROP NOT NULL;
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 3. AudienceVoter: New table for audience participation
|
-- 3. AudienceVoter: New table for audience participation
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "AudienceVoter" (
|
CREATE TABLE IF NOT EXISTS "AudienceVoter" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"sessionId" TEXT NOT NULL,
|
"sessionId" TEXT NOT NULL,
|
||||||
"token" TEXT NOT NULL,
|
"token" TEXT NOT NULL,
|
||||||
"identifier" TEXT,
|
"identifier" TEXT,
|
||||||
"identifierType" TEXT,
|
"identifierType" TEXT,
|
||||||
"ipAddress" TEXT,
|
"ipAddress" TEXT,
|
||||||
"userAgent" TEXT,
|
"userAgent" TEXT,
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
CONSTRAINT "AudienceVoter_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "AudienceVoter_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Unique constraint on token
|
-- Unique constraint on token
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
ALTER TABLE "AudienceVoter" ADD CONSTRAINT "AudienceVoter_token_key" UNIQUE ("token");
|
ALTER TABLE "AudienceVoter" ADD CONSTRAINT "AudienceVoter_token_key" UNIQUE ("token");
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
CREATE INDEX IF NOT EXISTS "AudienceVoter_sessionId_idx" ON "AudienceVoter"("sessionId");
|
CREATE INDEX IF NOT EXISTS "AudienceVoter_sessionId_idx" ON "AudienceVoter"("sessionId");
|
||||||
CREATE INDEX IF NOT EXISTS "AudienceVoter_token_idx" ON "AudienceVoter"("token");
|
CREATE INDEX IF NOT EXISTS "AudienceVoter_token_idx" ON "AudienceVoter"("token");
|
||||||
|
|
||||||
-- Foreign key: AudienceVoter.sessionId -> LiveVotingSession.id
|
-- Foreign key: AudienceVoter.sessionId -> LiveVotingSession.id
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
ALTER TABLE "AudienceVoter" ADD CONSTRAINT "AudienceVoter_sessionId_fkey"
|
ALTER TABLE "AudienceVoter" ADD CONSTRAINT "AudienceVoter_sessionId_fkey"
|
||||||
FOREIGN KEY ("sessionId") REFERENCES "LiveVotingSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("sessionId") REFERENCES "LiveVotingSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 4. LiveVote: Foreign key and indexes for audienceVoterId
|
-- 4. LiveVote: Foreign key and indexes for audienceVoterId
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS "LiveVote_audienceVoterId_idx" ON "LiveVote"("audienceVoterId");
|
CREATE INDEX IF NOT EXISTS "LiveVote_audienceVoterId_idx" ON "LiveVote"("audienceVoterId");
|
||||||
|
|
||||||
-- Foreign key: LiveVote.audienceVoterId -> AudienceVoter.id
|
-- Foreign key: LiveVote.audienceVoterId -> AudienceVoter.id
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
ALTER TABLE "LiveVote" ADD CONSTRAINT "LiveVote_audienceVoterId_fkey"
|
ALTER TABLE "LiveVote" ADD CONSTRAINT "LiveVote_audienceVoterId_fkey"
|
||||||
FOREIGN KEY ("audienceVoterId") REFERENCES "AudienceVoter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
FOREIGN KEY ("audienceVoterId") REFERENCES "AudienceVoter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- Unique constraint: sessionId + projectId + audienceVoterId
|
-- Unique constraint: sessionId + projectId + audienceVoterId
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
ALTER TABLE "LiveVote" ADD CONSTRAINT "LiveVote_sessionId_projectId_audienceVoterId_key"
|
ALTER TABLE "LiveVote" ADD CONSTRAINT "LiveVote_sessionId_projectId_audienceVoterId_key"
|
||||||
UNIQUE ("sessionId", "projectId", "audienceVoterId");
|
UNIQUE ("sessionId", "projectId", "audienceVoterId");
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- SUMMARY:
|
-- SUMMARY:
|
||||||
--
|
--
|
||||||
-- LiveVotingSession new columns:
|
-- LiveVotingSession new columns:
|
||||||
-- - votingMode (TEXT, default 'simple')
|
-- - votingMode (TEXT, default 'simple')
|
||||||
-- - criteriaJson (JSONB, nullable)
|
-- - criteriaJson (JSONB, nullable)
|
||||||
-- - audienceVotingMode (TEXT, default 'disabled')
|
-- - audienceVotingMode (TEXT, default 'disabled')
|
||||||
-- - audienceMaxFavorites (INTEGER, default 3)
|
-- - audienceMaxFavorites (INTEGER, default 3)
|
||||||
-- - audienceRequireId (BOOLEAN, default false)
|
-- - audienceRequireId (BOOLEAN, default false)
|
||||||
-- - audienceVotingDuration (INTEGER, nullable)
|
-- - audienceVotingDuration (INTEGER, nullable)
|
||||||
--
|
--
|
||||||
-- LiveVote changes:
|
-- LiveVote changes:
|
||||||
-- - criterionScoresJson (JSONB, nullable) - new column
|
-- - criterionScoresJson (JSONB, nullable) - new column
|
||||||
-- - audienceVoterId (TEXT, nullable) - new column
|
-- - audienceVoterId (TEXT, nullable) - new column
|
||||||
-- - userId changed from NOT NULL to nullable
|
-- - userId changed from NOT NULL to nullable
|
||||||
-- - New unique: (sessionId, projectId, audienceVoterId)
|
-- - New unique: (sessionId, projectId, audienceVoterId)
|
||||||
-- - New index: audienceVoterId
|
-- - New index: audienceVoterId
|
||||||
-- - New FK: audienceVoterId -> AudienceVoter(id)
|
-- - New FK: audienceVoterId -> AudienceVoter(id)
|
||||||
--
|
--
|
||||||
-- New table: AudienceVoter
|
-- New table: AudienceVoter
|
||||||
-- - id, sessionId, token (unique), identifier, identifierType,
|
-- - id, sessionId, token (unique), identifier, identifierType,
|
||||||
-- ipAddress, userAgent, createdAt
|
-- ipAddress, userAgent, createdAt
|
||||||
-- - FK: sessionId -> LiveVotingSession(id) CASCADE
|
-- - FK: sessionId -> LiveVotingSession(id) CASCADE
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (e.g., Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
|
|
||||||
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'
|
'use client'
|
||||||
|
|
||||||
import { use, useState, useEffect } from 'react'
|
import { use, useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
export default function EditAwardPage({
|
export default function EditAwardPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}) {
|
}) {
|
||||||
const { id: awardId } = use(params)
|
const { id: awardId } = use(params)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId })
|
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId })
|
||||||
const updateAward = trpc.specialAward.update.useMutation({
|
const updateAward = trpc.specialAward.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.specialAward.get.invalidate({ id: awardId })
|
utils.specialAward.get.invalidate({ id: awardId })
|
||||||
utils.specialAward.list.invalidate()
|
utils.specialAward.list.invalidate()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [criteriaText, setCriteriaText] = useState('')
|
const [criteriaText, setCriteriaText] = useState('')
|
||||||
const [scoringMode, setScoringMode] = useState<'PICK_WINNER' | 'RANKED' | 'SCORED'>('PICK_WINNER')
|
const [scoringMode, setScoringMode] = useState<'PICK_WINNER' | 'RANKED' | 'SCORED'>('PICK_WINNER')
|
||||||
const [useAiEligibility, setUseAiEligibility] = useState(true)
|
const [useAiEligibility, setUseAiEligibility] = useState(true)
|
||||||
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
|
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
|
||||||
const [votingStartAt, setVotingStartAt] = useState('')
|
const [votingStartAt, setVotingStartAt] = useState('')
|
||||||
const [votingEndAt, setVotingEndAt] = useState('')
|
const [votingEndAt, setVotingEndAt] = useState('')
|
||||||
|
|
||||||
// Helper to format date for datetime-local input
|
// Helper to format date for datetime-local input
|
||||||
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||||
if (!date) return ''
|
if (!date) return ''
|
||||||
const d = new Date(date)
|
const d = new Date(date)
|
||||||
// Format: YYYY-MM-DDTHH:mm
|
// Format: YYYY-MM-DDTHH:mm
|
||||||
return d.toISOString().slice(0, 16)
|
return d.toISOString().slice(0, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load existing values when award data arrives
|
// Load existing values when award data arrives
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (award) {
|
if (award) {
|
||||||
setName(award.name)
|
setName(award.name)
|
||||||
setDescription(award.description || '')
|
setDescription(award.description || '')
|
||||||
setCriteriaText(award.criteriaText || '')
|
setCriteriaText(award.criteriaText || '')
|
||||||
setScoringMode(award.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED')
|
setScoringMode(award.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED')
|
||||||
setUseAiEligibility(award.useAiEligibility)
|
setUseAiEligibility(award.useAiEligibility)
|
||||||
setMaxRankedPicks(String(award.maxRankedPicks || 3))
|
setMaxRankedPicks(String(award.maxRankedPicks || 3))
|
||||||
setVotingStartAt(formatDateForInput(award.votingStartAt))
|
setVotingStartAt(formatDateForInput(award.votingStartAt))
|
||||||
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
||||||
}
|
}
|
||||||
}, [award])
|
}, [award])
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!name.trim()) return
|
if (!name.trim()) return
|
||||||
try {
|
try {
|
||||||
await updateAward.mutateAsync({
|
await updateAward.mutateAsync({
|
||||||
id: awardId,
|
id: awardId,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
criteriaText: criteriaText.trim() || undefined,
|
criteriaText: criteriaText.trim() || undefined,
|
||||||
useAiEligibility,
|
useAiEligibility,
|
||||||
scoringMode,
|
scoringMode,
|
||||||
maxRankedPicks: scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
|
maxRankedPicks: scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
|
||||||
votingStartAt: votingStartAt ? new Date(votingStartAt) : undefined,
|
votingStartAt: votingStartAt ? new Date(votingStartAt) : undefined,
|
||||||
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
||||||
})
|
})
|
||||||
toast.success('Award updated')
|
toast.success('Award updated')
|
||||||
router.push(`/admin/awards/${awardId}`)
|
router.push(`/admin/awards/${awardId}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
toast.error(
|
||||||
error instanceof Error ? error.message : 'Failed to update award'
|
error instanceof Error ? error.message : 'Failed to update award'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Skeleton className="h-9 w-48" />
|
<Skeleton className="h-9 w-48" />
|
||||||
<Skeleton className="h-[400px] w-full" />
|
<Skeleton className="h-[400px] w-full" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!award) return null
|
if (!award) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" asChild className="-ml-4">
|
||||||
<Link href={`/admin/awards/${awardId}`}>
|
<Link href={`/admin/awards/${awardId}`}>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Award
|
Back to Award
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
Edit Award
|
Edit Award
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Update award settings and eligibility criteria
|
Update award settings and eligibility criteria
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Award Details</CardTitle>
|
<CardTitle>Award Details</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configure the award name, criteria, and scoring mode
|
Configure the award name, criteria, and scoring mode
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Award Name</Label>
|
<Label htmlFor="name">Award Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g., Mediterranean Entrepreneurship Award"
|
placeholder="e.g., Mediterranean Entrepreneurship Award"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="description">Description</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Brief description of this award"
|
placeholder="Brief description of this award"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="criteria">Eligibility Criteria</Label>
|
<Label htmlFor="criteria">Eligibility Criteria</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="criteria"
|
id="criteria"
|
||||||
value={criteriaText}
|
value={criteriaText}
|
||||||
onChange={(e) => setCriteriaText(e.target.value)}
|
onChange={(e) => setCriteriaText(e.target.value)}
|
||||||
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
This text will be used by AI to determine which projects are
|
This text will be used by AI to determine which projects are
|
||||||
eligible for this award.
|
eligible for this award.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="ai-toggle">AI Eligibility</Label>
|
<Label htmlFor="ai-toggle">AI Eligibility</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Use AI to automatically evaluate project eligibility based on the criteria above.
|
Use AI to automatically evaluate project eligibility based on the criteria above.
|
||||||
Turn off for awards decided by feeling or manual selection.
|
Turn off for awards decided by feeling or manual selection.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="ai-toggle"
|
id="ai-toggle"
|
||||||
checked={useAiEligibility}
|
checked={useAiEligibility}
|
||||||
onCheckedChange={setUseAiEligibility}
|
onCheckedChange={setUseAiEligibility}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="scoring">Scoring Mode</Label>
|
<Label htmlFor="scoring">Scoring Mode</Label>
|
||||||
<Select
|
<Select
|
||||||
value={scoringMode}
|
value={scoringMode}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
setScoringMode(v as 'PICK_WINNER' | 'RANKED' | 'SCORED')
|
setScoringMode(v as 'PICK_WINNER' | 'RANKED' | 'SCORED')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="scoring">
|
<SelectTrigger id="scoring">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="PICK_WINNER">
|
<SelectItem value="PICK_WINNER">
|
||||||
Pick Winner — Each juror picks 1
|
Pick Winner — Each juror picks 1
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="RANKED">
|
<SelectItem value="RANKED">
|
||||||
Ranked — Each juror ranks top N
|
Ranked — Each juror ranks top N
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="SCORED">
|
<SelectItem value="SCORED">
|
||||||
Scored — Use evaluation form
|
Scored — Use evaluation form
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{scoringMode === 'RANKED' && (
|
{scoringMode === 'RANKED' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
||||||
<Input
|
<Input
|
||||||
id="maxPicks"
|
id="maxPicks"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="20"
|
max="20"
|
||||||
value={maxRankedPicks}
|
value={maxRankedPicks}
|
||||||
onChange={(e) => setMaxRankedPicks(e.target.value)}
|
onChange={(e) => setMaxRankedPicks(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Voting Window Card */}
|
{/* Voting Window Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Voting Window</CardTitle>
|
<CardTitle>Voting Window</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Set the time period during which jurors can submit their votes
|
Set the time period during which jurors can submit their votes
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="votingStart">Voting Opens</Label>
|
<Label htmlFor="votingStart">Voting Opens</Label>
|
||||||
<Input
|
<Input
|
||||||
id="votingStart"
|
id="votingStart"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={votingStartAt}
|
value={votingStartAt}
|
||||||
onChange={(e) => setVotingStartAt(e.target.value)}
|
onChange={(e) => setVotingStartAt(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
When jurors can start voting (leave empty to set when opening voting)
|
When jurors can start voting (leave empty to set when opening voting)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="votingEnd">Voting Closes</Label>
|
<Label htmlFor="votingEnd">Voting Closes</Label>
|
||||||
<Input
|
<Input
|
||||||
id="votingEnd"
|
id="votingEnd"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={votingEndAt}
|
value={votingEndAt}
|
||||||
onChange={(e) => setVotingEndAt(e.target.value)}
|
onChange={(e) => setVotingEndAt(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Deadline for juror votes
|
Deadline for juror votes
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-4">
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link href={`/admin/awards/${awardId}`}>Cancel</Link>
|
<Link href={`/admin/awards/${awardId}`}>Cancel</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={updateAward.isPending || !name.trim()}
|
disabled={updateAward.isPending || !name.trim()}
|
||||||
>
|
>
|
||||||
{updateAward.isPending ? (
|
{updateAward.isPending ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,227 +1,227 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
export default function CreateAwardPage() {
|
export default function CreateAwardPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [criteriaText, setCriteriaText] = useState('')
|
const [criteriaText, setCriteriaText] = useState('')
|
||||||
const [scoringMode, setScoringMode] = useState<
|
const [scoringMode, setScoringMode] = useState<
|
||||||
'PICK_WINNER' | 'RANKED' | 'SCORED'
|
'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||||
>('PICK_WINNER')
|
>('PICK_WINNER')
|
||||||
const [useAiEligibility, setUseAiEligibility] = useState(true)
|
const [useAiEligibility, setUseAiEligibility] = useState(true)
|
||||||
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
|
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
|
||||||
const [programId, setProgramId] = useState('')
|
const [programId, setProgramId] = useState('')
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const { data: programs } = trpc.program.list.useQuery()
|
const { data: programs } = trpc.program.list.useQuery()
|
||||||
const createAward = trpc.specialAward.create.useMutation({
|
const createAward = trpc.specialAward.create.useMutation({
|
||||||
onSuccess: () => utils.specialAward.list.invalidate(),
|
onSuccess: () => utils.specialAward.list.invalidate(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!name.trim() || !programId) return
|
if (!name.trim() || !programId) return
|
||||||
try {
|
try {
|
||||||
const award = await createAward.mutateAsync({
|
const award = await createAward.mutateAsync({
|
||||||
programId,
|
programId,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
criteriaText: criteriaText.trim() || undefined,
|
criteriaText: criteriaText.trim() || undefined,
|
||||||
useAiEligibility,
|
useAiEligibility,
|
||||||
scoringMode,
|
scoringMode,
|
||||||
maxRankedPicks:
|
maxRankedPicks:
|
||||||
scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
|
scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
|
||||||
})
|
})
|
||||||
toast.success('Award created')
|
toast.success('Award created')
|
||||||
router.push(`/admin/awards/${award.id}`)
|
router.push(`/admin/awards/${award.id}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
toast.error(
|
||||||
error instanceof Error ? error.message : 'Failed to create award'
|
error instanceof Error ? error.message : 'Failed to create award'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" asChild className="-ml-4">
|
||||||
<Link href="/admin/awards">
|
<Link href="/admin/awards">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Awards
|
Back to Awards
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
Create Special Award
|
Create Special Award
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Define a new award with eligibility criteria and voting rules
|
Define a new award with eligibility criteria and voting rules
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Award Details</CardTitle>
|
<CardTitle>Award Details</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configure the award name, criteria, and scoring mode
|
Configure the award name, criteria, and scoring mode
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="program">Edition</Label>
|
<Label htmlFor="program">Edition</Label>
|
||||||
<Select value={programId} onValueChange={setProgramId}>
|
<Select value={programId} onValueChange={setProgramId}>
|
||||||
<SelectTrigger id="program">
|
<SelectTrigger id="program">
|
||||||
<SelectValue placeholder="Select an edition" />
|
<SelectValue placeholder="Select an edition" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{programs?.map((p) => (
|
{programs?.map((p) => (
|
||||||
<SelectItem key={p.id} value={p.id}>
|
<SelectItem key={p.id} value={p.id}>
|
||||||
{p.year} Edition
|
{p.year} Edition
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Award Name</Label>
|
<Label htmlFor="name">Award Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g., Mediterranean Entrepreneurship Award"
|
placeholder="e.g., Mediterranean Entrepreneurship Award"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="description">Description</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Brief description of this award"
|
placeholder="Brief description of this award"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="criteria">Eligibility Criteria</Label>
|
<Label htmlFor="criteria">Eligibility Criteria</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="criteria"
|
id="criteria"
|
||||||
value={criteriaText}
|
value={criteriaText}
|
||||||
onChange={(e) => setCriteriaText(e.target.value)}
|
onChange={(e) => setCriteriaText(e.target.value)}
|
||||||
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
This text will be used by AI to determine which projects are
|
This text will be used by AI to determine which projects are
|
||||||
eligible for this award.
|
eligible for this award.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="ai-toggle">AI Eligibility</Label>
|
<Label htmlFor="ai-toggle">AI Eligibility</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Use AI to automatically evaluate project eligibility based on the criteria above.
|
Use AI to automatically evaluate project eligibility based on the criteria above.
|
||||||
Turn off for awards decided by feeling or manual selection.
|
Turn off for awards decided by feeling or manual selection.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="ai-toggle"
|
id="ai-toggle"
|
||||||
checked={useAiEligibility}
|
checked={useAiEligibility}
|
||||||
onCheckedChange={setUseAiEligibility}
|
onCheckedChange={setUseAiEligibility}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="scoring">Scoring Mode</Label>
|
<Label htmlFor="scoring">Scoring Mode</Label>
|
||||||
<Select
|
<Select
|
||||||
value={scoringMode}
|
value={scoringMode}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
setScoringMode(
|
setScoringMode(
|
||||||
v as 'PICK_WINNER' | 'RANKED' | 'SCORED'
|
v as 'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="scoring">
|
<SelectTrigger id="scoring">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="PICK_WINNER">
|
<SelectItem value="PICK_WINNER">
|
||||||
Pick Winner — Each juror picks 1
|
Pick Winner — Each juror picks 1
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="RANKED">
|
<SelectItem value="RANKED">
|
||||||
Ranked — Each juror ranks top N
|
Ranked — Each juror ranks top N
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="SCORED">
|
<SelectItem value="SCORED">
|
||||||
Scored — Use evaluation form
|
Scored — Use evaluation form
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{scoringMode === 'RANKED' && (
|
{scoringMode === 'RANKED' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
||||||
<Input
|
<Input
|
||||||
id="maxPicks"
|
id="maxPicks"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="20"
|
max="20"
|
||||||
value={maxRankedPicks}
|
value={maxRankedPicks}
|
||||||
onChange={(e) => setMaxRankedPicks(e.target.value)}
|
onChange={(e) => setMaxRankedPicks(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-4">
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<Link href="/admin/awards">Cancel</Link>
|
<Link href="/admin/awards">Cancel</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={createAward.isPending || !name.trim() || !programId}
|
disabled={createAward.isPending || !name.trim() || !programId}
|
||||||
>
|
>
|
||||||
{createAward.isPending ? (
|
{createAward.isPending ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Create Award
|
Create Award
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,238 +1,238 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useDebounce } from '@/hooks/use-debounce'
|
import { useDebounce } from '@/hooks/use-debounce'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Plus, Trophy, Users, CheckCircle2, Search } from 'lucide-react'
|
import { Plus, Trophy, Users, CheckCircle2, Search } from 'lucide-react'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
DRAFT: 'secondary',
|
DRAFT: 'secondary',
|
||||||
NOMINATIONS_OPEN: 'default',
|
NOMINATIONS_OPEN: 'default',
|
||||||
VOTING_OPEN: 'default',
|
VOTING_OPEN: 'default',
|
||||||
CLOSED: 'outline',
|
CLOSED: 'outline',
|
||||||
ARCHIVED: 'secondary',
|
ARCHIVED: 'secondary',
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCORING_LABELS: Record<string, string> = {
|
const SCORING_LABELS: Record<string, string> = {
|
||||||
PICK_WINNER: 'Pick Winner',
|
PICK_WINNER: 'Pick Winner',
|
||||||
RANKED: 'Ranked',
|
RANKED: 'Ranked',
|
||||||
SCORED: 'Scored',
|
SCORED: 'Scored',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AwardsListPage() {
|
export default function AwardsListPage() {
|
||||||
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({})
|
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({})
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const debouncedSearch = useDebounce(search, 300)
|
const debouncedSearch = useDebounce(search, 300)
|
||||||
const [statusFilter, setStatusFilter] = useState('all')
|
const [statusFilter, setStatusFilter] = useState('all')
|
||||||
const [scoringFilter, setScoringFilter] = useState('all')
|
const [scoringFilter, setScoringFilter] = useState('all')
|
||||||
|
|
||||||
const filteredAwards = useMemo(() => {
|
const filteredAwards = useMemo(() => {
|
||||||
if (!awards) return []
|
if (!awards) return []
|
||||||
return awards.filter((award) => {
|
return awards.filter((award) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
!debouncedSearch ||
|
!debouncedSearch ||
|
||||||
award.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
award.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||||
award.description?.toLowerCase().includes(debouncedSearch.toLowerCase())
|
award.description?.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||||
const matchesStatus = statusFilter === 'all' || award.status === statusFilter
|
const matchesStatus = statusFilter === 'all' || award.status === statusFilter
|
||||||
const matchesScoring = scoringFilter === 'all' || award.scoringMode === scoringFilter
|
const matchesScoring = scoringFilter === 'all' || award.scoringMode === scoringFilter
|
||||||
return matchesSearch && matchesStatus && matchesScoring
|
return matchesSearch && matchesStatus && matchesScoring
|
||||||
})
|
})
|
||||||
}, [awards, debouncedSearch, statusFilter, scoringFilter])
|
}, [awards, debouncedSearch, statusFilter, scoringFilter])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Skeleton className="h-8 w-48" />
|
<Skeleton className="h-8 w-48" />
|
||||||
<Skeleton className="mt-2 h-4 w-72" />
|
<Skeleton className="mt-2 h-4 w-72" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-9 w-32" />
|
<Skeleton className="h-9 w-32" />
|
||||||
</div>
|
</div>
|
||||||
{/* Toolbar skeleton */}
|
{/* Toolbar skeleton */}
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<Skeleton className="h-10 flex-1" />
|
<Skeleton className="h-10 flex-1" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Skeleton className="h-10 w-[180px]" />
|
<Skeleton className="h-10 w-[180px]" />
|
||||||
<Skeleton className="h-10 w-[160px]" />
|
<Skeleton className="h-10 w-[160px]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Cards skeleton */}
|
{/* Cards skeleton */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<Skeleton key={i} className="h-48 rounded-lg" />
|
<Skeleton key={i} className="h-48 rounded-lg" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
Special Awards
|
Special Awards
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Manage named awards with eligibility criteria and jury voting
|
Manage named awards with eligibility criteria and jury voting
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/admin/awards/new">
|
<Link href="/admin/awards/new">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create Award
|
Create Award
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search awards..."
|
placeholder="Search awards..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger className="w-[180px]">
|
<SelectTrigger className="w-[180px]">
|
||||||
<SelectValue placeholder="All statuses" />
|
<SelectValue placeholder="All statuses" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All statuses</SelectItem>
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
<SelectItem value="DRAFT">Draft</SelectItem>
|
<SelectItem value="DRAFT">Draft</SelectItem>
|
||||||
<SelectItem value="NOMINATIONS_OPEN">Nominations Open</SelectItem>
|
<SelectItem value="NOMINATIONS_OPEN">Nominations Open</SelectItem>
|
||||||
<SelectItem value="VOTING_OPEN">Voting Open</SelectItem>
|
<SelectItem value="VOTING_OPEN">Voting Open</SelectItem>
|
||||||
<SelectItem value="CLOSED">Closed</SelectItem>
|
<SelectItem value="CLOSED">Closed</SelectItem>
|
||||||
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={scoringFilter} onValueChange={setScoringFilter}>
|
<Select value={scoringFilter} onValueChange={setScoringFilter}>
|
||||||
<SelectTrigger className="w-[160px]">
|
<SelectTrigger className="w-[160px]">
|
||||||
<SelectValue placeholder="All scoring" />
|
<SelectValue placeholder="All scoring" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All scoring</SelectItem>
|
<SelectItem value="all">All scoring</SelectItem>
|
||||||
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
|
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
|
||||||
<SelectItem value="RANKED">Ranked</SelectItem>
|
<SelectItem value="RANKED">Ranked</SelectItem>
|
||||||
<SelectItem value="SCORED">Scored</SelectItem>
|
<SelectItem value="SCORED">Scored</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
{awards && (
|
{awards && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{filteredAwards.length} of {awards.length} awards
|
{filteredAwards.length} of {awards.length} awards
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Awards Grid */}
|
{/* Awards Grid */}
|
||||||
{filteredAwards.length > 0 ? (
|
{filteredAwards.length > 0 ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{filteredAwards.map((award, index) => (
|
{filteredAwards.map((award, index) => (
|
||||||
<AnimatedCard key={award.id} index={index}>
|
<AnimatedCard key={award.id} index={index}>
|
||||||
<Link href={`/admin/awards/${award.id}`}>
|
<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">
|
<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">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<Trophy className="h-5 w-5 text-amber-500" />
|
<Trophy className="h-5 w-5 text-amber-500" />
|
||||||
{award.name}
|
{award.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
||||||
{award.status.replace('_', ' ')}
|
{award.status.replace('_', ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{award.description && (
|
{award.description && (
|
||||||
<CardDescription className="line-clamp-2">
|
<CardDescription className="line-clamp-2">
|
||||||
{award.description}
|
{award.description}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
{award._count.eligibilities} eligible
|
{award._count.eligibilities} eligible
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" />
|
||||||
{award._count.jurors} jurors
|
{award._count.jurors} jurors
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{SCORING_LABELS[award.scoringMode] || award.scoringMode}
|
{SCORING_LABELS[award.scoringMode] || award.scoringMode}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{award.winnerProject && (
|
{award.winnerProject && (
|
||||||
<div className="mt-3 pt-3 border-t">
|
<div className="mt-3 pt-3 border-t">
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
<span className="text-muted-foreground">Winner:</span>{' '}
|
<span className="text-muted-foreground">Winner:</span>{' '}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{award.winnerProject.title}
|
{award.winnerProject.title}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : awards && awards.length > 0 ? (
|
) : awards && awards.length > 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
No awards match your filters
|
No awards match your filters
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<Trophy className="h-12 w-12 text-muted-foreground/40" />
|
<Trophy className="h-12 w-12 text-muted-foreground/40" />
|
||||||
<h3 className="mt-3 text-lg font-medium">No awards yet</h3>
|
<h3 className="mt-3 text-lg font-medium">No awards yet</h3>
|
||||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||||
Create special awards with eligibility criteria and jury voting for outstanding projects.
|
Create special awards with eligibility criteria and jury voting for outstanding projects.
|
||||||
</p>
|
</p>
|
||||||
<Button className="mt-4" asChild>
|
<Button className="mt-4" asChild>
|
||||||
<Link href="/admin/awards/new">
|
<Link href="/admin/awards/new">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create Award
|
Create Award
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,481 +1,481 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Save,
|
Save,
|
||||||
Loader2,
|
Loader2,
|
||||||
FileText,
|
FileText,
|
||||||
Video,
|
Video,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
File,
|
File,
|
||||||
Trash2,
|
Trash2,
|
||||||
Eye,
|
Eye,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Dynamically import BlockEditor to avoid SSR issues
|
// Dynamically import BlockEditor to avoid SSR issues
|
||||||
const BlockEditor = dynamic(
|
const BlockEditor = dynamic(
|
||||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const resourceTypeOptions = [
|
const resourceTypeOptions = [
|
||||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
||||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
{ value: 'PDF', label: 'PDF', icon: FileText },
|
||||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
{ value: 'VIDEO', label: 'Video', icon: Video },
|
||||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
||||||
{ value: 'OTHER', label: 'Other', icon: File },
|
{ value: 'OTHER', label: 'Other', icon: File },
|
||||||
]
|
]
|
||||||
|
|
||||||
const cohortOptions = [
|
const cohortOptions = [
|
||||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
||||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
||||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function EditLearningResourcePage() {
|
export default function EditLearningResourcePage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const resourceId = params.id as string
|
const resourceId = params.id as string
|
||||||
|
|
||||||
// Fetch resource
|
// Fetch resource
|
||||||
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||||
const { data: stats } = trpc.learningResource.getStats.useQuery({ id: resourceId })
|
const { data: stats } = trpc.learningResource.getStats.useQuery({ id: resourceId })
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [contentJson, setContentJson] = useState<string>('')
|
const [contentJson, setContentJson] = useState<string>('')
|
||||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
||||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
||||||
const [externalUrl, setExternalUrl] = useState('')
|
const [externalUrl, setExternalUrl] = useState('')
|
||||||
const [isPublished, setIsPublished] = useState(false)
|
const [isPublished, setIsPublished] = useState(false)
|
||||||
const [programId, setProgramId] = useState<string | null>(null)
|
const [programId, setProgramId] = useState<string | null>(null)
|
||||||
|
|
||||||
// API
|
// API
|
||||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const updateResource = trpc.learningResource.update.useMutation({
|
const updateResource = trpc.learningResource.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.learningResource.get.invalidate({ id: resourceId })
|
utils.learningResource.get.invalidate({ id: resourceId })
|
||||||
utils.learningResource.list.invalidate()
|
utils.learningResource.list.invalidate()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const deleteResource = trpc.learningResource.delete.useMutation({
|
const deleteResource = trpc.learningResource.delete.useMutation({
|
||||||
onSuccess: () => utils.learningResource.list.invalidate(),
|
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||||
})
|
})
|
||||||
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
|
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
|
||||||
|
|
||||||
// Populate form when resource loads
|
// Populate form when resource loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (resource) {
|
if (resource) {
|
||||||
setTitle(resource.title)
|
setTitle(resource.title)
|
||||||
setDescription(resource.description || '')
|
setDescription(resource.description || '')
|
||||||
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
|
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
|
||||||
setResourceType(resource.resourceType)
|
setResourceType(resource.resourceType)
|
||||||
setCohortLevel(resource.cohortLevel)
|
setCohortLevel(resource.cohortLevel)
|
||||||
setExternalUrl(resource.externalUrl || '')
|
setExternalUrl(resource.externalUrl || '')
|
||||||
setIsPublished(resource.isPublished)
|
setIsPublished(resource.isPublished)
|
||||||
setProgramId(resource.programId)
|
setProgramId(resource.programId)
|
||||||
}
|
}
|
||||||
}, [resource])
|
}, [resource])
|
||||||
|
|
||||||
// Handle file upload for BlockNote
|
// Handle file upload for BlockNote
|
||||||
const handleUploadFile = async (file: File): Promise<string> => {
|
const handleUploadFile = async (file: File): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const { url, bucket, objectKey } = await getUploadUrl.mutateAsync({
|
const { url, bucket, objectKey } = await getUploadUrl.mutateAsync({
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
})
|
})
|
||||||
|
|
||||||
await fetch(url, {
|
await fetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: file,
|
body: file,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': file.type,
|
'Content-Type': file.type,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to upload file')
|
toast.error('Failed to upload file')
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
toast.error('Please enter a title')
|
toast.error('Please enter a title')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceType === 'LINK' && !externalUrl) {
|
if (resourceType === 'LINK' && !externalUrl) {
|
||||||
toast.error('Please enter an external URL')
|
toast.error('Please enter an external URL')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateResource.mutateAsync({
|
await updateResource.mutateAsync({
|
||||||
id: resourceId,
|
id: resourceId,
|
||||||
title,
|
title,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
||||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
||||||
externalUrl: externalUrl || null,
|
externalUrl: externalUrl || null,
|
||||||
isPublished,
|
isPublished,
|
||||||
})
|
})
|
||||||
|
|
||||||
toast.success('Resource updated successfully')
|
toast.success('Resource updated successfully')
|
||||||
router.push('/admin/learning')
|
router.push('/admin/learning')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
|
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteResource.mutateAsync({ id: resourceId })
|
await deleteResource.mutateAsync({ id: resourceId })
|
||||||
toast.success('Resource deleted successfully')
|
toast.success('Resource deleted successfully')
|
||||||
router.push('/admin/learning')
|
router.push('/admin/learning')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
|
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Skeleton className="h-9 w-40" />
|
<Skeleton className="h-9 w-40" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-8 w-64" />
|
<Skeleton className="h-8 w-64" />
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<Skeleton className="h-64 w-full" />
|
<Skeleton className="h-64 w-full" />
|
||||||
<Skeleton className="h-96 w-full" />
|
<Skeleton className="h-96 w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Skeleton className="h-48 w-full" />
|
<Skeleton className="h-48 w-full" />
|
||||||
<Skeleton className="h-32 w-full" />
|
<Skeleton className="h-32 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !resource) {
|
if (error || !resource) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Resource not found</AlertTitle>
|
<AlertTitle>Resource not found</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
The resource you're looking for does not exist.
|
The resource you're looking for does not exist.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/admin/learning">
|
<Link href="/admin/learning">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Learning Hub
|
Back to Learning Hub
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" asChild className="-ml-4">
|
||||||
<Link href="/admin/learning">
|
<Link href="/admin/learning">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Learning Hub
|
Back to Learning Hub
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Update this learning resource
|
Update this learning resource
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="outline" className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
<Button variant="outline" className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
|
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete "{resource.title}"? This action
|
Are you sure you want to delete "{resource.title}"? This action
|
||||||
cannot be undone.
|
cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
>
|
>
|
||||||
{deleteResource.isPending ? (
|
{deleteResource.isPending ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : null}
|
) : null}
|
||||||
Delete
|
Delete
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
{/* Basic Info */}
|
{/* Basic Info */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Resource Details</CardTitle>
|
<CardTitle>Resource Details</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Basic information about this resource
|
Basic information about this resource
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="title">Title *</Label>
|
<Label htmlFor="title">Title *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="e.g., Ocean Conservation Best Practices"
|
placeholder="e.g., Ocean Conservation Best Practices"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">Short Description</Label>
|
<Label htmlFor="description">Short Description</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Brief description of this resource"
|
placeholder="Brief description of this resource"
|
||||||
rows={2}
|
rows={2}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type">Resource Type</Label>
|
<Label htmlFor="type">Resource Type</Label>
|
||||||
<Select value={resourceType} onValueChange={setResourceType}>
|
<Select value={resourceType} onValueChange={setResourceType}>
|
||||||
<SelectTrigger id="type">
|
<SelectTrigger id="type">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{resourceTypeOptions.map((option) => (
|
{resourceTypeOptions.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<option.icon className="h-4 w-4" />
|
<option.icon className="h-4 w-4" />
|
||||||
{option.label}
|
{option.label}
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="cohort">Access Level</Label>
|
<Label htmlFor="cohort">Access Level</Label>
|
||||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
||||||
<SelectTrigger id="cohort">
|
<SelectTrigger id="cohort">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{cohortOptions.map((option) => (
|
{cohortOptions.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{resourceType === 'LINK' && (
|
{resourceType === 'LINK' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="url">External URL *</Label>
|
<Label htmlFor="url">External URL *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="url"
|
id="url"
|
||||||
type="url"
|
type="url"
|
||||||
value={externalUrl}
|
value={externalUrl}
|
||||||
onChange={(e) => setExternalUrl(e.target.value)}
|
onChange={(e) => setExternalUrl(e.target.value)}
|
||||||
placeholder="https://example.com/resource"
|
placeholder="https://example.com/resource"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Content Editor */}
|
{/* Content Editor */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Content</CardTitle>
|
<CardTitle>Content</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Rich text content with images and videos. Type / for commands.
|
Rich text content with images and videos. Type / for commands.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<BlockEditor
|
<BlockEditor
|
||||||
key={resourceId}
|
key={resourceId}
|
||||||
initialContent={contentJson || undefined}
|
initialContent={contentJson || undefined}
|
||||||
onChange={setContentJson}
|
onChange={setContentJson}
|
||||||
onUploadFile={handleUploadFile}
|
onUploadFile={handleUploadFile}
|
||||||
className="min-h-[300px]"
|
className="min-h-[300px]"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Publish Settings */}
|
{/* Publish Settings */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Publish Settings</CardTitle>
|
<CardTitle>Publish Settings</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="published">Published</Label>
|
<Label htmlFor="published">Published</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Make this resource visible to jury members
|
Make this resource visible to jury members
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="published"
|
id="published"
|
||||||
checked={isPublished}
|
checked={isPublished}
|
||||||
onCheckedChange={setIsPublished}
|
onCheckedChange={setIsPublished}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="program">Program</Label>
|
<Label htmlFor="program">Program</Label>
|
||||||
<Select
|
<Select
|
||||||
value={programId || 'global'}
|
value={programId || 'global'}
|
||||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="program">
|
<SelectTrigger id="program">
|
||||||
<SelectValue placeholder="Select program" />
|
<SelectValue placeholder="Select program" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||||
{programs?.map((program) => (
|
{programs?.map((program) => (
|
||||||
<SelectItem key={program.id} value={program.id}>
|
<SelectItem key={program.id} value={program.id}>
|
||||||
{program.year} Edition
|
{program.year} Edition
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Statistics */}
|
{/* Statistics */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Eye className="h-5 w-5" />
|
<Eye className="h-5 w-5" />
|
||||||
Statistics
|
Statistics
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
||||||
<p className="text-sm text-muted-foreground">Total views</p>
|
<p className="text-sm text-muted-foreground">Total views</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
||||||
<p className="text-sm text-muted-foreground">Unique users</p>
|
<p className="text-sm text-muted-foreground">Unique users</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={updateResource.isPending || !title.trim()}
|
disabled={updateResource.isPending || !title.trim()}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{updateResource.isPending ? (
|
{updateResource.isPending ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" asChild className="w-full">
|
<Button variant="outline" asChild className="w-full">
|
||||||
<Link href="/admin/learning">Cancel</Link>
|
<Link href="/admin/learning">Cancel</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,327 +1,327 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ArrowLeft, Save, Loader2, FileText, Video, Link as LinkIcon, File } from 'lucide-react'
|
import { ArrowLeft, Save, Loader2, FileText, Video, Link as LinkIcon, File } from 'lucide-react'
|
||||||
|
|
||||||
// Dynamically import BlockEditor to avoid SSR issues
|
// Dynamically import BlockEditor to avoid SSR issues
|
||||||
const BlockEditor = dynamic(
|
const BlockEditor = dynamic(
|
||||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const resourceTypeOptions = [
|
const resourceTypeOptions = [
|
||||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
||||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
{ value: 'PDF', label: 'PDF', icon: FileText },
|
||||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
{ value: 'VIDEO', label: 'Video', icon: Video },
|
||||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
||||||
{ value: 'OTHER', label: 'Other', icon: File },
|
{ value: 'OTHER', label: 'Other', icon: File },
|
||||||
]
|
]
|
||||||
|
|
||||||
const cohortOptions = [
|
const cohortOptions = [
|
||||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
||||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
||||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function NewLearningResourcePage() {
|
export default function NewLearningResourcePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [contentJson, setContentJson] = useState<string>('')
|
const [contentJson, setContentJson] = useState<string>('')
|
||||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
||||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
||||||
const [externalUrl, setExternalUrl] = useState('')
|
const [externalUrl, setExternalUrl] = useState('')
|
||||||
const [isPublished, setIsPublished] = useState(false)
|
const [isPublished, setIsPublished] = useState(false)
|
||||||
|
|
||||||
// API
|
// API
|
||||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||||
const [programId, setProgramId] = useState<string | null>(null)
|
const [programId, setProgramId] = useState<string | null>(null)
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const createResource = trpc.learningResource.create.useMutation({
|
const createResource = trpc.learningResource.create.useMutation({
|
||||||
onSuccess: () => utils.learningResource.list.invalidate(),
|
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||||
})
|
})
|
||||||
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
|
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
|
||||||
|
|
||||||
// Handle file upload for BlockNote
|
// Handle file upload for BlockNote
|
||||||
const handleUploadFile = async (file: File): Promise<string> => {
|
const handleUploadFile = async (file: File): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const { url, bucket, objectKey } = await getUploadUrl.mutateAsync({
|
const { url, bucket, objectKey } = await getUploadUrl.mutateAsync({
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Upload to MinIO
|
// Upload to MinIO
|
||||||
await fetch(url, {
|
await fetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: file,
|
body: file,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': file.type,
|
'Content-Type': file.type,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Return the MinIO URL
|
// Return the MinIO URL
|
||||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to upload file')
|
toast.error('Failed to upload file')
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
toast.error('Please enter a title')
|
toast.error('Please enter a title')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceType === 'LINK' && !externalUrl) {
|
if (resourceType === 'LINK' && !externalUrl) {
|
||||||
toast.error('Please enter an external URL')
|
toast.error('Please enter an external URL')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createResource.mutateAsync({
|
await createResource.mutateAsync({
|
||||||
programId,
|
programId,
|
||||||
title,
|
title,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
||||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
||||||
externalUrl: externalUrl || undefined,
|
externalUrl: externalUrl || undefined,
|
||||||
isPublished,
|
isPublished,
|
||||||
})
|
})
|
||||||
|
|
||||||
toast.success('Resource created successfully')
|
toast.success('Resource created successfully')
|
||||||
router.push('/admin/learning')
|
router.push('/admin/learning')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
|
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" asChild className="-ml-4">
|
||||||
<Link href="/admin/learning">
|
<Link href="/admin/learning">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Learning Hub
|
Back to Learning Hub
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Create a new learning resource for jury members
|
Create a new learning resource for jury members
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
{/* Basic Info */}
|
{/* Basic Info */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Resource Details</CardTitle>
|
<CardTitle>Resource Details</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Basic information about this resource
|
Basic information about this resource
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="title">Title *</Label>
|
<Label htmlFor="title">Title *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="e.g., Ocean Conservation Best Practices"
|
placeholder="e.g., Ocean Conservation Best Practices"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">Short Description</Label>
|
<Label htmlFor="description">Short Description</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Brief description of this resource"
|
placeholder="Brief description of this resource"
|
||||||
rows={2}
|
rows={2}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type">Resource Type</Label>
|
<Label htmlFor="type">Resource Type</Label>
|
||||||
<Select value={resourceType} onValueChange={setResourceType}>
|
<Select value={resourceType} onValueChange={setResourceType}>
|
||||||
<SelectTrigger id="type">
|
<SelectTrigger id="type">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{resourceTypeOptions.map((option) => (
|
{resourceTypeOptions.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<option.icon className="h-4 w-4" />
|
<option.icon className="h-4 w-4" />
|
||||||
{option.label}
|
{option.label}
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="cohort">Access Level</Label>
|
<Label htmlFor="cohort">Access Level</Label>
|
||||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
||||||
<SelectTrigger id="cohort">
|
<SelectTrigger id="cohort">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{cohortOptions.map((option) => (
|
{cohortOptions.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{resourceType === 'LINK' && (
|
{resourceType === 'LINK' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="url">External URL *</Label>
|
<Label htmlFor="url">External URL *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="url"
|
id="url"
|
||||||
type="url"
|
type="url"
|
||||||
value={externalUrl}
|
value={externalUrl}
|
||||||
onChange={(e) => setExternalUrl(e.target.value)}
|
onChange={(e) => setExternalUrl(e.target.value)}
|
||||||
placeholder="https://example.com/resource"
|
placeholder="https://example.com/resource"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Content Editor */}
|
{/* Content Editor */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Content</CardTitle>
|
<CardTitle>Content</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Rich text content with images and videos. Type / for commands.
|
Rich text content with images and videos. Type / for commands.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<BlockEditor
|
<BlockEditor
|
||||||
initialContent={contentJson || undefined}
|
initialContent={contentJson || undefined}
|
||||||
onChange={setContentJson}
|
onChange={setContentJson}
|
||||||
onUploadFile={handleUploadFile}
|
onUploadFile={handleUploadFile}
|
||||||
className="min-h-[300px]"
|
className="min-h-[300px]"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Publish Settings */}
|
{/* Publish Settings */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Publish Settings</CardTitle>
|
<CardTitle>Publish Settings</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="published">Published</Label>
|
<Label htmlFor="published">Published</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Make this resource visible to jury members
|
Make this resource visible to jury members
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="published"
|
id="published"
|
||||||
checked={isPublished}
|
checked={isPublished}
|
||||||
onCheckedChange={setIsPublished}
|
onCheckedChange={setIsPublished}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="program">Program</Label>
|
<Label htmlFor="program">Program</Label>
|
||||||
<Select
|
<Select
|
||||||
value={programId || 'global'}
|
value={programId || 'global'}
|
||||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="program">
|
<SelectTrigger id="program">
|
||||||
<SelectValue placeholder="Select program" />
|
<SelectValue placeholder="Select program" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||||
{programs?.map((program) => (
|
{programs?.map((program) => (
|
||||||
<SelectItem key={program.id} value={program.id}>
|
<SelectItem key={program.id} value={program.id}>
|
||||||
{program.year} Edition
|
{program.year} Edition
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={createResource.isPending || !title.trim()}
|
disabled={createResource.isPending || !title.trim()}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{createResource.isPending ? (
|
{createResource.isPending ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Create Resource
|
Create Resource
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" asChild className="w-full">
|
<Button variant="outline" asChild className="w-full">
|
||||||
<Link href="/admin/learning">Cancel</Link>
|
<Link href="/admin/learning">Cancel</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,247 +1,247 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useDebounce } from '@/hooks/use-debounce'
|
import { useDebounce } from '@/hooks/use-debounce'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
FileText,
|
FileText,
|
||||||
Video,
|
Video,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
File,
|
File,
|
||||||
Pencil,
|
Pencil,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Search,
|
Search,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const resourceTypeIcons = {
|
const resourceTypeIcons = {
|
||||||
PDF: FileText,
|
PDF: FileText,
|
||||||
VIDEO: Video,
|
VIDEO: Video,
|
||||||
DOCUMENT: File,
|
DOCUMENT: File,
|
||||||
LINK: LinkIcon,
|
LINK: LinkIcon,
|
||||||
OTHER: File,
|
OTHER: File,
|
||||||
}
|
}
|
||||||
|
|
||||||
const cohortColors: Record<string, string> = {
|
const cohortColors: Record<string, string> = {
|
||||||
ALL: 'bg-gray-100 text-gray-800',
|
ALL: 'bg-gray-100 text-gray-800',
|
||||||
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
||||||
FINALIST: 'bg-purple-100 text-purple-800',
|
FINALIST: 'bg-purple-100 text-purple-800',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LearningHubPage() {
|
export default function LearningHubPage() {
|
||||||
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 50 })
|
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 50 })
|
||||||
const resources = data?.data
|
const resources = data?.data
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const debouncedSearch = useDebounce(search, 300)
|
const debouncedSearch = useDebounce(search, 300)
|
||||||
const [typeFilter, setTypeFilter] = useState('all')
|
const [typeFilter, setTypeFilter] = useState('all')
|
||||||
const [cohortFilter, setCohortFilter] = useState('all')
|
const [cohortFilter, setCohortFilter] = useState('all')
|
||||||
|
|
||||||
const filteredResources = useMemo(() => {
|
const filteredResources = useMemo(() => {
|
||||||
if (!resources) return []
|
if (!resources) return []
|
||||||
return resources.filter((resource) => {
|
return resources.filter((resource) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
!debouncedSearch ||
|
!debouncedSearch ||
|
||||||
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
|
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||||
const matchesType = typeFilter === 'all' || resource.resourceType === typeFilter
|
const matchesType = typeFilter === 'all' || resource.resourceType === typeFilter
|
||||||
const matchesCohort = cohortFilter === 'all' || resource.cohortLevel === cohortFilter
|
const matchesCohort = cohortFilter === 'all' || resource.cohortLevel === cohortFilter
|
||||||
return matchesSearch && matchesType && matchesCohort
|
return matchesSearch && matchesType && matchesCohort
|
||||||
})
|
})
|
||||||
}, [resources, debouncedSearch, typeFilter, cohortFilter])
|
}, [resources, debouncedSearch, typeFilter, cohortFilter])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Skeleton className="h-8 w-48" />
|
<Skeleton className="h-8 w-48" />
|
||||||
<Skeleton className="mt-2 h-4 w-72" />
|
<Skeleton className="mt-2 h-4 w-72" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-9 w-32" />
|
<Skeleton className="h-9 w-32" />
|
||||||
</div>
|
</div>
|
||||||
{/* Toolbar skeleton */}
|
{/* Toolbar skeleton */}
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<Skeleton className="h-10 flex-1" />
|
<Skeleton className="h-10 flex-1" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Skeleton className="h-10 w-[160px]" />
|
<Skeleton className="h-10 w-[160px]" />
|
||||||
<Skeleton className="h-10 w-[160px]" />
|
<Skeleton className="h-10 w-[160px]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Resource list skeleton */}
|
{/* Resource list skeleton */}
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<Card key={i}>
|
<Card key={i}>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Skeleton className="h-5 w-48" />
|
<Skeleton className="h-5 w-48" />
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="h-4 w-32" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-8 w-8 rounded" />
|
<Skeleton className="h-8 w-8 rounded" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Learning Hub</h1>
|
<h1 className="text-2xl font-bold">Learning Hub</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Manage educational resources for jury members
|
Manage educational resources for jury members
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/learning/new">
|
<Link href="/admin/learning/new">
|
||||||
<Button>
|
<Button>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Resource
|
Add Resource
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search resources..."
|
placeholder="Search resources..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
<SelectTrigger className="w-[160px]">
|
<SelectTrigger className="w-[160px]">
|
||||||
<SelectValue placeholder="All types" />
|
<SelectValue placeholder="All types" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All types</SelectItem>
|
<SelectItem value="all">All types</SelectItem>
|
||||||
<SelectItem value="PDF">PDF</SelectItem>
|
<SelectItem value="PDF">PDF</SelectItem>
|
||||||
<SelectItem value="VIDEO">Video</SelectItem>
|
<SelectItem value="VIDEO">Video</SelectItem>
|
||||||
<SelectItem value="DOCUMENT">Document</SelectItem>
|
<SelectItem value="DOCUMENT">Document</SelectItem>
|
||||||
<SelectItem value="LINK">Link</SelectItem>
|
<SelectItem value="LINK">Link</SelectItem>
|
||||||
<SelectItem value="OTHER">Other</SelectItem>
|
<SelectItem value="OTHER">Other</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={cohortFilter} onValueChange={setCohortFilter}>
|
<Select value={cohortFilter} onValueChange={setCohortFilter}>
|
||||||
<SelectTrigger className="w-[160px]">
|
<SelectTrigger className="w-[160px]">
|
||||||
<SelectValue placeholder="All cohorts" />
|
<SelectValue placeholder="All cohorts" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All cohorts</SelectItem>
|
<SelectItem value="all">All cohorts</SelectItem>
|
||||||
<SelectItem value="ALL">All (cohort)</SelectItem>
|
<SelectItem value="ALL">All (cohort)</SelectItem>
|
||||||
<SelectItem value="SEMIFINALIST">Semifinalist</SelectItem>
|
<SelectItem value="SEMIFINALIST">Semifinalist</SelectItem>
|
||||||
<SelectItem value="FINALIST">Finalist</SelectItem>
|
<SelectItem value="FINALIST">Finalist</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
{resources && (
|
{resources && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{filteredResources.length} of {resources.length} resources
|
{filteredResources.length} of {resources.length} resources
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resource List */}
|
{/* Resource List */}
|
||||||
{filteredResources.length > 0 ? (
|
{filteredResources.length > 0 ? (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{filteredResources.map((resource) => {
|
{filteredResources.map((resource) => {
|
||||||
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
|
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
|
||||||
return (
|
return (
|
||||||
<Card key={resource.id}>
|
<Card key={resource.id}>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-medium truncate">{resource.title}</h3>
|
<h3 className="font-medium truncate">{resource.title}</h3>
|
||||||
{!resource.isPublished && (
|
{!resource.isPublished && (
|
||||||
<Badge variant="secondary">Draft</Badge>
|
<Badge variant="secondary">Draft</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Badge className={cohortColors[resource.cohortLevel] || ''} variant="outline">
|
<Badge className={cohortColors[resource.cohortLevel] || ''} variant="outline">
|
||||||
{resource.cohortLevel}
|
{resource.cohortLevel}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span>{resource.resourceType}</span>
|
<span>{resource.resourceType}</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>{resource._count.accessLogs} views</span>
|
<span>{resource._count.accessLogs} views</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{resource.externalUrl && (
|
{resource.externalUrl && (
|
||||||
<a
|
<a
|
||||||
href={resource.externalUrl}
|
href={resource.externalUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<Link href={`/admin/learning/${resource.id}`}>
|
<Link href={`/admin/learning/${resource.id}`}>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : resources && resources.length > 0 ? (
|
) : resources && resources.length > 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
No resources match your filters
|
No resources match your filters
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<FileText className="h-12 w-12 text-muted-foreground/40" />
|
<FileText className="h-12 w-12 text-muted-foreground/40" />
|
||||||
<h3 className="mt-3 text-lg font-medium">No resources yet</h3>
|
<h3 className="mt-3 text-lg font-medium">No resources yet</h3>
|
||||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||||
Add learning materials like videos, documents, and links for program participants.
|
Add learning materials like videos, documents, and links for program participants.
|
||||||
</p>
|
</p>
|
||||||
<Button className="mt-4" asChild>
|
<Button className="mt-4" asChild>
|
||||||
<Link href="/admin/learning/new">
|
<Link href="/admin/learning/new">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Resource
|
Add Resource
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export default function MemberDetailPage() {
|
||||||
)
|
)
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
const [role, setRole] = useState<string>('JURY_MEMBER')
|
const [role, setRole] = useState<string>('JURY_MEMBER')
|
||||||
const [status, setStatus] = useState<string>('NONE')
|
const [status, setStatus] = useState<string>('NONE')
|
||||||
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
||||||
|
|
@ -83,6 +84,7 @@ export default function MemberDetailPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setName(user.name || '')
|
setName(user.name || '')
|
||||||
|
setEmail(user.email || '')
|
||||||
setRole(user.role)
|
setRole(user.role)
|
||||||
setStatus(user.status)
|
setStatus(user.status)
|
||||||
setExpertiseTags(user.expertiseTags || [])
|
setExpertiseTags(user.expertiseTags || [])
|
||||||
|
|
@ -94,6 +96,7 @@ export default function MemberDetailPage() {
|
||||||
try {
|
try {
|
||||||
await updateUser.mutateAsync({
|
await updateUser.mutateAsync({
|
||||||
id: userId,
|
id: userId,
|
||||||
|
email: email || undefined,
|
||||||
name: name || null,
|
name: name || null,
|
||||||
role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN',
|
role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN',
|
||||||
status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED',
|
status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED',
|
||||||
|
|
@ -212,7 +215,12 @@ export default function MemberDetailPage() {
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input id="email" value={user.email} disabled />
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Name</Label>
|
<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'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Plus,
|
Plus,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
Loader2,
|
Loader2,
|
||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
Eye,
|
Eye,
|
||||||
Variable,
|
Variable,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
const AVAILABLE_VARIABLES = [
|
const AVAILABLE_VARIABLES = [
|
||||||
{ name: '{{projectName}}', desc: 'Project title' },
|
{ name: '{{projectName}}', desc: 'Project title' },
|
||||||
{ name: '{{userName}}', desc: "Recipient's name" },
|
{ name: '{{userName}}', desc: "Recipient's name" },
|
||||||
{ name: '{{deadline}}', desc: 'Deadline date' },
|
{ name: '{{deadline}}', desc: 'Deadline date' },
|
||||||
{ name: '{{roundName}}', desc: 'Round name' },
|
{ name: '{{roundName}}', desc: 'Round name' },
|
||||||
{ name: '{{programName}}', desc: 'Program name' },
|
{ name: '{{programName}}', desc: 'Program name' },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface TemplateFormData {
|
interface TemplateFormData {
|
||||||
name: string
|
name: string
|
||||||
category: string
|
category: string
|
||||||
subject: string
|
subject: string
|
||||||
body: string
|
body: string
|
||||||
variables: string[]
|
variables: string[]
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultForm: TemplateFormData = {
|
const defaultForm: TemplateFormData = {
|
||||||
name: '',
|
name: '',
|
||||||
category: '',
|
category: '',
|
||||||
subject: '',
|
subject: '',
|
||||||
body: '',
|
body: '',
|
||||||
variables: [],
|
variables: [],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageTemplatesPage() {
|
export default function MessageTemplatesPage() {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
const [formData, setFormData] = useState<TemplateFormData>(defaultForm)
|
const [formData, setFormData] = useState<TemplateFormData>(defaultForm)
|
||||||
const [showPreview, setShowPreview] = useState(false)
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
const { data: templates, isLoading } = trpc.message.listTemplates.useQuery()
|
const { data: templates, isLoading } = trpc.message.listTemplates.useQuery()
|
||||||
|
|
||||||
const createMutation = trpc.message.createTemplate.useMutation({
|
const createMutation = trpc.message.createTemplate.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.message.listTemplates.invalidate()
|
utils.message.listTemplates.invalidate()
|
||||||
toast.success('Template created')
|
toast.success('Template created')
|
||||||
closeDialog()
|
closeDialog()
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateMutation = trpc.message.updateTemplate.useMutation({
|
const updateMutation = trpc.message.updateTemplate.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.message.listTemplates.invalidate()
|
utils.message.listTemplates.invalidate()
|
||||||
toast.success('Template updated')
|
toast.success('Template updated')
|
||||||
closeDialog()
|
closeDialog()
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteMutation = trpc.message.deleteTemplate.useMutation({
|
const deleteMutation = trpc.message.deleteTemplate.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.message.listTemplates.invalidate()
|
utils.message.listTemplates.invalidate()
|
||||||
toast.success('Template deleted')
|
toast.success('Template deleted')
|
||||||
setDeleteId(null)
|
setDeleteId(null)
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
setDialogOpen(false)
|
setDialogOpen(false)
|
||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
setFormData(defaultForm)
|
setFormData(defaultForm)
|
||||||
setShowPreview(false)
|
setShowPreview(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEdit = (template: Record<string, unknown>) => {
|
const openEdit = (template: Record<string, unknown>) => {
|
||||||
setEditingId(String(template.id))
|
setEditingId(String(template.id))
|
||||||
setFormData({
|
setFormData({
|
||||||
name: String(template.name || ''),
|
name: String(template.name || ''),
|
||||||
category: String(template.category || ''),
|
category: String(template.category || ''),
|
||||||
subject: String(template.subject || ''),
|
subject: String(template.subject || ''),
|
||||||
body: String(template.body || ''),
|
body: String(template.body || ''),
|
||||||
variables: Array.isArray(template.variables) ? template.variables.map(String) : [],
|
variables: Array.isArray(template.variables) ? template.variables.map(String) : [],
|
||||||
isActive: template.isActive !== false,
|
isActive: template.isActive !== false,
|
||||||
})
|
})
|
||||||
setDialogOpen(true)
|
setDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertVariable = (variable: string) => {
|
const insertVariable = (variable: string) => {
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
body: prev.body + variable,
|
body: prev.body + variable,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!formData.name.trim() || !formData.subject.trim()) {
|
if (!formData.name.trim() || !formData.subject.trim()) {
|
||||||
toast.error('Name and subject are required')
|
toast.error('Name and subject are required')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: formData.name.trim(),
|
name: formData.name.trim(),
|
||||||
category: formData.category.trim() || 'General',
|
category: formData.category.trim() || 'General',
|
||||||
subject: formData.subject.trim(),
|
subject: formData.subject.trim(),
|
||||||
body: formData.body.trim(),
|
body: formData.body.trim(),
|
||||||
variables: formData.variables.length > 0 ? formData.variables : undefined,
|
variables: formData.variables.length > 0 ? formData.variables : undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
updateMutation.mutate({ id: editingId, ...payload, isActive: formData.isActive })
|
updateMutation.mutate({ id: editingId, ...payload, isActive: formData.isActive })
|
||||||
} else {
|
} else {
|
||||||
createMutation.mutate(payload)
|
createMutation.mutate(payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPreviewText = (text: string): string => {
|
const getPreviewText = (text: string): string => {
|
||||||
return text
|
return text
|
||||||
.replace(/\{\{userName\}\}/g, 'John Doe')
|
.replace(/\{\{userName\}\}/g, 'John Doe')
|
||||||
.replace(/\{\{projectName\}\}/g, 'Ocean Cleanup Initiative')
|
.replace(/\{\{projectName\}\}/g, 'Ocean Cleanup Initiative')
|
||||||
.replace(/\{\{roundName\}\}/g, 'Round 1 - Semi-Finals')
|
.replace(/\{\{roundName\}\}/g, 'Round 1 - Semi-Finals')
|
||||||
.replace(/\{\{programName\}\}/g, 'MOPC 2026')
|
.replace(/\{\{programName\}\}/g, 'MOPC 2026')
|
||||||
.replace(/\{\{deadline\}\}/g, 'March 15, 2026')
|
.replace(/\{\{deadline\}\}/g, 'March 15, 2026')
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending
|
const isPending = createMutation.isPending || updateMutation.isPending
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4">
|
<Button variant="ghost" asChild className="-ml-4">
|
||||||
<Link href="/admin/messages">
|
<Link href="/admin/messages">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Messages
|
Back to Messages
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Message Templates</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Message Templates</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Create and manage reusable message templates
|
Create and manage reusable message templates
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
|
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button onClick={() => { setFormData(defaultForm); setEditingId(null); setDialogOpen(true) }}>
|
<Button onClick={() => { setFormData(defaultForm); setEditingId(null); setDialogOpen(true) }}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Create Template
|
Create Template
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingId ? 'Edit Template' : 'Create Template'}</DialogTitle>
|
<DialogTitle>{editingId ? 'Edit Template' : 'Create Template'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Define a reusable message template with variable placeholders.
|
Define a reusable message template with variable placeholders.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Template Name</Label>
|
<Label>Template Name</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g., Evaluation Reminder"
|
placeholder="e.g., Evaluation Reminder"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Category</Label>
|
<Label>Category</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g., Notification, Reminder"
|
placeholder="e.g., Notification, Reminder"
|
||||||
value={formData.category}
|
value={formData.category}
|
||||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Subject</Label>
|
<Label>Subject</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g., Reminder: {{roundName}} evaluation deadline"
|
placeholder="e.g., Reminder: {{roundName}} evaluation deadline"
|
||||||
value={formData.subject}
|
value={formData.subject}
|
||||||
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Message Body</Label>
|
<Label>Message Body</Label>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowPreview(!showPreview)}
|
onClick={() => setShowPreview(!showPreview)}
|
||||||
>
|
>
|
||||||
<Eye className="mr-1 h-3 w-3" />
|
<Eye className="mr-1 h-3 w-3" />
|
||||||
{showPreview ? 'Edit' : 'Preview'}
|
{showPreview ? 'Edit' : 'Preview'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPreview ? (
|
{showPreview ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-sm font-medium mb-2">
|
<p className="text-sm font-medium mb-2">
|
||||||
Subject: {getPreviewText(formData.subject)}
|
Subject: {getPreviewText(formData.subject)}
|
||||||
</p>
|
</p>
|
||||||
<div className="text-sm whitespace-pre-wrap border-t pt-2">
|
<div className="text-sm whitespace-pre-wrap border-t pt-2">
|
||||||
{getPreviewText(formData.body) || 'No content yet'}
|
{getPreviewText(formData.body) || 'No content yet'}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Write your template message..."
|
placeholder="Write your template message..."
|
||||||
value={formData.body}
|
value={formData.body}
|
||||||
onChange={(e) => setFormData({ ...formData, body: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, body: e.target.value })}
|
||||||
rows={8}
|
rows={8}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Variable buttons */}
|
{/* Variable buttons */}
|
||||||
{!showPreview && (
|
{!showPreview && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="flex items-center gap-1">
|
<Label className="flex items-center gap-1">
|
||||||
<Variable className="h-3 w-3" />
|
<Variable className="h-3 w-3" />
|
||||||
Insert Variable
|
Insert Variable
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{AVAILABLE_VARIABLES.map((v) => (
|
{AVAILABLE_VARIABLES.map((v) => (
|
||||||
<Button
|
<Button
|
||||||
key={v.name}
|
key={v.name}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
onClick={() => insertVariable(v.name)}
|
onClick={() => insertVariable(v.name)}
|
||||||
title={v.desc}
|
title={v.desc}
|
||||||
>
|
>
|
||||||
{v.name}
|
{v.name}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingId && (
|
{editingId && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="template-active"
|
id="template-active"
|
||||||
checked={formData.isActive}
|
checked={formData.isActive}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
setFormData({ ...formData, isActive: checked })
|
setFormData({ ...formData, isActive: checked })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="template-active" className="text-sm cursor-pointer">
|
<label htmlFor="template-active" className="text-sm cursor-pointer">
|
||||||
Active
|
Active
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={closeDialog}>
|
<Button variant="outline" onClick={closeDialog}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={isPending}>
|
<Button onClick={handleSubmit} disabled={isPending}>
|
||||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
{editingId ? 'Update' : 'Create'}
|
{editingId ? 'Update' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Variable reference panel */}
|
{/* Variable reference panel */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
<Variable className="h-4 w-4" />
|
<Variable className="h-4 w-4" />
|
||||||
Available Template Variables
|
Available Template Variables
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{AVAILABLE_VARIABLES.map((v) => (
|
{AVAILABLE_VARIABLES.map((v) => (
|
||||||
<div key={v.name} className="flex items-center gap-2">
|
<div key={v.name} className="flex items-center gap-2">
|
||||||
<code className="text-xs bg-muted rounded px-2 py-1 font-mono">
|
<code className="text-xs bg-muted rounded px-2 py-1 font-mono">
|
||||||
{v.name}
|
{v.name}
|
||||||
</code>
|
</code>
|
||||||
<span className="text-xs text-muted-foreground">{v.desc}</span>
|
<span className="text-xs text-muted-foreground">{v.desc}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Templates list */}
|
{/* Templates list */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<TemplatesSkeleton />
|
<TemplatesSkeleton />
|
||||||
) : templates && (templates as unknown[]).length > 0 ? (
|
) : templates && (templates as unknown[]).length > 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Category</TableHead>
|
<TableHead className="hidden md:table-cell">Category</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Subject</TableHead>
|
<TableHead className="hidden md:table-cell">Subject</TableHead>
|
||||||
<TableHead className="hidden lg:table-cell">Status</TableHead>
|
<TableHead className="hidden lg:table-cell">Status</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{(templates as Array<Record<string, unknown>>).map((template) => (
|
{(templates as Array<Record<string, unknown>>).map((template) => (
|
||||||
<TableRow key={String(template.id)}>
|
<TableRow key={String(template.id)}>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
{String(template.name)}
|
{String(template.name)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden md:table-cell">
|
<TableCell className="hidden md:table-cell">
|
||||||
{template.category ? (
|
{template.category ? (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{String(template.category)}
|
{String(template.category)}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">--</span>
|
<span className="text-xs text-muted-foreground">--</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden md:table-cell text-sm text-muted-foreground truncate max-w-[200px]">
|
<TableCell className="hidden md:table-cell text-sm text-muted-foreground truncate max-w-[200px]">
|
||||||
{String(template.subject || '')}
|
{String(template.subject || '')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden lg:table-cell">
|
<TableCell className="hidden lg:table-cell">
|
||||||
{template.isActive !== false ? (
|
{template.isActive !== false ? (
|
||||||
<Badge variant="default" className="text-xs">Active</Badge>
|
<Badge variant="default" className="text-xs">Active</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary" className="text-xs">Inactive</Badge>
|
<Badge variant="secondary" className="text-xs">Inactive</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => openEdit(template)}
|
onClick={() => openEdit(template)}
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setDeleteId(String(template.id))}
|
onClick={() => setDeleteId(String(template.id))}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<LayoutTemplate className="h-12 w-12 text-muted-foreground/50" />
|
<LayoutTemplate className="h-12 w-12 text-muted-foreground/50" />
|
||||||
<p className="mt-2 font-medium">No templates yet</p>
|
<p className="mt-2 font-medium">No templates yet</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Create a template to speed up message composition.
|
Create a template to speed up message composition.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
{/* Delete confirmation */}
|
||||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete Template</AlertDialogTitle>
|
<AlertDialogTitle>Delete Template</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete this template? This action cannot be undone.
|
Are you sure you want to delete this template? This action cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => deleteId && deleteMutation.mutate({ id: deleteId })}
|
onClick={() => deleteId && deleteMutation.mutate({ id: deleteId })}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
>
|
>
|
||||||
{deleteMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{deleteMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Delete
|
Delete
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplatesSkeleton() {
|
function TemplatesSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<div key={i} className="flex items-center gap-4">
|
<div key={i} className="flex items-center gap-4">
|
||||||
<Skeleton className="h-4 w-40" />
|
<Skeleton className="h-4 w-40" />
|
||||||
<Skeleton className="h-6 w-24" />
|
<Skeleton className="h-6 w-24" />
|
||||||
<Skeleton className="h-4 w-48" />
|
<Skeleton className="h-4 w-48" />
|
||||||
<Skeleton className="h-8 w-16 ml-auto" />
|
<Skeleton className="h-8 w-16 ml-auto" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,72 @@
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { CircleDot } from 'lucide-react'
|
import { CircleDot } from 'lucide-react'
|
||||||
import { DashboardContent } from './dashboard-content'
|
import { DashboardContent } from './dashboard-content'
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Admin Dashboard' }
|
export const metadata: Metadata = { title: 'Admin Dashboard' }
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
searchParams: Promise<{ edition?: string }>
|
searchParams: Promise<{ edition?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
||||||
let editionId: string | null = null
|
let editionId: string | null = null
|
||||||
let sessionName = 'Admin'
|
let sessionName = 'Admin'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [session, params] = await Promise.all([
|
const [session, params] = await Promise.all([
|
||||||
auth(),
|
auth(),
|
||||||
searchParams,
|
searchParams,
|
||||||
])
|
])
|
||||||
|
|
||||||
editionId = params.edition || null
|
editionId = params.edition || null
|
||||||
sessionName = session?.user?.name || 'Admin'
|
sessionName = session?.user?.name || 'Admin'
|
||||||
|
|
||||||
if (!editionId) {
|
if (!editionId) {
|
||||||
const defaultEdition = await prisma.program.findFirst({
|
const defaultEdition = await prisma.program.findFirst({
|
||||||
where: { status: 'ACTIVE' },
|
where: { status: 'ACTIVE' },
|
||||||
orderBy: { year: 'desc' },
|
orderBy: { year: 'desc' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
editionId = defaultEdition?.id || null
|
editionId = defaultEdition?.id || null
|
||||||
|
|
||||||
if (!editionId) {
|
if (!editionId) {
|
||||||
const anyEdition = await prisma.program.findFirst({
|
const anyEdition = await prisma.program.findFirst({
|
||||||
orderBy: { year: 'desc' },
|
orderBy: { year: 'desc' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
editionId = anyEdition?.id || null
|
editionId = anyEdition?.id || null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[AdminDashboard] Page init failed:', err)
|
console.error('[AdminDashboard] Page init failed:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!editionId) {
|
if (!editionId) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
|
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
|
||||||
<p className="mt-2 font-medium">No edition selected</p>
|
<p className="mt-2 font-medium">No edition selected</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Select an edition from the sidebar to view dashboard
|
Select an edition from the sidebar to view dashboard
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<DashboardContent editionId={editionId} sessionName={sessionName} />
|
<DashboardContent editionId={editionId} sessionName={sessionName} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,282 +1,282 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useRouter, useParams } from 'next/navigation'
|
import { useRouter, useParams } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { ArrowLeft, Loader2, Trash2 } from 'lucide-react'
|
import { ArrowLeft, Loader2, Trash2 } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export default function EditPartnerPage() {
|
export default function EditPartnerPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const id = params.id as string
|
const id = params.id as string
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
website: '',
|
website: '',
|
||||||
partnerType: 'PARTNER',
|
partnerType: 'PARTNER',
|
||||||
visibility: 'ADMIN_ONLY',
|
visibility: 'ADMIN_ONLY',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: partner, isLoading } = trpc.partner.get.useQuery({ id })
|
const { data: partner, isLoading } = trpc.partner.get.useQuery({ id })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (partner) {
|
if (partner) {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: partner.name,
|
name: partner.name,
|
||||||
description: partner.description || '',
|
description: partner.description || '',
|
||||||
website: partner.website || '',
|
website: partner.website || '',
|
||||||
partnerType: partner.partnerType,
|
partnerType: partner.partnerType,
|
||||||
visibility: partner.visibility,
|
visibility: partner.visibility,
|
||||||
isActive: partner.isActive,
|
isActive: partner.isActive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [partner])
|
}, [partner])
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const updatePartner = trpc.partner.update.useMutation({
|
const updatePartner = trpc.partner.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.partner.list.invalidate()
|
utils.partner.list.invalidate()
|
||||||
utils.partner.get.invalidate()
|
utils.partner.get.invalidate()
|
||||||
toast.success('Partner updated successfully')
|
toast.success('Partner updated successfully')
|
||||||
router.push('/admin/partners')
|
router.push('/admin/partners')
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || 'Failed to update partner')
|
toast.error(error.message || 'Failed to update partner')
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const deletePartner = trpc.partner.delete.useMutation({
|
const deletePartner = trpc.partner.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.partner.list.invalidate()
|
utils.partner.list.invalidate()
|
||||||
toast.success('Partner deleted successfully')
|
toast.success('Partner deleted successfully')
|
||||||
router.push('/admin/partners')
|
router.push('/admin/partners')
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || 'Failed to delete partner')
|
toast.error(error.message || 'Failed to delete partner')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
updatePartner.mutate({
|
updatePartner.mutate({
|
||||||
id,
|
id,
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
description: formData.description || null,
|
description: formData.description || null,
|
||||||
website: formData.website || null,
|
website: formData.website || null,
|
||||||
partnerType: formData.partnerType as 'SPONSOR' | 'PARTNER' | 'SUPPORTER' | 'MEDIA' | 'OTHER',
|
partnerType: formData.partnerType as 'SPONSOR' | 'PARTNER' | 'SUPPORTER' | 'MEDIA' | 'OTHER',
|
||||||
visibility: formData.visibility as 'ADMIN_ONLY' | 'JURY_VISIBLE' | 'PUBLIC',
|
visibility: formData.visibility as 'ADMIN_ONLY' | 'JURY_VISIBLE' | 'PUBLIC',
|
||||||
isActive: formData.isActive,
|
isActive: formData.isActive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete handled via AlertDialog in JSX
|
// Delete handled via AlertDialog in JSX
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Skeleton className="h-10 w-10" />
|
<Skeleton className="h-10 w-10" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-8 w-48" />
|
<Skeleton className="h-8 w-48" />
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="h-4 w-32" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="space-y-4 pt-6">
|
<CardContent className="space-y-4 pt-6">
|
||||||
<Skeleton className="h-10 w-full" />
|
<Skeleton className="h-10 w-full" />
|
||||||
<Skeleton className="h-10 w-full" />
|
<Skeleton className="h-10 w-full" />
|
||||||
<Skeleton className="h-24 w-full" />
|
<Skeleton className="h-24 w-full" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href="/admin/partners">
|
<Link href="/admin/partners">
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Edit Partner</h1>
|
<h1 className="text-2xl font-bold">Edit Partner</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Update partner information
|
Update partner information
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive">
|
<Button variant="destructive">
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete Partner</AlertDialogTitle>
|
<AlertDialogTitle>Delete Partner</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will permanently delete this partner. This action cannot be undone.
|
This will permanently delete this partner. This action cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={() => deletePartner.mutate({ id })}>
|
<AlertDialogAction onClick={() => deletePartner.mutate({ id })}>
|
||||||
Delete
|
Delete
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Partner Details</CardTitle>
|
<CardTitle>Partner Details</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Basic information about the partner organization
|
Basic information about the partner organization
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Organization Name *</Label>
|
<Label htmlFor="name">Organization Name *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="partnerType">Partner Type</Label>
|
<Label htmlFor="partnerType">Partner Type</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.partnerType}
|
value={formData.partnerType}
|
||||||
onValueChange={(value) => setFormData({ ...formData, partnerType: value })}
|
onValueChange={(value) => setFormData({ ...formData, partnerType: value })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
||||||
<SelectItem value="PARTNER">Partner</SelectItem>
|
<SelectItem value="PARTNER">Partner</SelectItem>
|
||||||
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
||||||
<SelectItem value="MEDIA">Media</SelectItem>
|
<SelectItem value="MEDIA">Media</SelectItem>
|
||||||
<SelectItem value="OTHER">Other</SelectItem>
|
<SelectItem value="OTHER">Other</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="description">Description</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="website">Website</Label>
|
<Label htmlFor="website">Website</Label>
|
||||||
<Input
|
<Input
|
||||||
id="website"
|
id="website"
|
||||||
type="url"
|
type="url"
|
||||||
value={formData.website}
|
value={formData.website}
|
||||||
onChange={(e) => setFormData({ ...formData, website: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, website: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="visibility">Visibility</Label>
|
<Label htmlFor="visibility">Visibility</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.visibility}
|
value={formData.visibility}
|
||||||
onValueChange={(value) => setFormData({ ...formData, visibility: value })}
|
onValueChange={(value) => setFormData({ ...formData, visibility: value })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="ADMIN_ONLY">Admin Only</SelectItem>
|
<SelectItem value="ADMIN_ONLY">Admin Only</SelectItem>
|
||||||
<SelectItem value="JURY_VISIBLE">Visible to Jury</SelectItem>
|
<SelectItem value="JURY_VISIBLE">Visible to Jury</SelectItem>
|
||||||
<SelectItem value="PUBLIC">Public</SelectItem>
|
<SelectItem value="PUBLIC">Public</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pt-8">
|
<div className="flex items-center gap-2 pt-8">
|
||||||
<Switch
|
<Switch
|
||||||
id="isActive"
|
id="isActive"
|
||||||
checked={formData.isActive}
|
checked={formData.isActive}
|
||||||
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
|
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="isActive">Active</Label>
|
<Label htmlFor="isActive">Active</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
<Link href="/admin/partners">
|
<Link href="/admin/partners">
|
||||||
<Button type="button" variant="outline">
|
<Button type="button" variant="outline">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,170 +1,170 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export default function NewPartnerPage() {
|
export default function NewPartnerPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [partnerType, setPartnerType] = useState('PARTNER')
|
const [partnerType, setPartnerType] = useState('PARTNER')
|
||||||
const [visibility, setVisibility] = useState('ADMIN_ONLY')
|
const [visibility, setVisibility] = useState('ADMIN_ONLY')
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const createPartner = trpc.partner.create.useMutation({
|
const createPartner = trpc.partner.create.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.partner.list.invalidate()
|
utils.partner.list.invalidate()
|
||||||
toast.success('Partner created successfully')
|
toast.success('Partner created successfully')
|
||||||
router.push('/admin/partners')
|
router.push('/admin/partners')
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || 'Failed to create partner')
|
toast.error(error.message || 'Failed to create partner')
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
const formData = new FormData(e.currentTarget)
|
const formData = new FormData(e.currentTarget)
|
||||||
const name = formData.get('name') as string
|
const name = formData.get('name') as string
|
||||||
const description = formData.get('description') as string
|
const description = formData.get('description') as string
|
||||||
const website = formData.get('website') as string
|
const website = formData.get('website') as string
|
||||||
|
|
||||||
createPartner.mutate({
|
createPartner.mutate({
|
||||||
name,
|
name,
|
||||||
programId: null,
|
programId: null,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
website: website || undefined,
|
website: website || undefined,
|
||||||
partnerType: partnerType as 'SPONSOR' | 'PARTNER' | 'SUPPORTER' | 'MEDIA' | 'OTHER',
|
partnerType: partnerType as 'SPONSOR' | 'PARTNER' | 'SUPPORTER' | 'MEDIA' | 'OTHER',
|
||||||
visibility: visibility as 'ADMIN_ONLY' | 'JURY_VISIBLE' | 'PUBLIC',
|
visibility: visibility as 'ADMIN_ONLY' | 'JURY_VISIBLE' | 'PUBLIC',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href="/admin/partners">
|
<Link href="/admin/partners">
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Add Partner</h1>
|
<h1 className="text-2xl font-bold">Add Partner</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Add a new partner or sponsor organization
|
Add a new partner or sponsor organization
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Partner Details</CardTitle>
|
<CardTitle>Partner Details</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Basic information about the partner organization
|
Basic information about the partner organization
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Organization Name *</Label>
|
<Label htmlFor="name">Organization Name *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="e.g., Ocean Conservation Foundation"
|
placeholder="e.g., Ocean Conservation Foundation"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="partnerType">Partner Type</Label>
|
<Label htmlFor="partnerType">Partner Type</Label>
|
||||||
<Select value={partnerType} onValueChange={setPartnerType}>
|
<Select value={partnerType} onValueChange={setPartnerType}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
||||||
<SelectItem value="PARTNER">Partner</SelectItem>
|
<SelectItem value="PARTNER">Partner</SelectItem>
|
||||||
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
||||||
<SelectItem value="MEDIA">Media</SelectItem>
|
<SelectItem value="MEDIA">Media</SelectItem>
|
||||||
<SelectItem value="OTHER">Other</SelectItem>
|
<SelectItem value="OTHER">Other</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="description">Description</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
name="description"
|
name="description"
|
||||||
placeholder="Describe the organization and partnership..."
|
placeholder="Describe the organization and partnership..."
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="website">Website</Label>
|
<Label htmlFor="website">Website</Label>
|
||||||
<Input
|
<Input
|
||||||
id="website"
|
id="website"
|
||||||
name="website"
|
name="website"
|
||||||
type="url"
|
type="url"
|
||||||
placeholder="https://example.org"
|
placeholder="https://example.org"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="visibility">Visibility</Label>
|
<Label htmlFor="visibility">Visibility</Label>
|
||||||
<Select value={visibility} onValueChange={setVisibility}>
|
<Select value={visibility} onValueChange={setVisibility}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="ADMIN_ONLY">Admin Only</SelectItem>
|
<SelectItem value="ADMIN_ONLY">Admin Only</SelectItem>
|
||||||
<SelectItem value="JURY_VISIBLE">Visible to Jury</SelectItem>
|
<SelectItem value="JURY_VISIBLE">Visible to Jury</SelectItem>
|
||||||
<SelectItem value="PUBLIC">Public</SelectItem>
|
<SelectItem value="PUBLIC">Public</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
<Link href="/admin/partners">
|
<Link href="/admin/partners">
|
||||||
<Button type="button" variant="outline">
|
<Button type="button" variant="outline">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Add Partner
|
Add Partner
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,259 +1,259 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useDebounce } from '@/hooks/use-debounce'
|
import { useDebounce } from '@/hooks/use-debounce'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Pencil,
|
Pencil,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Building2,
|
Building2,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Globe,
|
Globe,
|
||||||
Search,
|
Search,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const visibilityIcons = {
|
const visibilityIcons = {
|
||||||
ADMIN_ONLY: EyeOff,
|
ADMIN_ONLY: EyeOff,
|
||||||
JURY_VISIBLE: Eye,
|
JURY_VISIBLE: Eye,
|
||||||
PUBLIC: Globe,
|
PUBLIC: Globe,
|
||||||
}
|
}
|
||||||
|
|
||||||
const partnerTypeColors: Record<string, string> = {
|
const partnerTypeColors: Record<string, string> = {
|
||||||
SPONSOR: 'bg-yellow-100 text-yellow-800',
|
SPONSOR: 'bg-yellow-100 text-yellow-800',
|
||||||
PARTNER: 'bg-blue-100 text-blue-800',
|
PARTNER: 'bg-blue-100 text-blue-800',
|
||||||
SUPPORTER: 'bg-green-100 text-green-800',
|
SUPPORTER: 'bg-green-100 text-green-800',
|
||||||
MEDIA: 'bg-purple-100 text-purple-800',
|
MEDIA: 'bg-purple-100 text-purple-800',
|
||||||
OTHER: 'bg-gray-100 text-gray-800',
|
OTHER: 'bg-gray-100 text-gray-800',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PartnersPage() {
|
export default function PartnersPage() {
|
||||||
const { data, isLoading } = trpc.partner.list.useQuery({ perPage: 50 })
|
const { data, isLoading } = trpc.partner.list.useQuery({ perPage: 50 })
|
||||||
const partners = data?.data
|
const partners = data?.data
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const debouncedSearch = useDebounce(search, 300)
|
const debouncedSearch = useDebounce(search, 300)
|
||||||
const [typeFilter, setTypeFilter] = useState('all')
|
const [typeFilter, setTypeFilter] = useState('all')
|
||||||
const [activeFilter, setActiveFilter] = useState('all')
|
const [activeFilter, setActiveFilter] = useState('all')
|
||||||
|
|
||||||
const filteredPartners = useMemo(() => {
|
const filteredPartners = useMemo(() => {
|
||||||
if (!partners) return []
|
if (!partners) return []
|
||||||
return partners.filter((partner) => {
|
return partners.filter((partner) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
!debouncedSearch ||
|
!debouncedSearch ||
|
||||||
partner.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
partner.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||||
partner.description?.toLowerCase().includes(debouncedSearch.toLowerCase())
|
partner.description?.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||||
const matchesType = typeFilter === 'all' || partner.partnerType === typeFilter
|
const matchesType = typeFilter === 'all' || partner.partnerType === typeFilter
|
||||||
const matchesActive =
|
const matchesActive =
|
||||||
activeFilter === 'all' ||
|
activeFilter === 'all' ||
|
||||||
(activeFilter === 'active' && partner.isActive) ||
|
(activeFilter === 'active' && partner.isActive) ||
|
||||||
(activeFilter === 'inactive' && !partner.isActive)
|
(activeFilter === 'inactive' && !partner.isActive)
|
||||||
return matchesSearch && matchesType && matchesActive
|
return matchesSearch && matchesType && matchesActive
|
||||||
})
|
})
|
||||||
}, [partners, debouncedSearch, typeFilter, activeFilter])
|
}, [partners, debouncedSearch, typeFilter, activeFilter])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Skeleton className="h-8 w-48" />
|
<Skeleton className="h-8 w-48" />
|
||||||
<Skeleton className="mt-2 h-4 w-72" />
|
<Skeleton className="mt-2 h-4 w-72" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-9 w-32" />
|
<Skeleton className="h-9 w-32" />
|
||||||
</div>
|
</div>
|
||||||
{/* Toolbar skeleton */}
|
{/* Toolbar skeleton */}
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<Skeleton className="h-10 flex-1" />
|
<Skeleton className="h-10 flex-1" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Skeleton className="h-10 w-[160px]" />
|
<Skeleton className="h-10 w-[160px]" />
|
||||||
<Skeleton className="h-10 w-[160px]" />
|
<Skeleton className="h-10 w-[160px]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Partner cards skeleton */}
|
{/* Partner cards skeleton */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<Card key={i}>
|
<Card key={i}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Skeleton className="h-5 w-32" />
|
<Skeleton className="h-5 w-32" />
|
||||||
<Skeleton className="h-4 w-20" />
|
<Skeleton className="h-4 w-20" />
|
||||||
<Skeleton className="h-4 w-full" />
|
<Skeleton className="h-4 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Partners</h1>
|
<h1 className="text-2xl font-bold">Partners</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Manage partner and sponsor organizations
|
Manage partner and sponsor organizations
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/partners/new">
|
<Link href="/admin/partners/new">
|
||||||
<Button>
|
<Button>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Partner
|
Add Partner
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search partners..."
|
placeholder="Search partners..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
<SelectTrigger className="w-[160px]">
|
<SelectTrigger className="w-[160px]">
|
||||||
<SelectValue placeholder="All types" />
|
<SelectValue placeholder="All types" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All types</SelectItem>
|
<SelectItem value="all">All types</SelectItem>
|
||||||
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
||||||
<SelectItem value="PARTNER">Partner</SelectItem>
|
<SelectItem value="PARTNER">Partner</SelectItem>
|
||||||
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
||||||
<SelectItem value="MEDIA">Media</SelectItem>
|
<SelectItem value="MEDIA">Media</SelectItem>
|
||||||
<SelectItem value="OTHER">Other</SelectItem>
|
<SelectItem value="OTHER">Other</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
||||||
<SelectTrigger className="w-[160px]">
|
<SelectTrigger className="w-[160px]">
|
||||||
<SelectValue placeholder="All statuses" />
|
<SelectValue placeholder="All statuses" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All statuses</SelectItem>
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
<SelectItem value="active">Active</SelectItem>
|
<SelectItem value="active">Active</SelectItem>
|
||||||
<SelectItem value="inactive">Inactive</SelectItem>
|
<SelectItem value="inactive">Inactive</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
{partners && (
|
{partners && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{filteredPartners.length} of {partners.length} partners
|
{filteredPartners.length} of {partners.length} partners
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Partners Grid */}
|
{/* Partners Grid */}
|
||||||
{filteredPartners.length > 0 ? (
|
{filteredPartners.length > 0 ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{filteredPartners.map((partner) => {
|
{filteredPartners.map((partner) => {
|
||||||
const VisibilityIcon = visibilityIcons[partner.visibility as keyof typeof visibilityIcons] || Eye
|
const VisibilityIcon = visibilityIcons[partner.visibility as keyof typeof visibilityIcons] || Eye
|
||||||
return (
|
return (
|
||||||
<Card key={partner.id} className={!partner.isActive ? 'opacity-60' : ''}>
|
<Card key={partner.id} className={!partner.isActive ? 'opacity-60' : ''}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-start gap-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">
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||||
<Building2 className="h-6 w-6" />
|
<Building2 className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-medium truncate">{partner.name}</h3>
|
<h3 className="font-medium truncate">{partner.name}</h3>
|
||||||
{!partner.isActive && (
|
{!partner.isActive && (
|
||||||
<Badge variant="secondary">Inactive</Badge>
|
<Badge variant="secondary">Inactive</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<Badge className={partnerTypeColors[partner.partnerType] || ''} variant="outline">
|
<Badge className={partnerTypeColors[partner.partnerType] || ''} variant="outline">
|
||||||
{partner.partnerType}
|
{partner.partnerType}
|
||||||
</Badge>
|
</Badge>
|
||||||
<VisibilityIcon className="h-3 w-3 text-muted-foreground" />
|
<VisibilityIcon className="h-3 w-3 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{partner.description && (
|
{partner.description && (
|
||||||
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
|
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
|
||||||
{partner.description}
|
{partner.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 mt-4 pt-4 border-t">
|
<div className="flex items-center justify-end gap-2 mt-4 pt-4 border-t">
|
||||||
{partner.website && (
|
{partner.website && (
|
||||||
<a
|
<a
|
||||||
href={partner.website}
|
href={partner.website}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
<ExternalLink className="h-4 w-4 mr-1" />
|
<ExternalLink className="h-4 w-4 mr-1" />
|
||||||
Website
|
Website
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<Link href={`/admin/partners/${partner.id}`}>
|
<Link href={`/admin/partners/${partner.id}`}>
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
<Pencil className="h-4 w-4 mr-1" />
|
<Pencil className="h-4 w-4 mr-1" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : partners && partners.length > 0 ? (
|
) : partners && partners.length > 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
No partners match your filters
|
No partners match your filters
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<Building2 className="h-12 w-12 text-muted-foreground/40" />
|
<Building2 className="h-12 w-12 text-muted-foreground/40" />
|
||||||
<h3 className="mt-3 text-lg font-medium">No partners yet</h3>
|
<h3 className="mt-3 text-lg font-medium">No partners yet</h3>
|
||||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||||
Add sponsor and partner organizations to showcase on the platform.
|
Add sponsor and partner organizations to showcase on the platform.
|
||||||
</p>
|
</p>
|
||||||
<Button className="mt-4" asChild>
|
<Button className="mt-4" asChild>
|
||||||
<Link href="/admin/partners/new">
|
<Link href="/admin/partners/new">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Partner
|
Add Partner
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue