docs: map existing codebase

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 23:14:08 +01:00
parent c96f1b67a5
commit 8cc86bae20
7 changed files with 1647 additions and 0 deletions

View File

@@ -0,0 +1,193 @@
# Architecture
**Analysis Date:** 2026-02-26
## Pattern Overview
**Overall:** Role-gated multi-tenant monolith with end-to-end type safety (Prisma → tRPC → React)
**Key Characteristics:**
- Single Next.js 15 App Router application serving all roles (admin, jury, applicant, mentor, observer, public)
- All API calls go through tRPC with superjson serialization; no separate REST API for client data
- Role enforcement happens at two levels: layout-level (`requireRole()`) and procedure-level (tRPC middleware)
- Service layer (`src/server/services/`) contains all business logic — routers delegate immediately to services
- All state machine transitions are audited via `DecisionAuditLog` through `src/server/utils/audit.ts`
## Layers
**Data Layer:**
- Purpose: Schema definition, migrations, query client
- Location: `prisma/schema.prisma`, `src/lib/prisma.ts`
- Contains: Prisma schema, singleton PrismaClient with connection pool (limit=20, timeout=10)
- Depends on: PostgreSQL 16
- Used by: Service layer, tRPC context
**Service Layer:**
- Purpose: Business logic, state machines, external integrations
- Location: `src/server/services/`
- Contains: Round engine, deliberation, assignment, AI services, submission manager, live control, result lock, notification
- Depends on: Prisma client (passed as param to allow transactional usage), `src/lib/` utilities
- Used by: tRPC routers only
**API Layer (tRPC):**
- Purpose: Type-safe RPC procedures with role-based access control
- Location: `src/server/routers/`, `src/server/trpc.ts`, `src/server/context.ts`
- Contains: 44+ domain routers assembled in `src/server/routers/_app.ts`, middleware hierarchy, tRPC context
- Depends on: Service layer, Prisma (via context)
- Used by: Client components via `src/lib/trpc/client.ts`
**UI Layer:**
- Purpose: Server and client React components, pages, layouts
- Location: `src/app/`, `src/components/`
- Contains: Route groups per role, layouts with role guards, client components using tRPC hooks
- Depends on: tRPC client, shadcn/ui, Tailwind CSS
- Used by: Browser clients
**Shared Utilities:**
- Purpose: Cross-cutting helpers available everywhere
- Location: `src/lib/`
- Contains: Auth config, Prisma singleton, email, MinIO client, OpenAI client, logger, rate limiter, feature flags, storage provider abstraction
- Depends on: External services
- Used by: Service layer, routers, layouts
## Data Flow
**Client Query Flow:**
1. React component calls `trpc.domain.procedure.useQuery()` via `src/lib/trpc/client.ts`
2. Request hits `src/app/api/trpc/[trpc]/route.ts` — rate limited at 100 req/min per IP
3. tRPC resolves context (`src/server/context.ts`): auth session + prisma singleton + IP/UA
4. Middleware chain runs: authentication check → role check → procedure handler
5. Router delegates to service (e.g., `roundEngineRouter``src/server/services/round-engine.ts`)
6. Service queries Prisma, may call external APIs, writes audit log
7. Superjson-serialized result returns to React Query cache
**Mutation Flow (with audit trail):**
1. Component calls `trpc.domain.action.useMutation()`
2. tRPC middleware validates auth + role
3. Router calls `logAudit()` before or after service call
4. Service performs database work inside `prisma.$transaction()` when atomicity required
5. Service writes its own `logAudit()` for state machine transitions
6. Cache invalidated via `utils.trpc.invalidate()`
**Server-Sent Events Flow (live voting/deliberation):**
1. Client subscribes via `src/hooks/use-live-voting-sse.ts` or `src/hooks/use-stage-live-sse.ts`
2. SSE route `src/app/api/live-voting/stream/route.ts` polls database on interval
3. Events emitted for vote count changes, cursor position changes, status changes
4. Admin cursor controlled via `src/server/services/live-control.ts` → tRPC `liveRouter`
**State Management:**
- Server state: React Query via tRPC hooks (cache + invalidation)
- Edition/program selection: `src/contexts/edition-context.tsx` (localStorage + URL param + React Context)
- Form state: Local React state with autosave timers (evaluation page uses refs to prevent race conditions)
- No global client state library (no Redux/Zustand)
## Key Abstractions
**Competition/Round State Machine:**
- Purpose: Governs round lifecycle and per-project states within rounds
- Examples: `src/server/services/round-engine.ts`
- Pattern: Pure functions with explicit transition maps; `VALID_ROUND_TRANSITIONS` and `VALID_PROJECT_TRANSITIONS` constants define allowed moves. All transitions are transactional and audited.
- Round transitions: `ROUND_DRAFT → ROUND_ACTIVE → ROUND_CLOSED → ROUND_ARCHIVED`
- Project-in-round transitions: `PENDING → IN_PROGRESS → PASSED/REJECTED → COMPLETED/WITHDRAWN`
**tRPC Procedure Types (RBAC middleware):**
- Purpose: Enforce role-based access at the API boundary
- Examples: `src/server/trpc.ts`
- Pattern: `publicProcedure`, `protectedProcedure`, `adminProcedure`, `superAdminProcedure`, `juryProcedure`, `mentorProcedure`, `observerProcedure`, `awardMasterProcedure`, `audienceProcedure`
- Each is a pre-configured middleware chain; routers simply choose the correct type
**CompetitionContext resolver:**
- Purpose: Loads the full typed context for a round (competition + round + parsed configJson + juryGroup + submissionWindows)
- Examples: `src/server/services/competition-context.ts`
- Pattern: `resolveCompetitionContext(roundId)` used by any service that needs the full picture. Also provides `resolveMemberContext()` for jury-member-specific context with assignment counts.
**Round-Type Config Schemas:**
- Purpose: Each RoundType (`INTAKE`, `FILTERING`, `EVALUATION`, etc.) has a dedicated Zod config schema stored in `Round.configJson`
- Examples: `src/types/competition-configs.ts``IntakeConfigSchema`, `FilteringConfigSchema`, `EvaluationConfigSchema`, etc.
- Pattern: `safeValidateRoundConfig(roundType, configJson)` returns typed config or null; `validateRoundConfig()` throws on invalid
**Storage Provider Abstraction:**
- Purpose: Swap MinIO (production) for local filesystem (dev/test) without changing service code
- Examples: `src/lib/storage/types.ts`, `src/lib/storage/s3-provider.ts`, `src/lib/storage/local-provider.ts`, `src/lib/storage/index.ts`
- Pattern: `StorageProvider` interface with `getUploadUrl`, `getDownloadUrl`, `deleteObject`, `putObject`, `getObject`
**AI Pipeline with Anonymization:**
- Purpose: All AI calls strip PII before sending to OpenAI
- Examples: `src/server/services/anonymization.ts` + any `src/server/services/ai-*.ts`
- Pattern: `anonymizeProjectsForAI()` returns `AnonymizedProjectForAI[]` + `ProjectAIMapping`; AI service uses anonymous IDs; results mapped back via mapping object
## Entry Points
**tRPC API Handler:**
- Location: `src/app/api/trpc/[trpc]/route.ts`
- Triggers: All client data queries and mutations
- Responsibilities: Rate limiting (100 req/min), `fetchRequestHandler` with `appRouter` + `createContext`, error logging
**Auth Handler:**
- Location: `src/app/api/auth/[...nextauth]/route.ts`
- Triggers: Login, magic link verification, session management
- Responsibilities: NextAuth v5 with Email + Credentials providers, 5-attempt lockout
**Cron Endpoints:**
- Location: `src/app/api/cron/` (audit-cleanup, digest, draft-cleanup, reminders)
- Triggers: External scheduler via `CRON_SECRET` header check
- Responsibilities: Periodic maintenance — evaluation reminders, draft cleanup, digest emails, audit log rotation
**SSE Stream:**
- Location: `src/app/api/live-voting/stream/route.ts`
- Triggers: Live voting/deliberation pages subscribe as long-running GET connections
- Responsibilities: Poll DB for changes, push events for vote counts, cursor position, status transitions
**Next.js Middleware:**
- Location: `middleware.ts` (root, uses `src/lib/auth.config.ts`)
- Triggers: Every request
- Responsibilities: Auth check (edge-compatible), redirect to `/login` if unauthenticated, redirect to `/set-password` if `mustSetPassword` flag set
**Role Layout Guards:**
- Location: `src/app/(admin)/layout.tsx`, `src/app/(jury)/layout.tsx`, etc.
- Triggers: Navigation into role-specific route group
- Responsibilities: Server-side `requireRole()` call, redirect to role dashboard if unauthorized, onboarding gate (jury)
## Error Handling
**Strategy:** Boundary-based — tRPC errors propagate to React Query; service errors use `TRPCError`; audit never throws
**Patterns:**
- Services return typed result objects (`{ success: boolean, errors?: string[] }`) for state machine operations — no throwing
- tRPC procedures throw `TRPCError` with code (`NOT_FOUND`, `FORBIDDEN`, `CONFLICT`, `BAD_REQUEST`) for client-visible errors
- `logAudit()` is wrapped in try-catch — audit failures are logged to console but never surface to users
- AI services use `classifyAIError()` from `src/server/services/ai-errors.ts` to translate OpenAI errors
- Round notification functions explicitly catch all errors and log them — notifications never block round transitions
- Client uses `toast.error()` from sonner for user-facing error display
## Cross-Cutting Concerns
**Logging:**
- `src/lib/logger.ts` — tagged, level-aware (`debug/info/warn/error`), respects `LOG_LEVEL` env var, defaults to `debug` in development and `warn` in production
- Pattern: `logger.info('ServiceName', 'message', { data })` — tag identifies the calling service
**Validation:**
- Zod schemas on all tRPC procedure inputs (`.input(z.object({...}))`)
- `ZodError` is formatted and included in tRPC error response via `errorFormatter` in `src/server/trpc.ts`
- Round config Zod schemas in `src/types/competition-configs.ts` validate `configJson` at activation time
**Authentication:**
- NextAuth v5 with JWT strategy; session available server-side via `auth()` from `src/lib/auth.ts`
- `requireRole(...roles)` in `src/lib/auth-redirect.ts` used by all role layouts — checks `user.roles[]` array with `user.role` fallback
- `userHasRole()` in `src/server/trpc.ts` used inline for fine-grained procedure-level checks
**Audit Trail:**
- `logAudit()` in `src/server/utils/audit.ts` — writes to `AuditLog` table
- Called from both routers (with `ctx.prisma` to share transaction) and services
- Never throws — always wrapped in try-catch
**Feature Flags:**
- `src/lib/feature-flags.ts` — reads `SystemSetting` records with `category: FEATURE_FLAGS`
- Currently one active flag: `feature.useCompetitionModel` (defaults to `true`)
---
*Architecture analysis: 2026-02-26*

View File

@@ -0,0 +1,217 @@
# Codebase Concerns
**Analysis Date:** 2026-02-26
---
## Tech Debt
**Award Router is a Stub:**
- Issue: Entire award router is commented out and non-functional. All award procedures were deleted when the Pipeline/Track models were removed and have not been reimplemented.
- Files: `src/server/routers/award.ts`
- Impact: Any UI that references award management procedures will fail at runtime. The `SpecialAward` model still exists in the schema but has no tRPC exposure via this router.
- Fix approach: Reimplement the router procedures against the current `SpecialAward``Competition` FK relationship. See the TODO comment at line 9 for the list of procedures to reimplement.
**Deliberation Page Has Incomplete Implementation:**
- Issue: The jury-facing deliberation page has two hardcoded stub values that break actual vote submission.
- `juryMemberId: ''` — submitted vote will have an empty juror ID.
- `const hasVoted = false` — the "already voted" guard never fires, allowing duplicate vote submissions.
- Files: `src/app/(jury)/jury/competitions/deliberation/[sessionId]/page.tsx` lines 34 and 66
- Impact: Jury members can submit blank/duplicate votes in deliberation sessions. The submitted vote will be associated with an empty string `juryMemberId`, which will likely fail at the Prisma level or silently create bad data.
- Fix approach: Derive `juryMemberId` from `session.participants` by matching `ctx.user.id`. Derive `hasVoted` by checking if a `DeliberationVote` with the current user's jury member ID already exists in `session.votes`.
**Audit Middleware is a No-Op:**
- Issue: The `withAuditLog` middleware in `src/server/trpc.ts` (lines 99114) identifies mutation calls by path pattern but does nothing with them — the body contains only a comment: `// We'll implement this in the audit service`.
- Files: `src/server/trpc.ts`
- Impact: Automatic centralised audit logging for all admin mutations does not occur through this middleware. Manual `logAudit()` / `DecisionAuditLog.create()` calls are present in many routers but coverage is inconsistent.
- Fix approach: Implement the middleware body to call `logAudit()` with `ctx.user.id`, `path`, and serialized input/output. This provides a safety net for any procedure that doesn't call `logAudit` manually.
**In-Memory Rate Limiter Not Suitable for Multi-Instance Deployment:**
- Issue: `src/lib/rate-limit.ts` uses a module-level `Map` for rate limit state. This works in a single process but does not share state across multiple Node.js instances or after a process restart.
- Files: `src/lib/rate-limit.ts`
- Impact: Rate limits can be trivially bypassed by hitting different server instances. Auth brute-force protection (5-attempt lockout) also uses this same in-memory store (`src/lib/auth.ts` line 12).
- Fix approach: Replace with Redis-based rate limiting (e.g., `@upstash/ratelimit` or `ioredis`). The comment at line 5 already acknowledges this: "For production with multiple instances, replace with Redis-based solution."
**`configJson` Widely Cast Without Validation:**
- Issue: `configJson` (a Prisma `Json` field) is cast directly to `Record<string, unknown>` in 65 locations across server routers and services without running it through the Zod validators. The validators (`safeValidateRoundConfig`, `EvaluationConfigSchema.safeParse`) are only called in 4 locations.
- Files: `src/server/routers/assignment.ts`, `src/server/routers/evaluation.ts`, `src/server/routers/filtering.ts`, `src/server/services/round-engine.ts`, and many others.
- Impact: Stale or malformed config JSON stored in the database can cause silent runtime failures deep in business logic (e.g., missing criteria, wrong field names) without a clear validation error.
- Fix approach: Extract a typed `parseRoundConfig(roundType, configJson)` utility that returns a typed config or throws a `TRPCError`. Replace bare `as Record<string, unknown>` casts with this utility at query boundaries.
---
## Known Bugs
**Tag Rename Performs N+1 Database Writes:**
- Symptoms: Renaming a tag iterates over every user and every project that has the tag, issuing one `UPDATE` per record.
- Files: `src/server/routers/tag.ts` lines 361389 and 421438
- Trigger: Admin renames any tag that is widely used.
- Workaround: None. Will time out for large datasets.
- Fix approach: Use a raw SQL `UPDATE ... SET tags = array_replace(tags, $old, $new)` or a Prisma `$executeRaw` to perform the rename in a single query.
**Jury Deliberation Vote: `juryMemberId` Is Hardcoded Empty String:**
- Symptoms: Votes submitted via the jury deliberation page will have `juryMemberId: ''`.
- Files: `src/app/(jury)/jury/competitions/deliberation/[sessionId]/page.tsx` line 34
- Trigger: Any jury member visits a deliberation session and submits a vote.
- Workaround: None — the vote will silently pass or fail depending on Prisma validation.
---
## Security Considerations
**IP Header Spoofing on Rate Limiter:**
- Risk: All rate limiters extract the client IP from `x-forwarded-for` without validating that the header originates from a trusted proxy. A client can set this header to any value, bypassing per-IP rate limits.
- Files: `src/app/api/trpc/[trpc]/route.ts` lines 1318, `src/app/api/auth/[...nextauth]/route.ts` lines 710, `src/app/api/email/change-password/route.ts` line 36, `src/app/api/email/verify-credentials/route.ts` line 20, `src/server/context.ts` line 13.
- Current mitigation: Nginx passes `X-Forwarded-For` from upstream; in single-proxy deployment this reduces (but does not eliminate) risk.
- Recommendations: Pin IP extraction to `req.headers.get('x-real-ip')` set by Nginx only, or validate the forwarded-for chain against a trusted proxy list.
**Cron Secret Compared with `!==` (NonTiming-Safe):**
- Risk: String equality check `cronSecret !== process.env.CRON_SECRET` is vulnerable to timing side-channel attacks on the secret value.
- Files: `src/app/api/cron/audit-cleanup/route.ts`, `src/app/api/cron/digest/route.ts`, `src/app/api/cron/draft-cleanup/route.ts`, `src/app/api/cron/reminders/route.ts` — all at line 8.
- Current mitigation: Cron endpoints are not user-facing and rate limited at Nginx level.
- Recommendations: Replace with `timingSafeEqual(Buffer.from(cronSecret), Buffer.from(process.env.CRON_SECRET))` — the same approach already used in `src/lib/storage/local-provider.ts` line 75.
**No Content-Security-Policy Header:**
- Risk: No CSP is set in `next.config.ts` or via middleware headers. If an XSS vector exists, there is no second line of defence to limit script execution.
- Files: `next.config.ts` (missing `headers()` function), `docker/nginx/mopc-platform.conf` (missing CSP directive).
- Current mitigation: Nginx sets `X-Frame-Options`, `X-Content-Type-Options`, and `X-XSS-Protection`, but these are legacy headers. No HSTS header is configured in Nginx either (only set post-certbot).
- Recommendations: Add `Content-Security-Policy` and `Strict-Transport-Security` via the Next.js `headers()` config function.
**MinIO Fallback Credentials Hardcoded:**
- Risk: When `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` are not set in non-production environments, the client defaults to `minioadmin`/`minioadmin`.
- Files: `src/lib/minio.ts` lines 2829
- Current mitigation: Production throws an error if credentials are missing (line 2022). The fallback only applies in development.
- Recommendations: Remove the hardcoded fallback entirely; require credentials in all environments to prevent accidental exposure of a non-production MinIO instance.
**`NEXT_PUBLIC_MINIO_ENDPOINT` Undefined in Production:**
- Risk: Two admin learning pages read `process.env.NEXT_PUBLIC_MINIO_ENDPOINT` at runtime. This variable is not defined in `docker-compose.yml` and has no `NEXT_PUBLIC_` entry. Next.js requires public env vars to be present at build time; at runtime this will always resolve to the fallback `http://localhost:9000`, making file previews broken in production.
- Files: `src/app/(admin)/admin/learning/new/page.tsx` line 112, `src/app/(admin)/admin/learning/[id]/page.tsx` line 165.
- Fix approach: Add `NEXT_PUBLIC_MINIO_ENDPOINT` to `docker-compose.yml` env section, or use the server-side `MINIO_PUBLIC_ENDPOINT` via a tRPC query rather than client-side env.
---
## Performance Bottlenecks
**Unbounded `findMany` Queries in Analytics Router:**
- Problem: `src/server/routers/analytics.ts` contains approximately 25 `findMany` calls with no `take` limit. With large competitions (hundreds of projects, thousands of evaluations) these will perform full table scans filtered only by `roundId` or `competitionId`.
- Files: `src/server/routers/analytics.ts` — queries at lines 38, 80, 265, 421, 539, 582, 649, 749, 794, 1207, 1227, 1346, 1406, 1481, 1498, 1654, 1677, 1700.
- Cause: Analytics queries are built for correctness, not scale. They load entire result sets into Node.js memory before aggregation.
- Improvement path: Move aggregation into the database using Prisma `groupBy` and `_count`/`_avg` aggregations, or write `$queryRaw` SQL for complex analytics. Add pagination or date-range limits to the procedure inputs.
**Tag Rename N+1 Pattern:**
- Problem: Renaming a tag issues one DB write per entity (user or project) that carries the tag rather than a single bulk update.
- Files: `src/server/routers/tag.ts` lines 355390
- Cause: Prisma does not support `array_replace` natively; the current implementation works around this with a loop.
- Improvement path: Use `prisma.$executeRaw` with PostgreSQL's `array_replace` function.
**`assignment.ts` Router is 3,337 Lines:**
- Problem: The assignment router is the single largest file and handles jury assignments, AI assignment, manual overrides, transfer, COI, and coverage checks in one module.
- Files: `src/server/routers/assignment.ts`
- Cause: Organic growth without module splitting.
- Improvement path: Extract into separate files: `src/server/routers/assignment/manual.ts`, `assignment/ai.ts`, `assignment/coverage.ts`. This will also improve IDE performance and test isolation.
---
## Fragile Areas
**Round Engine: COMPLETED State Has No Guards on Re-Entry:**
- Files: `src/server/services/round-engine.ts` lines 5764
- Why fragile: `COMPLETED` is defined as a terminal state (empty transitions array). However, there is no server-side guard preventing a direct Prisma update to a `COMPLETED` project state outside of `transitionProjectState()`. If a bug or data migration bypasses the state machine, projects can end up in unexpected states.
- Safe modification: Always transition through `transitionProjectState()`. Any admin data repair scripts should call this function rather than using `prisma.projectRoundState.update` directly.
- Test coverage: No unit tests for project state transitions. Only `tests/unit/assignment-policy.test.ts` exists, covering a different subsystem.
**`Prisma.$transaction` Typed as `any`:**
- Files: `src/server/services/round-engine.ts` line 129, `src/server/services/result-lock.ts` lines 87, 169, `src/server/services/mentor-workspace.ts` lines 39, 254, and 50+ other locations.
- Why fragile: `tx: any` disables all type-checking inside transaction callbacks. A mistakenly called method on `tx` (e.g., `tx.round.delete` instead of `tx.round.update`) will compile successfully but may cause silent data corruption.
- Safe modification: Type the callback as `(tx: Parameters<Parameters<typeof prisma.$transaction>[0]>[0]) => ...` or define a `TransactionalPrisma` type alias. The `PrismaClient | any` union also defeats the purpose of typing.
**`email.ts` is 2,175 Lines:**
- Files: `src/lib/email.ts`
- Why fragile: All email templates, SMTP transport logic, and dynamic config loading are in one file. Adding a new email type requires navigating 2,000+ lines, and any change to transport setup affects all templates.
- Safe modification: Extract individual email functions into `src/lib/email/` subdirectory with one file per template type. Keep shared transport logic in `src/lib/email/transport.ts`.
- Test coverage: No tests for email sending. Email errors are caught and logged but not surfaced to callers consistently.
**`admin/rounds/[roundId]/page.tsx` is 2,398 Lines:**
- Files: `src/app/(admin)/admin/rounds/[roundId]/page.tsx`
- Why fragile: The entire round management UI (config, assignments, filtering, deliberation controls) lives in a single client component. State from one section can accidentally affect another, and the component re-renders on any state change.
- Safe modification: Extract tab sections into separate `'use client'` components with scoped state. Consider converting to a tab-based layout with lazy loading.
**SSE Live Voting Stream Relies on Polling:**
- Files: `src/app/api/live-voting/stream/route.ts` lines 184194
- Why fragile: The SSE endpoint polls the database every 2 seconds per connected client. Under live ceremony conditions with many simultaneous audience connections, this can produce significant database load.
- Safe modification: Introduce a Redis pub/sub channel that the vote submission path writes to, and have the SSE stream subscribe to the channel rather than polling. Alternatively, implement a debounce on the poll and share the result across all open SSE connections via a singleton broadcaster.
---
## Scaling Limits
**In-Memory State (Rate Limiter, Login Attempts):**
- Current capacity: Works correctly for a single Node.js process.
- Limit: Breaks under horizontal scaling or after a process restart (all rate limit windows reset).
- Scaling path: Replace `src/lib/rate-limit.ts` with a Redis-backed solution. Replace the `failedAttempts` Map in `src/lib/auth.ts` with Redis counters or database fields on the `User` model.
**SSE Connection Count vs. Database Poll Rate:**
- Current capacity: Each SSE client issues 1 database query per 2 seconds.
- Limit: At 100 concurrent audience connections, this is 50 queries/second to `liveVotingSession` and related tables during a ceremony.
- Scaling path: Shared broadcaster pattern (one database poll, fan-out to all SSE streams) or Redis pub/sub as described above.
---
## Dependencies at Risk
**`next-auth` v5 (Auth.js) — Beta API:**
- Risk: Auth.js v5 was in release candidate status at time of integration. The API surface (`authConfig` + `handlers` + `auth`) differs significantly from v4. Upgrading to a stable v5 release may require changes to `src/lib/auth.ts` and `src/lib/auth.config.ts`.
- Impact: Session type definitions, adapter interfaces, and middleware patterns may change.
- Migration plan: Monitor the Auth.js v5 stable release. Changes are likely limited to `src/lib/auth.ts` and `src/types/next-auth.d.ts`.
---
## Missing Critical Features
**No Database Backup Configuration:**
- Problem: `docker-compose.yml` has no scheduled backup service or volume snapshot configuration for the PostgreSQL container.
- Blocks: Point-in-time recovery after data loss or accidental deletion.
- Recommendation: Add a sidecar backup service (e.g., `prodrigestivill/postgres-backup-local`) or configure WAL archiving to MinIO.
**No Error Monitoring / Observability:**
- Problem: There is no Sentry, Datadog, or equivalent error monitoring integration. Application errors are only logged to stdout via `console.error`. In production, these are only visible if the Docker logs are actively monitored.
- Files: No integration found in `src/instrumentation.ts` or anywhere else.
- Blocks: Proactive detection of runtime errors, AI service failures, and payment/submission edge cases.
- Recommendation: Add Sentry (`@sentry/nextjs`) in `src/instrumentation.ts` — Next.js has native support for this. Filter out expected errors (e.g., `TRPCError` with `NOT_FOUND`) to reduce noise.
**No Automated Tests for Core Business Logic (Round Engine, Evaluation, Filtering):**
- Problem: Only one test file exists: `tests/unit/assignment-policy.test.ts`. The round engine state machine (`src/server/services/round-engine.ts`), evaluation submission flow (`src/server/routers/evaluation.ts`), and AI filtering pipeline (`src/server/services/ai-filtering.ts`) have no automated tests.
- Blocks: Confident refactoring of the state machine, advance-criterion logic, and scoring.
- Recommendation: Add unit tests for `activateRound`, `closeRound`, `transitionProjectState` (happy path + guard failures), and `submitEvaluation` (COI check, advance criterion logic, score validation).
---
## Test Coverage Gaps
**Round Engine State Machine:**
- What's not tested: All `activateRound`, `closeRound`, `archiveRound`, `transitionProjectState` transitions, including guard conditions (e.g., activating an ARCHIVED round, transitioning a COMPLETED project).
- Files: `src/server/services/round-engine.ts`
- Risk: A regression in state transition guards could allow data corruption (e.g., re-activating a closed round, double-passing a project).
- Priority: High
**Evaluation Submission (advance criterion, COI, scoring):**
- What's not tested: `submitEvaluation` mutation — specifically the advance criterion auto-transition logic (lines 16371646 of `src/server/routers/evaluation.ts`), COI auto-reassignment on `declareCOI`, and `upsertForm` criterion validation.
- Files: `src/server/routers/evaluation.ts`
- Risk: Regression in advance criterion will silently skip project advancement. COI declaration failures are caught and logged but untested.
- Priority: High
**AI Anonymization:**
- What's not tested: `sanitizeText`, `anonymizeProject`, `validateNoPersonalInfo` in `src/server/services/anonymization.ts`. These are GDPR-critical functions.
- Files: `src/server/services/anonymization.ts`
- Risk: A PII leak in AI calls would violate GDPR without detection.
- Priority: High
**Assignment Policy Execution:**
- What's not tested: End-to-end `executeAssignment` flow in `src/server/services/round-assignment.ts` — specifically the COI filtering, geo-diversity penalty, familiarity bonus, and under-coverage gap-fill.
- Files: `src/server/services/round-assignment.ts`, `src/server/services/smart-assignment.ts`
- Risk: Silent over- or under-assignment when constraints interact.
- Priority: Medium
---
*Concerns audit: 2026-02-26*

View File

@@ -0,0 +1,267 @@
# Coding Conventions
**Analysis Date:** 2026-02-26
## Naming Patterns
**Files:**
- `kebab-case` for all TypeScript/TSX source files: `round-engine.ts`, `filtering-dashboard.tsx`, `ai-errors.ts`
- `kebab-case` for route directories under `src/app/`: `(admin)/admin/juries/`, `[roundId]/`
- Exception: Next.js reserved names remain as-is: `page.tsx`, `layout.tsx`
**Components:**
- `PascalCase` for component functions: `FilteringDashboard`, `EmptyState`, `JuriesPage`
- Page components follow the suffix pattern `XxxPage`: `JuriesPage`, `RoundDetailPage`, `AuditLogPage`
- Sub-components within a file follow `XxxSection`, `XxxCard`, `XxxDialog`
**Functions:**
- `camelCase` for all functions and methods: `activateRound`, `resolveEffectiveCap`, `createTestUser`
- Service functions are named by operation + domain: `activateRound`, `closeRound`, `batchTransitionProjects`
- Boolean functions prefixed with `is`, `has`, `should`, `can`: `shouldRetry`, `isParseError`, `shouldLog`
**Variables:**
- `camelCase` for all local variables
- `SCREAMING_SNAKE_CASE` for module-level constants: `BATCH_SIZE = 50`, `SYSTEM_DEFAULT_CAP`, `VALID_ROUND_TRANSITIONS`
- Enum-like lookup objects in `SCREAMING_SNAKE_CASE`: `ERROR_PATTERNS`, `LOG_LEVELS`
**Types:**
- `type` keyword preferred over `interface` per CLAUDE.md — but both exist in practice
- `interface` is used for component props in some files (e.g., `ButtonProps`, `EmptyStateProps`), `type` used in others
- Prisma-derived types use `type` aliases with `z.infer<typeof Schema>`: `type EvaluationConfig = z.infer<typeof EvaluationConfigSchema>`
- Prop types: `type XxxProps = { ... }` (preferred in most components), `interface XxxProps { ... }` (used in deliberation, some UI components)
- Export complex input types from `src/types/competition.ts`: `CreateCompetitionInput`, `UpdateRoundInput`
## Code Style
**Formatting:**
- Prettier 3.4.2 with `prettier-plugin-tailwindcss` for class sorting
- No `.prettierrc` found — uses Prettier defaults: 2-space indent, double quotes, trailing commas (ES5), 80-char print width
- Single quotes confirmed absent in codebase: all string literals use double quotes
- Tailwind classes sorted automatically by the plugin on format
**Linting:**
- ESLint 9.x with `eslint-config-next` (Next.js configuration)
- Run via `npm run lint` (calls `next lint`)
- No custom rules file found — relies on Next.js default rules
**TypeScript:**
- Strict mode enabled in `tsconfig.json` (`"strict": true`)
- `noEmit: true` — TypeScript used for type checking only, not transpilation
- Target: ES2022
- Module resolution: `bundler` (Next.js Turbopack compatible)
- Path alias: `@/*` maps to `./src/*`
## Import Organization
**Order (observed in client components):**
1. `'use client'` directive (if needed)
2. React/framework hooks: `import { useState, useEffect } from 'react'`
3. Next.js imports: `import { useRouter } from 'next/navigation'`, `import Link from 'next/link'`
4. tRPC client: `import { trpc } from '@/lib/trpc/client'`
5. UI libraries: shadcn/ui components `import { Button } from '@/components/ui/button'`
6. Icons: `import { Loader2, Save } from 'lucide-react'`
7. Internal utilities/helpers: `import { cn } from '@/lib/utils'`
8. Internal components: `import { FilteringDashboard } from '@/components/admin/round/...'`
9. Types: `import type { EvaluationConfig } from '@/types/competition-configs'`
**Order (observed in server/service files):**
1. `import { z } from 'zod'` (validation)
2. `import { TRPCError } from '@trpc/server'` (errors)
3. tRPC router/procedures: `import { router, adminProcedure } from '../trpc'`
4. Internal services/utilities: `import { logAudit } from '@/server/utils/audit'`
5. Type imports at end: `import type { PrismaClient } from '@prisma/client'`
**Path Aliases:**
- Use `@/` prefix for all internal imports: `@/components/ui/button`, `@/server/services/round-engine`
- Never use relative `../../` paths for cross-directory imports
- Relative paths (`./`, `../`) only within the same directory level
## React Component Conventions
**Server vs Client Components:**
- Default to **Server Components** — do not add `'use client'` unless needed
- Layouts (`layout.tsx`) are server components: they call `await requireRole()`, fetch data directly from Prisma, and pass to client wrappers
- Pages that use tRPC hooks, `useState`, or browser APIs must be `'use client'`
- The pattern: server layout fetches session/editions → passes to client wrapper → client components handle interactive state
**Client Component Pattern:**
```tsx
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
type XxxProps = {
competitionId: string
roundId: string
}
export default function XxxPage() { ... }
// Sub-components in same file as local functions (not exported)
function XxxSection({ competition }: XxxSectionProps) { ... }
```
**Server Layout Pattern:**
```tsx
import { requireRole } from '@/lib/auth-redirect'
import { prisma } from '@/lib/prisma'
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await requireRole('SUPER_ADMIN', 'PROGRAM_ADMIN')
const data = await prisma.program.findMany({ ... })
return <ClientWrapper data={data}>{children}</ClientWrapper>
}
```
**Props with Params (Next.js 15):**
```tsx
type PageProps = {
params: Promise<{ roundId: string; projectId: string }>
}
export default function Page({ params: paramsPromise }: PageProps) {
const params = use(paramsPromise) // React.use() for async params
...
}
```
## tRPC Router Conventions
**Procedure Selection:**
- `adminProcedure` for CRUD on competition/round/jury entities
- `protectedProcedure` for shared read access across roles
- `juryProcedure` for jury-only operations
- Role checks within procedure body use `userHasRole(ctx.user, 'ROLE')` for per-entity authorization
**Input Validation:**
- All inputs validated with Zod `.input(z.object({ ... }))`
- Use `.min()`, `.max()`, `.regex()` for strings
- Use `.int().positive()` for ID/count integers
- Use `.optional().nullable()` for optional fields with null support
- Inline schema definition (not shared schema objects) per router
**Mutation Pattern:**
```typescript
create: adminProcedure
.input(z.object({
name: z.string().min(1).max(255),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
}))
.mutation(async ({ ctx, input }) => {
// 1. Check business rules (conflict, not found)
const existing = await ctx.prisma.xxx.findUnique({ where: { slug: input.slug } })
if (existing) {
throw new TRPCError({ code: 'CONFLICT', message: '...' })
}
// 2. Perform operation
const result = await ctx.prisma.xxx.create({ data: input })
// 3. Audit log (for mutations)
await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', ... })
// 4. Return result
return result
})
```
**Error Codes:**
- `NOT_FOUND` — entity doesn't exist
- `CONFLICT` — duplicate slug/unique constraint
- `FORBIDDEN` — user lacks permission for specific entity
- `UNAUTHORIZED` — not logged in (handled by middleware)
- `BAD_REQUEST` — invalid business state (e.g., no active form)
## Error Handling
**tRPC Routers (user-facing errors):**
- Always throw `TRPCError` with `{ code, message }` — never plain `throw new Error()`
- Message should be human-readable: `'Competition not found'`, not `'Competition_NOT_FOUND'`
- Use `findUniqueOrThrow` / `findFirstOrThrow` for implicit 404s on required relations
**Service Layer (internal errors):**
- Services return result objects `{ success: boolean, errors?: string[] }` — they do NOT throw
- Callers check `result.success` before proceeding
- Error message pattern: `error instanceof Error ? error.message : 'Unknown error during X'`
- Non-fatal side effects (notifications, emails) are wrapped in separate try/catch and logged but never propagate
**AI Services:**
- Use `classifyAIError()` from `src/server/services/ai-errors.ts` for all OpenAI errors
- Wrap AI calls with `withAIErrorHandling(fn, fallback)` for unified error + fallback handling
- All AI errors logged with service tag: `console.error('[AI Assignment] failed:', ...)`
**Client-Side:**
- Mutation errors displayed via `toast.error(err.message)` from Sonner
- Success via `toast.success('...')` with query invalidation: `utils.xxx.yyy.invalidate()`
- Loading states tracked via `mutation.isPending` and `query.isLoading`
## Logging
**Framework:** Custom structured logger at `src/lib/logger.ts`
**Usage:**
```typescript
import { logger } from '@/lib/logger'
logger.info('RoundEngine', 'Round activated', { roundId, competitionId })
logger.error('Storage', 'Upload failed', error)
logger.warn('Filtering', 'Non-fatal error in document check', retroError)
```
**Tag Convention:** `[ServiceName]` prefix in brackets — `'RoundEngine'`, `'AIFiltering'`, `'Storage'`
**Direct console usage** (still common in routers, not yet migrated to logger):
- Tagged format: `console.log('[FeatureName] message', data)`
- Always uses bracket tag: `'[Filtering]'`, `'[Assignment]'`, `'[COI]'`
**Log Level Defaults:**
- Development: `debug` (all logs)
- Production: `warn` (warns and errors only)
- Override via `LOG_LEVEL` env var
## Comments
**When to Comment:**
- All exported service functions get a JSDoc-style comment explaining purpose and invariants
- Inline comments for non-obvious business logic: `// re-include after rejection`, `// Bounded to admin max`
- Section header separators in large files using box-drawing chars: `// ─── Section Name ──────`
- `// =====` separators for major logical sections in long files
**JSDoc/TSDoc:**
- Used on exported functions in services and utilities
- Standard `/**` block with plain description — no `@param`/`@returns` annotations in most code
- Routers use `/** procedure description */` above each procedure for documentation
**TODO Comments:**
- Present but sparse — only 3 found in entire codebase (deliberation page, award router)
- Format: `// TODO: description`
## Function Design
**Size:** Service functions can be long (100-200+ lines) for complex state machines; router procedures typically 20-60 lines
**Parameters:**
- Services accept `(entityId: string, actorId: string, prisma: PrismaClient)` — explicit prisma injection for testability
- Router procedures destructure `{ ctx, input }` — never access `ctx.prisma` outside routers
**Return Values:**
- Queries return data directly or throw `TRPCError`
- Mutations return the created/updated record
- Services return typed result objects: `RoundTransitionResult`, `BatchProjectTransitionResult`
- Async service results always typed: `Promise<RoundTransitionResult>`
## Module Design
**Exports:**
- Single responsibility: each service file exports one domain's functions
- Named exports preferred over default exports for services and utilities
- Default exports used only for React components (`export default function Page()`)
**Barrel Files:**
- Used sparingly — only for chart components (`src/components/charts/index.ts`), form steps, and storage utilities
- Most imports are direct path imports: `import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'`
**Prisma Transactions:**
- Use `ctx.prisma.$transaction(async (tx) => { ... })` for multi-step mutations
- Always pass `tx` (transaction client) through nested operations
- Sequential array syntax `ctx.prisma.$transaction([op1, op2])` for simple atomic batches
---
*Convention analysis: 2026-02-26*

View File

@@ -0,0 +1,213 @@
# External Integrations
**Analysis Date:** 2026-02-26
## APIs & External Services
**AI Providers (configurable via SystemSettings DB table):**
- OpenAI - AI filtering, jury assignment suggestions, evaluation summaries, project tagging, award eligibility, shortlist recommendations
- SDK: `openai` ^6.16.0
- Auth: `OPENAI_API_KEY` env var or `openai_api_key` SystemSetting
- Base URL: `OPENAI_BASE_URL` env var or `openai_base_url` SystemSetting (for OpenAI-compatible proxies)
- Model: `OPENAI_MODEL` env var or `ai_model` SystemSetting (default: `gpt-4o`)
- Client: `src/lib/openai.ts` - lazy singleton, reset via `resetOpenAIClient()`
- Anthropic Claude - Alternative AI provider, same AI feature set as OpenAI
- SDK: `@anthropic-ai/sdk` ^0.78.0
- Auth: `ANTHROPIC_API_KEY` env var or `anthropic_api_key` SystemSetting
- Adapter: `src/lib/openai.ts` wraps Anthropic SDK behind OpenAI `.chat.completions.create()` interface
- Supported models: `claude-opus-4-5-20250514`, `claude-sonnet-4-5-20250514`, `claude-haiku-3-5-20241022`, `claude-opus-4-20250514`, `claude-sonnet-4-20250514`
- Extended thinking enabled automatically for Opus models
- LiteLLM proxy - Third option for ChatGPT subscription routing (no real API key needed)
- Config: `ai_provider = 'litellm'` in SystemSettings + `openai_base_url` pointing to proxy
- Token limit fields stripped for `chatgpt/*` model prefix
- AI provider selection: `getConfiguredProvider()` in `src/lib/openai.ts` reads `ai_provider` SystemSetting; defaults to `openai`
**All AI data is anonymized before sending** via `src/server/services/anonymization.ts`
**Notion:**
- Used for project import (alternative to CSV)
- SDK: `@notionhq/client` ^2.3.0
- Auth: API key stored in SystemSettings (`notion_api_key`), per-import flow
- Client: `src/lib/notion.ts` - `createNotionClient(apiKey)` per-request (not singleton)
- Router: `src/server/routers/notion-import.ts`
**Typeform:**
- Used for project import from form responses
- Auth: API key stored in SystemSettings per-import
- Client: `src/lib/typeform.ts` - plain fetch against `https://api.typeform.com`, no SDK
- Router: `src/server/routers/typeform-import.ts`
**WhatsApp (optional, configurable):**
- Two provider options: Meta WhatsApp Business Cloud API or Twilio WhatsApp
- Meta provider: `src/lib/whatsapp/meta-provider.ts`
- Twilio provider: `src/lib/whatsapp/twilio-provider.ts`
- Abstraction: `src/lib/whatsapp/index.ts` - `getWhatsAppProvider()` reads `whatsapp_provider` SystemSetting
- Auth: API keys stored in SystemSettings (`whatsapp_enabled`, `whatsapp_provider`, provider-specific keys)
- Used for: notification delivery (alternative to email)
## Data Storage
**Databases:**
- PostgreSQL 16 (primary datastore)
- Connection: `DATABASE_URL` env var (e.g., `postgresql://mopc:${password}@postgres:5432/mopc`)
- Client: Prisma 6 ORM, `src/lib/prisma.ts` singleton with connection pool (limit=20, timeout=10)
- Schema: `prisma/schema.prisma` (~95KB, ~100+ models)
- Migrations: `prisma/migrations/` directory, deployed via `prisma migrate deploy` on startup
- Test DB: `DATABASE_URL_TEST` env var (falls back to `DATABASE_URL` in test setup)
**File Storage:**
- MinIO (S3-compatible, self-hosted on VPS)
- Internal endpoint: `MINIO_ENDPOINT` env var (server-to-server)
- Public endpoint: `MINIO_PUBLIC_ENDPOINT` env var (browser-accessible pre-signed URLs)
- Auth: `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`
- Bucket: `MINIO_BUCKET` (default: `mopc-files`)
- Client: `src/lib/minio.ts` - lazy singleton via Proxy, `getMinioClient()`
- Access pattern: pre-signed URLs only (15-minute expiry by default), never direct public bucket
- Key structure: `{ProjectName}/{RoundName}/{timestamp}-{fileName}`
- File types stored: EXEC_SUMMARY, PRESENTATION, VIDEO, BUSINESS_PLAN, VIDEO_PITCH, SUPPORTING_DOC, OTHER
**Caching:**
- None (in-memory rate limiter in `src/lib/rate-limit.ts`, not a caching layer)
- Note: rate limiter is in-memory only — not suitable for multi-instance deployments
## Authentication & Identity
**Auth Provider: NextAuth v5 (self-hosted)**
- Implementation: `src/lib/auth.ts` + `src/lib/auth.config.ts`
- Adapter: `@auth/prisma-adapter` (stores sessions/tokens in PostgreSQL)
- Strategy: JWT sessions (24-hour default, configurable via `SESSION_MAX_AGE`)
- Session includes: `user.id`, `user.email`, `user.name`, `user.role`, `user.roles[]`, `user.mustSetPassword`
**Auth Providers:**
1. Email (Magic Links)
- NextAuth `EmailProvider` — magic link sent via Nodemailer
- Link expiry: 15 minutes (`MAGIC_LINK_EXPIRY` env var or default 900s)
- Custom send function: `sendMagicLinkEmail()` in `src/lib/email.ts`
2. Credentials (Password + Invite Token)
- Email/password with bcryptjs hashing (`src/lib/password.ts`)
- Invite token flow: one-time token clears on first use, sets `mustSetPassword: true`
- Failed login tracking: 5-attempt lockout, 15-minute duration (in-memory, not persistent)
- `mustSetPassword` flag forces redirect to `/set-password` before any other page
**Role System:**
- User model has `role` (primary, legacy scalar) and `roles` (array, multi-role)
- `userHasRole()` helper in `src/server/trpc.ts` checks `roles[]` with `[role]` fallback
- 8 roles: `SUPER_ADMIN`, `PROGRAM_ADMIN`, `JURY_MEMBER`, `MENTOR`, `OBSERVER`, `APPLICANT`, `AWARD_MASTER`, `AUDIENCE`
## Monitoring & Observability
**Error Tracking:**
- Not detected (no Sentry, Datadog, or similar third-party service)
**Logs:**
- Custom structured logger: `src/lib/logger.ts`
- Tagged format: `{timestamp} [LEVEL] [Tag] message data`
- Levels: debug, info, warn, error
- Default: `debug` in development, `warn` in production
- Configurable via `LOG_LEVEL` env var
- All output to `console.*` (stdout/stderr)
**Audit Logging:**
- All auth events and admin mutations logged to `AuditLog` DB table
- `src/server/utils/audit.ts` - `logAudit()` helper
- Events tracked: LOGIN_SUCCESS, LOGIN_FAILED, INVITATION_ACCEPTED, all CRUD operations on major entities
- Cron for cleanup: `src/app/api/cron/audit-cleanup/`
**Application Metrics:**
- Health check endpoint: `GET /api/health` (used by Docker healthcheck)
## CI/CD & Deployment
**Hosting:**
- Self-hosted VPS with Docker
- Nginx reverse proxy with SSL (external to compose stack)
- Domain: `monaco-opc.com`
**CI Pipeline:**
- Gitea Actions (self-hosted Gitea at `code.monaco-opc.com/MOPC/MOPC-Portal`)
- Pipeline builds Docker image and pushes to private container registry
- `REGISTRY_URL` env var configures the registry in `docker/docker-compose.yml`
**Docker Setup:**
- Production: `docker/docker-compose.yml` — app + postgres services
- Dev: `docker/docker-compose.dev.yml` — dev stack variant
- App image: standalone Next.js build (`output: 'standalone'`)
- Entrypoint: `docker/docker-entrypoint.sh` — migrations → generate → seed → start
## Environment Configuration
**Required env vars:**
- `DATABASE_URL` - PostgreSQL connection string
- `NEXTAUTH_URL` - Full URL of the app (e.g., `https://monaco-opc.com`)
- `NEXTAUTH_SECRET` / `AUTH_SECRET` - JWT signing secret
- `MINIO_ENDPOINT` - MinIO server URL (internal)
- `MINIO_ACCESS_KEY` - MinIO access key
- `MINIO_SECRET_KEY` - MinIO secret key
- `MINIO_BUCKET` - MinIO bucket name
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` - SMTP credentials
- `EMAIL_FROM` - Sender address
- `CRON_SECRET` - Shared secret for cron endpoint authentication
**Optional env vars:**
- `MINIO_PUBLIC_ENDPOINT` - Public-facing MinIO URL for pre-signed URLs
- `OPENAI_API_KEY` - OpenAI API key (also in SystemSettings)
- `OPENAI_MODEL` - Default AI model (default: `gpt-4o`)
- `OPENAI_BASE_URL` - Custom base URL for OpenAI-compatible providers
- `ANTHROPIC_API_KEY` - Anthropic Claude API key
- `POSTE_API_URL` - Poste.io mail server API URL
- `POSTE_ADMIN_EMAIL`, `POSTE_ADMIN_PASSWORD`, `POSTE_MAIL_DOMAIN` - Poste.io admin
- `SESSION_MAX_AGE` - JWT session duration in seconds (default: 86400)
- `MAX_FILE_SIZE` - Max upload size in bytes (default: 524288000 = 500MB)
- `LOG_LEVEL` - Logging verbosity (debug/info/warn/error)
- `MAGIC_LINK_EXPIRY` - Magic link lifetime in seconds (default: 900)
**Secrets location:**
- `.env` file at repo root (read by Docker Compose via `env_file: .env`)
- Runtime secrets also configurable via `SystemSettings` DB table (admin UI)
## Webhooks & Callbacks
**Incoming:**
- `/api/auth/[...nextauth]` - NextAuth callback routes (magic link verification, OAuth if added)
- No third-party webhook receivers detected
**Outgoing:**
- Configurable webhooks via `Webhook` DB model and `src/server/services/webhook-dispatcher.ts`
- Admin-managed via `src/server/routers/webhook.ts` (SUPER_ADMIN only)
- Signed with HMAC-SHA256 (`X-Webhook-Signature: sha256={sig}`)
- Events dispatched: `evaluation.submitted`, `evaluation.updated`, `project.created`, `project.statusChanged`, `round.activated`, `round.closed`, `assignment.created`, `assignment.completed`, `user.invited`, `user.activated`
- Retry logic: configurable max retries per webhook (010), retry via cron
## Real-Time
**Server-Sent Events (SSE):**
- Endpoint: `/api/sse/` - in-app notifications push
- Used for: real-time notification delivery to connected clients
**Live Voting Stream:**
- Endpoint: `/api/live-voting/stream/` - SSE stream for live ceremony voting cursor
- Service: `src/server/services/live-control.ts`
## Cron Jobs
All cron endpoints protected by `CRON_SECRET` header check:
- `GET /api/cron/reminders` - Evaluation reminders via `src/server/services/evaluation-reminders.ts`
- `GET /api/cron/digest` - Email digests via `src/server/services/email-digest.ts`
- `GET /api/cron/draft-cleanup` - Remove stale draft evaluations
- `GET /api/cron/audit-cleanup` - Purge old audit log entries
## Email
**SMTP Transport:**
- Provider: Poste.io (self-hosted mail server, port 587)
- Client: Nodemailer 7 via `src/lib/email.ts`
- Config priority: SystemSettings DB > env vars
- Transporter cached, rebuilt when config hash changes
- Error handling: email errors logged but never thrown (non-blocking)
---
*Integration audit: 2026-02-26*

141
.planning/codebase/STACK.md Normal file
View File

@@ -0,0 +1,141 @@
# Technology Stack
**Analysis Date:** 2026-02-26
## Languages
**Primary:**
- TypeScript 5.7 - All application code (strict mode, `noEmit`, `ES2022` target)
**Secondary:**
- CSS (Tailwind utility classes only, no custom CSS files)
## Runtime
**Environment:**
- Node.js >=20.0.0 (engines field in `package.json`)
**Package Manager:**
- npm (standard)
- Lockfile: `package-lock.json` present
## Frameworks
**Core:**
- Next.js 15.1 - App Router, standalone output, Turbopack dev mode
- React 19.0 - Server Components by default; `'use client'` only where needed
**API Layer:**
- tRPC 11 (RC build `11.0.0-rc.678`) - End-to-end typed RPC, superjson transformer
- `@trpc/server`, `@trpc/client`, `@trpc/react-query` - All at same version
**Data:**
- Prisma 6.19 - ORM and schema-first migrations; binary targets: `native`, `windows`, `linux-musl-openssl-3.0.x`
- `@prisma/client` 6.19 - Generated client with connection pool (limit=20, timeout=10)
**Auth:**
- NextAuth v5 (Beta 25) - JWT strategy, 24-hour sessions; Prisma adapter via `@auth/prisma-adapter`
**Forms & Validation:**
- Zod 3.24 - Input validation for all tRPC procedures
- React Hook Form 7.54 - Client-side form state; `@hookform/resolvers` for Zod integration
**UI Components:**
- shadcn/ui (configured via `components.json`) - Radix UI primitives styled with Tailwind
- Radix UI primitives: alert-dialog, avatar, checkbox, collapsible, dialog, dropdown-menu, label, popover, progress, radio-group, scroll-area, select, separator, slider, slot, switch, tabs, toggle, tooltip (all `^1.x` or `^2.x`)
- Tailwind CSS 4.1 - Utility-first, `@tailwindcss/postcss` plugin
- Lucide React 0.563 - Icon library (import-optimized via `next.config.ts`)
- Framer Motion 11 (`motion` package) - Animation
- Tremor 3.18 - Data visualization / chart components
- `@blocknote/react`, `@blocknote/core`, `@blocknote/shadcn` 0.46 - Rich text block editor
- next-themes 0.4 - Dark/light mode switching
- Sonner 2.0 - Toast notifications
**Testing:**
- Vitest 4.0 - Test runner, `fileParallelism: false`, `pool: 'forks'`
- `@playwright/test` 1.49 - E2E test runner
**Build/Dev:**
- Turbopack (built into Next.js 15) - Dev server via `next dev --turbopack`
- tsx 4.19 - Direct TypeScript execution for scripts and seeds
- ESLint 9.17 + `eslint-config-next` 15.1 - Linting
- Prettier 3.4 + `prettier-plugin-tailwindcss` 0.7 - Formatting
## Key Dependencies
**Critical:**
- `superjson` 2.2 - tRPC transformer; enables Date, Map, Set serialization over the wire
- `bcryptjs` 3.0 - Password hashing (no native bcrypt — pure JS for portability)
- `minio` 8.0 - S3-compatible object storage client
- `nodemailer` 7.0 - SMTP email delivery
- `openai` 6.16 - OpenAI SDK for AI features
- `@anthropic-ai/sdk` 0.78 - Anthropic Claude SDK; wrapped in adapter matching OpenAI interface
- `@notionhq/client` 2.3 - Notion API for project import
- `csv-parse` 6.1 - CSV import for candidatures seed
**Infrastructure:**
- `date-fns` 4.1 - Date manipulation
- `use-debounce` 10.0 - Input debouncing
- `@tanstack/react-query` 5.62 - Server state caching (used via tRPC)
- `@dnd-kit/core`, `@dnd-kit/sortable` - Drag-and-drop ordering UI
- `leaflet` 1.9 + `react-leaflet` 5.0 - Map rendering
- `mammoth` 1.11 - DOCX to HTML conversion for file content extraction
- `pdf-parse` 2.4, `unpdf` 1.4 - PDF text extraction
- `html2canvas` 1.4, `jspdf` 4.1, `jspdf-autotable` 5.0 - PDF export for reports
- `franc` 6.2 - Language detection for multilingual project content
- `papaparse` 5.4 - CSV parsing in browser
- `cmdk` 1.0 - Command palette component
- `react-easy-crop` 5.5 - Avatar image cropping
- `react-phone-number-input` 3.4 - International phone number input
- `react-day-picker` 9.13 - Date picker calendar
## Configuration
**TypeScript:**
- `src/tsconfig.json`: strict mode, `ES2022` target, path alias `@/*``./src/*`, bundler module resolution
- Config file: `tsconfig.json`
**Next.js:**
- Config file: `next.config.ts`
- `output: 'standalone'` for Docker deployment
- `typedRoutes: true` for compile-time route safety
- `serverExternalPackages: ['@prisma/client', 'minio']` — not bundled
**Tailwind:**
- Config file: `tailwind.config.ts`
- PostCSS via `postcss.config.mjs` + `@tailwindcss/postcss`
- Brand palette: Primary Red `#de0f1e`, Dark Blue `#053d57`, White `#fefefe`, Teal `#557f8c`
**Vitest:**
- Config file: `vitest.config.ts`
- `environment: 'node'`, `testTimeout: 30000`, sequential execution
- Path alias mirrors tsconfig
**Environment:**
- Required vars: `DATABASE_URL`, `NEXTAUTH_URL`, `NEXTAUTH_SECRET`, `MINIO_ENDPOINT`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`, `MINIO_BUCKET`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `EMAIL_FROM`, `OPENAI_API_KEY`, `CRON_SECRET`
- Optional vars: `MINIO_PUBLIC_ENDPOINT`, `OPENAI_MODEL` (default: `gpt-4o`), `OPENAI_BASE_URL`, `ANTHROPIC_API_KEY`, `SESSION_MAX_AGE` (default: 86400), `MAX_FILE_SIZE` (default: 524288000), `LOG_LEVEL`, `MAGIC_LINK_EXPIRY` (default: 900)
- All settings also configurable via `SystemSettings` DB table (DB takes priority over env vars)
**Build:**
- `npm run build``next build` → produces `.next/standalone/` output
- `npm run typecheck``tsc --noEmit` (no emit, type checking only)
## Platform Requirements
**Development:**
- Node.js >=20.0.0
- PostgreSQL 16 (via Docker or local)
- MinIO instance (optional in dev, defaults to `localhost:9000`)
**Production:**
- Docker (compose file: `docker/docker-compose.yml`)
- PostgreSQL 16 (`postgres:16-alpine` image) in Docker network
- Next.js app runs as standalone Node.js server on port 7600
- MinIO and Poste.io are external pre-existing services on VPS
- Nginx reverse proxy with SSL (external, not in compose)
- CI/CD: Gitea Actions (image pushed to container registry, `pull_policy: always`)
- App entrypoint (`docker/docker-entrypoint.sh`): runs `prisma migrate deploy``prisma generate` → auto-seeds if DB empty → `node server.js`
---
*Stack analysis: 2026-02-26*

View File

@@ -0,0 +1,327 @@
# Codebase Structure
**Analysis Date:** 2026-02-26
## Directory Layout
```
MOPC/
├── prisma/ # Database schema and migrations
│ ├── schema.prisma # Single source of truth for all models
│ ├── seed.ts # Seed script (imports from docs/CSV files)
│ └── migrations/ # Prisma migration history (auto-generated)
├── src/
│ ├── app/ # Next.js App Router — all routes
│ │ ├── (admin)/ # Admin dashboard route group (SUPER_ADMIN, PROGRAM_ADMIN)
│ │ ├── (jury)/ # Jury evaluation route group (JURY_MEMBER)
│ │ ├── (applicant)/ # Applicant dashboard route group (APPLICANT)
│ │ ├── (mentor)/ # Mentor workspace route group (MENTOR)
│ │ ├── (observer)/ # Observer read-only route group (OBSERVER)
│ │ ├── (auth)/ # Public auth pages (login, verify, onboarding)
│ │ ├── (public)/ # Fully public pages (apply, vote, live-scores)
│ │ ├── (settings)/ # User settings (profile)
│ │ └── api/ # API routes (tRPC, auth, cron, SSE, files, health)
│ ├── components/ # React components organized by domain
│ │ ├── admin/ # Admin-specific components
│ │ ├── jury/ # Jury-specific components
│ │ ├── applicant/ # Applicant-specific components
│ │ ├── mentor/ # Mentor-specific components
│ │ ├── observer/ # Observer-specific components
│ │ ├── public/ # Public-facing components
│ │ ├── forms/ # Shared form components (apply wizard, COI dialog)
│ │ ├── charts/ # Chart/visualization components
│ │ ├── dashboard/ # Dashboard widgets
│ │ ├── layouts/ # Navigation layouts per role (sidebar, nav bars)
│ │ ├── shared/ # Reusable cross-domain components
│ │ └── ui/ # shadcn/ui primitives
│ ├── server/ # Server-only code
│ │ ├── routers/ # tRPC domain routers (44+ files)
│ │ │ └── _app.ts # Root router composing all domains
│ │ ├── services/ # Business logic services
│ │ ├── utils/ # Server utilities (audit, ai-usage, image-upload)
│ │ ├── trpc.ts # tRPC init, middleware, procedure types
│ │ └── context.ts # tRPC context factory (session + prisma + IP)
│ ├── lib/ # Shared libraries (client + server)
│ │ ├── trpc/ # tRPC client (client.ts), server caller (server.ts)
│ │ ├── storage/ # Storage provider abstraction (S3/local)
│ │ ├── whatsapp/ # WhatsApp notification client
│ │ ├── auth.ts # NextAuth full configuration
│ │ ├── auth.config.ts # Edge-compatible auth config (middleware)
│ │ ├── auth-redirect.ts # requireRole() server helper
│ │ ├── prisma.ts # Prisma singleton with connection pooling
│ │ ├── logger.ts # Structured logger (tagged, level-aware)
│ │ ├── email.ts # Nodemailer email sender
│ │ ├── minio.ts # MinIO client initialization
│ │ ├── openai.ts # OpenAI client initialization
│ │ ├── rate-limit.ts # In-memory rate limiter
│ │ ├── feature-flags.ts # DB-backed feature flags
│ │ ├── round-config.ts # Round config helper utilities
│ │ ├── utils.ts # General utilities (cn, formatters)
│ │ └── [others] # countries, pdf-generator, typeform, notion, etc.
│ ├── types/ # TypeScript type definitions
│ │ ├── competition.ts # Composite types for Competition/Round domain
│ │ ├── competition-configs.ts # Per-RoundType Zod schemas + inferred types
│ │ └── wizard-config.ts # Application wizard configuration types
│ ├── hooks/ # Custom React hooks
│ │ ├── use-debounce.ts
│ │ ├── use-live-voting-sse.ts # SSE subscription for live voting
│ │ └── use-stage-live-sse.ts # SSE subscription for live stage
│ └── contexts/ # React contexts
│ └── edition-context.tsx # Edition/Program selector context
├── tests/ # Test files (Vitest)
│ ├── setup.ts # Test setup (prisma client, helpers)
│ ├── helpers.ts # Test factories (createTestUser, createTestCompetition, etc.)
│ └── unit/ # Unit test files
├── docs/ # Internal documentation and architecture notes
├── docker/ # Docker Compose configs and Nginx config
├── public/ # Static assets (fonts, images, maps)
├── scripts/ # Utility scripts
├── middleware.ts # Next.js edge middleware (auth check)
├── next.config.ts # Next.js config (standalone output, legacy redirects)
└── prisma/ # (see above)
```
## Directory Purposes
**`src/app/(admin)/admin/`:**
- Purpose: All admin pages behind SUPER_ADMIN/PROGRAM_ADMIN role gate
- Contains: Competition management, round config, project management, jury groups, members, programs, reports, audit, awards, settings, messages, mentors, partners, learning
- Key files: `layout.tsx` (role guard + edition selector), `admin/page.tsx` (dashboard), `admin/rounds/[roundId]/page.tsx` (round detail — largest page)
**`src/app/(jury)/jury/`:**
- Purpose: Jury evaluation interface behind JURY_MEMBER role gate
- Contains: Competitions list, round overview, project list, evaluate page, deliberation, live voting, learning resources, awards
- Key files: `layout.tsx` (role guard + onboarding check), `competitions/[roundId]/projects/[projectId]/evaluate/page.tsx` (evaluation form)
**`src/app/(applicant)/applicant/`:**
- Purpose: Applicant dashboard behind APPLICANT role gate
- Contains: Competition progress, documents, evaluations received, mentor chat, resources, team
- Key files: `layout.tsx`, `applicant/page.tsx`
**`src/app/(mentor)/mentor/`:**
- Purpose: Mentor workspace behind MENTOR role gate
- Contains: Project list, workspace per project, resources
- Key files: `layout.tsx`, `mentor/workspace/[projectId]/page.tsx`
**`src/app/(observer)/observer/`:**
- Purpose: Read-only view behind OBSERVER role gate
- Contains: Projects, reports
- Key files: `layout.tsx`
**`src/app/(public)/`:**
- Purpose: No-auth-required pages
- Contains: Application form (`apply/[slug]`), edition application (`apply/edition/[programSlug]`), live scores display (`live-scores/[sessionId]`), audience vote (`vote/[sessionId]`), submission status (`my-submission/[id]`), email change password
- Key files: `apply/[slug]/page.tsx` (application wizard)
**`src/app/(auth)/`:**
- Purpose: Auth flow pages
- Contains: Login, verify (magic link), verify-email, accept-invite, onboarding, set-password, error
- Key files: `login/page.tsx`, `onboarding/page.tsx`, `accept-invite/page.tsx`
**`src/app/api/`:**
- Purpose: Next.js route handlers for non-tRPC API
- Contains:
- `trpc/[trpc]/` — tRPC HTTP adapter (GET + POST)
- `auth/[...nextauth]/` — NextAuth handler
- `cron/` — Cron job endpoints (audit-cleanup, digest, draft-cleanup, reminders)
- `live-voting/stream/` — SSE stream for live voting
- `files/bulk-download/` — Bulk file download handler
- `storage/local/` — Local dev storage handler
- `health/` — DB health check endpoint
**`src/server/routers/`:**
- Purpose: tRPC domain routers, one file per domain
- Contains: 44+ router files assembled in `_app.ts`
- Key files: `competition.ts`, `round.ts`, `roundEngine.ts`, `evaluation.ts`, `filtering.ts`, `deliberation.ts`, `resultLock.ts`, `roundAssignment.ts`, `assignment.ts`, `project.ts`, `user.ts`, `program.ts`
**`src/server/services/`:**
- Purpose: All business logic — state machines, AI integrations, external service calls
- Contains:
- `round-engine.ts` — Round and project state machine
- `deliberation.ts` — Deliberation session lifecycle (DELIB_OPEN → VOTING → TALLYING → DELIB_LOCKED)
- `round-assignment.ts` — Jury assignment generation with policy enforcement
- `smart-assignment.ts` — Scoring algorithm (tag overlap, bio match, workload, geo-diversity, COI, availability)
- `submission-manager.ts` — Submission window lifecycle and file requirement enforcement
- `result-lock.ts` — Immutable result locking with snapshot
- `live-control.ts` — Live ceremony cursor management
- `competition-context.ts` — Cross-cutting context resolver
- `ai-filtering.ts`, `ai-assignment.ts`, `ai-evaluation-summary.ts`, `ai-tagging.ts`, `ai-award-eligibility.ts`, `ai-shortlist.ts` — AI feature services
- `anonymization.ts` — PII stripping before AI calls
- `notification.ts`, `in-app-notification.ts`, `evaluation-reminders.ts`, `email-digest.ts` — Notification services
- `assignment-policy.ts`, `assignment-intent.ts` — Policy governance for assignments
- `mentor-matching.ts`, `mentor-workspace.ts` — Mentor domain services
**`src/components/admin/round/`:**
- Purpose: Components for the round detail page (the most complex admin page)
- Key files: `filtering-dashboard.tsx`, `project-states-table.tsx`
**`src/components/admin/rounds/config/`:**
- Purpose: Per-RoundType config form sections
- Contains: Config UI for each round type (`intake-config.tsx`, `evaluation-config.tsx`, etc.)
**`src/components/shared/`:**
- Purpose: Domain-agnostic reusable components
- Contains: `page-header.tsx`, `status-badge.tsx`, `file-upload.tsx`, `file-viewer.tsx`, `pagination.tsx`, `notification-bell.tsx`, `edition-selector.tsx`, `empty-state.tsx`, `loading-spinner.tsx`, and others
**`src/components/ui/`:**
- Purpose: shadcn/ui primitive components (never modified directly)
- Contains: `button.tsx`, `card.tsx`, `dialog.tsx`, `form.tsx`, `select.tsx`, `table.tsx`, etc.
**`src/components/layouts/`:**
- Purpose: Role-specific navigation shells
- Contains: `admin-sidebar.tsx`, `jury-nav.tsx`, `mentor-nav.tsx`, `observer-nav.tsx`, `applicant-nav.tsx`, `role-nav.tsx`, `admin-edition-wrapper.tsx`
**`src/lib/trpc/`:**
- Purpose: tRPC client configuration
- Contains:
- `client.ts``createTRPCReact<AppRouter>()` export (client components use `import { trpc } from '@/lib/trpc/client'`)
- `server.ts` — Server-side caller for Server Components
- `index.ts` — Provider setup (TRPCProvider + QueryClientProvider)
**`src/types/`:**
- Purpose: Shared TypeScript types not generated by Prisma
- Contains:
- `competition.ts` — Composite types with nested relations (e.g., `CompetitionWithRounds`, `RoundWithRelations`)
- `competition-configs.ts` — Per-RoundType Zod config schemas and inferred TypeScript types
- `wizard-config.ts` — Application wizard step configuration types
## Key File Locations
**Entry Points:**
- `middleware.ts` — Edge middleware (auth check before every request)
- `src/app/api/trpc/[trpc]/route.ts` — tRPC HTTP handler
- `src/app/api/auth/[...nextauth]/route.ts` — Auth handler
- `src/server/routers/_app.ts` — Root tRPC router
**Configuration:**
- `prisma/schema.prisma` — Database schema
- `next.config.ts` — Next.js configuration + legacy route redirects
- `src/lib/auth.config.ts` — Edge-compatible NextAuth config + Session type augmentations
- `src/lib/auth.ts` — Full NextAuth configuration with providers
- `src/server/trpc.ts` — tRPC initialization and all procedure type definitions
- `src/server/context.ts` — tRPC context (session, prisma, ip, userAgent)
- `tsconfig.json` — TypeScript strict mode config with `@/` path alias
**Core Logic:**
- `src/server/services/round-engine.ts` — Round state machine
- `src/server/services/deliberation.ts` — Deliberation state machine
- `src/server/services/round-assignment.ts` — Assignment generation
- `src/server/services/smart-assignment.ts` — Scoring algorithm
- `src/server/services/competition-context.ts` — Context resolver
- `src/types/competition-configs.ts` — Zod schemas for round configs
- `src/server/utils/audit.ts` — Audit logging utility
**Testing:**
- `tests/setup.ts` — Vitest setup with Prisma client
- `tests/helpers.ts` — Test data factories
- `tests/unit/` — Unit test files
- `vitest.config.ts` — Vitest configuration
## Naming Conventions
**Files:**
- kebab-case for all source files: `round-engine.ts`, `admin-sidebar.tsx`, `use-live-voting-sse.ts`
- Router files match domain name: `competition.ts`, `roundEngine.ts` (camelCase variants also seen for compound names)
- Service files use kebab-case: `round-assignment.ts`, `ai-filtering.ts`, `result-lock.ts`
**Directories:**
- kebab-case for all directories: `admin/`, `round-assignment/`, `apply-steps/`
- Route group segments use parentheses per Next.js convention: `(admin)`, `(jury)`, `(public)`
- Dynamic segments use square brackets: `[roundId]`, `[projectId]`, `[trpc]`
**Components:**
- PascalCase exports: `AdminSidebar`, `FilteringDashboard`, `JurorProgressDashboard`
- Component files kebab-case: `admin-sidebar.tsx`, `filtering-dashboard.tsx`
**Types:**
- `type` keyword preferred over `interface` (TypeScript strict mode project)
- Prisma-generated types used directly where possible; composite types in `src/types/`
- Zod schemas named `[Domain]ConfigSchema`; inferred types named `[Domain]Config`
## Where to Add New Code
**New tRPC Domain Router:**
- Router file: `src/server/routers/[domain].ts`
- Register in: `src/server/routers/_app.ts`
- Follow pattern: import from `../trpc`, use typed procedure (`adminProcedure`, `juryProcedure`, etc.), call `logAudit()` on mutations
**New Business Logic Service:**
- Implementation: `src/server/services/[domain].ts`
- Accept `prisma: PrismaClient | any` as parameter (for transaction compatibility)
- Return typed result objects `{ success: boolean, errors?: string[] }` for state machine functions
- Call `logAudit()` for all state changes
- Never import tRPC types — services are tRPC-agnostic
**New Admin Page:**
- Page file: `src/app/(admin)/admin/[section]/page.tsx`
- Layout guard is inherited from `src/app/(admin)/layout.tsx` — no additional role check needed
- Use `export const dynamic = 'force-dynamic'` for data-fetching pages
- Fetch data server-side in page component using `auth()` + `prisma` directly, or use client component with tRPC hooks
**New Jury Page:**
- Page file: `src/app/(jury)/jury/[section]/page.tsx`
- Layout guard in `src/app/(jury)/layout.tsx` checks `JURY_MEMBER` role and onboarding completion
**New Public Page:**
- Page file: `src/app/(public)/[section]/page.tsx`
- No auth guard — fully public
**New Component (domain-specific):**
- Admin component: `src/components/admin/[subdomain]/[component-name].tsx`
- Jury component: `src/components/jury/[component-name].tsx`
- Shared component: `src/components/shared/[component-name].tsx`
**New shadcn/ui Primitive:**
- Location: `src/components/ui/[component].tsx` (generated via `npx shadcn@latest add [component]`)
**New Round Config Schema:**
- Add Zod schema to `src/types/competition-configs.ts` following existing pattern
- Add to `RoundConfigMap` discriminated union
- Update `validateRoundConfig()` and `safeValidateRoundConfig()` switch statements
- Add config UI component to `src/components/admin/rounds/config/`
**Utilities:**
- Shared server+client helpers: `src/lib/utils.ts` or new `src/lib/[utility].ts`
- Server-only utilities: `src/server/utils/[utility].ts`
- Custom React hooks: `src/hooks/use-[name].ts`
## Special Directories
**`prisma/migrations/`:**
- Purpose: Auto-generated SQL migration files
- Generated: Yes (by `prisma migrate dev`)
- Committed: Yes
**`.next/`:**
- Purpose: Next.js build output cache
- Generated: Yes
- Committed: No
**`docs/`:**
- Purpose: Internal architecture notes, redesign plans, GDPR documentation, feature plans
- Generated: No
- Committed: Yes
**`prototypes/`:**
- Purpose: HTML/CSS prototype mockups for admin redesign
- Generated: No
- Committed: Yes
**`docker/`:**
- Purpose: Docker Compose files for production and dev stacks; Nginx reverse proxy config
- Generated: No
- Committed: Yes
**`.planning/`:**
- Purpose: GSD planning documents (codebase analysis, implementation plans)
- Generated: By GSD tooling
- Committed: No (gitignored)
**`.serena/`:**
- Purpose: Serena MCP project cache and memories
- Generated: Yes
- Committed: No
---
*Structure analysis: 2026-02-26*

View File

@@ -0,0 +1,289 @@
# Testing Patterns
**Analysis Date:** 2026-02-26
## Test Framework
**Runner:**
- Vitest 4.0.18
- Config: `vitest.config.ts`
- Environment: `node` (no jsdom — tests are server-side only)
- Globals: `true``describe`, `it`, `expect` available without imports (but explicit imports are used in practice)
- `fileParallelism: false` — test files run sequentially
- `pool: 'forks'` — each test file in isolated subprocess
**Assertion Library:**
- Vitest built-in (`expect`)
**Path Aliases:**
- `@/` resolves to `./src/` in test files (configured in `vitest.config.ts` via `resolve.alias`)
**Run Commands:**
```bash
npx vitest # Watch mode (all tests)
npx vitest run # Run all tests once
npx vitest run tests/unit/assignment-policy.test.ts # Single file
npx vitest run -t 'test name' # Single test by name/pattern
```
**Timeout:**
- Default `testTimeout: 30000` (30 seconds) — allows for database operations
## Test File Organization
**Location:**
- All tests live under `tests/` (not co-located with source files)
- `tests/unit/` — pure-logic tests, no database
- `tests/integration/` — database-backed tests using real Prisma client (currently `assignment-policy.test.ts` in both directories)
- Setup: `tests/setup.ts`
- Factories: `tests/helpers.ts`
**Naming:**
- `{domain}.test.ts` — matches domain name: `assignment-policy.test.ts`, `round-engine.test.ts`
- No `.spec.ts` files — exclusively `.test.ts`
**Structure:**
```
tests/
├── setup.ts # Global test context, prisma client, createTestContext()
├── helpers.ts # Test data factories (createTestUser, createTestRound, etc.)
├── unit/
│ └── assignment-policy.test.ts # Pure logic, no DB
└── integration/
└── assignment-policy.test.ts # DB-backed tests
```
## Test Structure
**Suite Organization:**
```typescript
import { describe, it, expect } from 'vitest'
import type { CapMode } from '@prisma/client'
import { resolveEffectiveCap } from '@/server/services/assignment-policy'
// ============================================================================
// Section Title with box dividers
// ============================================================================
describe('functionName', () => {
it('returns expected value when condition', () => {
const result = functionName(input)
expect(result.value).toBe(expected)
expect(result.source).toBe('system')
})
describe('nested scenario group', () => {
it('specific behavior', () => { ... })
})
})
```
**Helper/Stub Pattern:**
```typescript
// Builder functions at top of file construct minimal test objects
function baseMemberContext(overrides: Partial<MemberContext> = {}): MemberContext {
return {
competition: {} as any,
round: {} as any,
member: { id: 'member-1', role: 'MEMBER', ... } as any,
currentAssignmentCount: 0,
...overrides,
}
}
function withJuryGroup(ctx: MemberContext, groupOverrides = {}): MemberContext {
return { ...ctx, juryGroup: { id: 'jg-1', defaultMaxAssignments: 20, ...groupOverrides } as any }
}
```
**Patterns:**
- Build minimal context objects inline — no heavy mocking frameworks
- Use spread + override: `{ ...ctx, member: { ...ctx.member, maxAssignmentsOverride: 10 } }`
- Assert on both value AND metadata: `expect(result.value).toBe(25)` + `expect(result.source).toBe('jury_group')`
- Tests are descriptive: `'admin per-member override takes precedence over group default'`
## Mocking
**Framework:** None — unit tests avoid mocking entirely by testing pure functions.
**Approach:**
- Unit tests pass plain JavaScript objects (`{} as any`) for unused dependencies
- No `vi.mock()`, `vi.fn()`, or `vi.spyOn()` observed in current test files
- Prisma is a real client connected to a test database (see integration tests)
- tRPC context is constructed via `createTestContext(user)` — a plain object, not mocked
**What to Mock:**
- External I/O (email, MinIO, OpenAI) — not currently tested; fire-and-forget pattern used
- Anything not relevant to the assertion being made (`{} as any` for unused context fields)
**What NOT to Mock:**
- Business logic functions under test
- Prisma in integration tests — use real database with `DATABASE_URL_TEST`
- The `createTestContext` / `createCaller` — these are lightweight stubs, not mocks
## Fixtures and Factories
**Test Data (from `tests/helpers.ts`):**
```typescript
// uid() creates unique prefixed IDs to avoid collisions
export function uid(prefix = 'test'): string {
return `${prefix}-${randomUUID().slice(0, 12)}`
}
// Factories accept overrides for specific test scenarios
export async function createTestUser(
role: UserRole = 'JURY_MEMBER',
overrides: Partial<{ email: string; name: string; ... }> = {}
) {
const id = uid('user')
return prisma.user.create({
data: {
id,
email: overrides.email ?? `${id}@test.local`,
role,
...
}
})
}
```
**Available Factories:**
- `createTestUser(role, overrides)` — creates User in database
- `createTestProgram(overrides)` — creates Program
- `createTestCompetition(programId, overrides)` — creates Competition
- `createTestRound(competitionId, overrides)` — creates Round (default: EVALUATION, ROUND_ACTIVE)
- `createTestProject(programId, overrides)` — creates Project
- `createTestProjectRoundState(projectId, roundId, overrides)` — creates ProjectRoundState
- `createTestAssignment(userId, projectId, roundId, overrides)` — creates Assignment
- `createTestEvaluation(assignmentId, formId, overrides)` — creates Evaluation
- `createTestEvaluationForm(roundId, criteria)` — creates EvaluationForm
- `createTestFilteringRule(roundId, overrides)` — creates FilteringRule
- `createTestCOI(assignmentId, userId, projectId, hasConflict)` — creates ConflictOfInterest
- `createTestCohort(roundId, overrides)` — creates Cohort
- `createTestCohortProject(cohortId, projectId)` — creates CohortProject
**Location:**
- Factories in `tests/helpers.ts`
- Shared Prisma client in `tests/setup.ts`
## Coverage
**Requirements:** None enforced — no coverage thresholds configured.
**View Coverage:**
```bash
npx vitest run --coverage # Requires @vitest/coverage-v8 (not currently installed)
```
## Test Types
**Unit Tests (`tests/unit/`):**
- Scope: Pure business logic functions with no I/O
- Approach: Construct in-memory objects, call function, assert return value
- Examples: `assignment-policy.test.ts` tests `resolveEffectiveCap`, `evaluateAssignmentPolicy`
- No database, no HTTP, no file system
**Integration Tests (`tests/integration/`):**
- Scope: tRPC router procedures via `createCaller`
- Approach: Create real database records → call procedure → assert DB state or return value → cleanup
- Uses `DATABASE_URL_TEST` (or falls back to `DATABASE_URL`)
- Sequential execution (`fileParallelism: false`) to avoid DB conflicts
**E2E Tests:**
- Playwright configured (`@playwright/test` installed, `npm run test:e2e` script)
- No test files found yet — framework is available but not implemented
## Common Patterns
**Integration Test Pattern (calling tRPC procedures):**
```typescript
import { describe, it, expect, afterAll } from 'vitest'
import { prisma } from '../setup'
import { createTestUser, createTestProgram, createTestCompetition, cleanupTestData, uid } from '../helpers'
import { roundRouter } from '@/server/routers/round'
describe('round procedures', () => {
let programId: string
let adminUser: Awaited<ReturnType<typeof createTestUser>>
beforeAll(async () => {
adminUser = await createTestUser('SUPER_ADMIN')
const program = await createTestProgram()
programId = program.id
})
it('activates a round', async () => {
const competition = await createTestCompetition(programId)
const caller = createCaller(roundRouter, adminUser)
const result = await caller.activate({ roundId: round.id })
expect(result.status).toBe('ROUND_ACTIVE')
})
afterAll(async () => {
await cleanupTestData(programId, [adminUser.id])
})
})
```
**Unit Test Pattern (pure logic):**
```typescript
import { describe, it, expect } from 'vitest'
import { resolveEffectiveCap } from '@/server/services/assignment-policy'
describe('resolveEffectiveCap', () => {
it('returns system default when no jury group', () => {
const ctx = baseMemberContext() // local builder function
const result = resolveEffectiveCap(ctx)
expect(result.value).toBe(SYSTEM_DEFAULT_CAP)
expect(result.source).toBe('system')
})
})
```
**Async Testing:**
```typescript
it('creates evaluation', async () => {
const result = await caller.evaluation.start({ assignmentId: assignment.id })
expect(result.status).toBe('DRAFT')
})
```
**Error Testing:**
```typescript
it('throws FORBIDDEN when accessing others evaluation', async () => {
const otherUser = await createTestUser('JURY_MEMBER')
const caller = createCaller(evaluationRouter, otherUser)
await expect(
caller.get({ assignmentId: assignment.id })
).rejects.toThrow('FORBIDDEN')
})
```
**Cleanup (afterAll):**
```typescript
afterAll(async () => {
// Pass programId to cascade-delete competition data, plus explicit user IDs
await cleanupTestData(programId, [adminUser.id, jurorUser.id])
})
```
## Test Infrastructure Details
**`createTestContext(user)`** in `tests/setup.ts`:
- Builds a fake tRPC context matching `{ session: { user, expires }, prisma, ip, userAgent }`
- `prisma` is the shared test client
- Used internally by `createCaller`
**`createCaller(routerModule, user)`** in `tests/setup.ts`:
- Shorthand: `const caller = createCaller(evaluationRouter, adminUser)`
- Returns type-safe caller — procedures called as `await caller.procedureName(input)`
- Import the router module directly, not `appRouter`
**Database Isolation:**
- Tests share one database — isolation is by unique IDs (via `uid()`)
- `cleanupTestData(programId)` does ordered deletion respecting FK constraints
- Always call `cleanupTestData` in `afterAll`, never skip
---
*Testing analysis: 2026-02-26*