From 8cc86bae20e210c0b0ef14c437d34eba6e1f682c Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 26 Feb 2026 23:14:08 +0100 Subject: [PATCH] docs: map existing codebase Co-Authored-By: Claude Opus 4.6 --- .planning/codebase/ARCHITECTURE.md | 193 +++++++++++++++++ .planning/codebase/CONCERNS.md | 217 +++++++++++++++++++ .planning/codebase/CONVENTIONS.md | 267 +++++++++++++++++++++++ .planning/codebase/INTEGRATIONS.md | 213 +++++++++++++++++++ .planning/codebase/STACK.md | 141 +++++++++++++ .planning/codebase/STRUCTURE.md | 327 +++++++++++++++++++++++++++++ .planning/codebase/TESTING.md | 289 +++++++++++++++++++++++++ 7 files changed, 1647 insertions(+) create mode 100644 .planning/codebase/ARCHITECTURE.md create mode 100644 .planning/codebase/CONCERNS.md create mode 100644 .planning/codebase/CONVENTIONS.md create mode 100644 .planning/codebase/INTEGRATIONS.md create mode 100644 .planning/codebase/STACK.md create mode 100644 .planning/codebase/STRUCTURE.md create mode 100644 .planning/codebase/TESTING.md diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 0000000..fa9763e --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -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* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 0000000..70f8fb0 --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -0,0 +1,217 @@ +# Codebase Concerns + +**Analysis Date:** 2026-02-26 + +--- + +## Tech Debt + +**Award Router is a Stub:** +- Issue: Entire award router is commented out and non-functional. All award procedures were deleted when the Pipeline/Track models were removed and have not been reimplemented. +- Files: `src/server/routers/award.ts` +- Impact: Any UI that references award management procedures will fail at runtime. The `SpecialAward` model still exists in the schema but has no tRPC exposure via this router. +- Fix approach: Reimplement the router procedures against the current `SpecialAward` → `Competition` FK relationship. See the TODO comment at line 9 for the list of procedures to reimplement. + +**Deliberation Page Has Incomplete Implementation:** +- Issue: The jury-facing deliberation page has two hardcoded stub values that break actual vote submission. + - `juryMemberId: ''` — submitted vote will have an empty juror ID. + - `const hasVoted = false` — the "already voted" guard never fires, allowing duplicate vote submissions. +- Files: `src/app/(jury)/jury/competitions/deliberation/[sessionId]/page.tsx` lines 34 and 66 +- Impact: Jury members can submit blank/duplicate votes in deliberation sessions. The submitted vote will be associated with an empty string `juryMemberId`, which will likely fail at the Prisma level or silently create bad data. +- Fix approach: Derive `juryMemberId` from `session.participants` by matching `ctx.user.id`. Derive `hasVoted` by checking if a `DeliberationVote` with the current user's jury member ID already exists in `session.votes`. + +**Audit Middleware is a No-Op:** +- Issue: The `withAuditLog` middleware in `src/server/trpc.ts` (lines 99–114) identifies mutation calls by path pattern but does nothing with them — the body contains only a comment: `// We'll implement this in the audit service`. +- Files: `src/server/trpc.ts` +- Impact: Automatic centralised audit logging for all admin mutations does not occur through this middleware. Manual `logAudit()` / `DecisionAuditLog.create()` calls are present in many routers but coverage is inconsistent. +- Fix approach: Implement the middleware body to call `logAudit()` with `ctx.user.id`, `path`, and serialized input/output. This provides a safety net for any procedure that doesn't call `logAudit` manually. + +**In-Memory Rate Limiter Not Suitable for Multi-Instance Deployment:** +- Issue: `src/lib/rate-limit.ts` uses a module-level `Map` for rate limit state. This works in a single process but does not share state across multiple Node.js instances or after a process restart. +- Files: `src/lib/rate-limit.ts` +- Impact: Rate limits can be trivially bypassed by hitting different server instances. Auth brute-force protection (5-attempt lockout) also uses this same in-memory store (`src/lib/auth.ts` line 12). +- Fix approach: Replace with Redis-based rate limiting (e.g., `@upstash/ratelimit` or `ioredis`). The comment at line 5 already acknowledges this: "For production with multiple instances, replace with Redis-based solution." + +**`configJson` Widely Cast Without Validation:** +- Issue: `configJson` (a Prisma `Json` field) is cast directly to `Record` 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` casts with this utility at query boundaries. + +--- + +## Known Bugs + +**Tag Rename Performs N+1 Database Writes:** +- Symptoms: Renaming a tag iterates over every user and every project that has the tag, issuing one `UPDATE` per record. +- Files: `src/server/routers/tag.ts` lines 361–389 and 421–438 +- Trigger: Admin renames any tag that is widely used. +- Workaround: None. Will time out for large datasets. +- Fix approach: Use a raw SQL `UPDATE ... SET tags = array_replace(tags, $old, $new)` or a Prisma `$executeRaw` to perform the rename in a single query. + +**Jury Deliberation Vote: `juryMemberId` Is Hardcoded Empty String:** +- Symptoms: Votes submitted via the jury deliberation page will have `juryMemberId: ''`. +- Files: `src/app/(jury)/jury/competitions/deliberation/[sessionId]/page.tsx` line 34 +- Trigger: Any jury member visits a deliberation session and submits a vote. +- Workaround: None — the vote will silently pass or fail depending on Prisma validation. + +--- + +## Security Considerations + +**IP Header Spoofing on Rate Limiter:** +- Risk: All rate limiters extract the client IP from `x-forwarded-for` without validating that the header originates from a trusted proxy. A client can set this header to any value, bypassing per-IP rate limits. +- Files: `src/app/api/trpc/[trpc]/route.ts` lines 13–18, `src/app/api/auth/[...nextauth]/route.ts` lines 7–10, `src/app/api/email/change-password/route.ts` line 36, `src/app/api/email/verify-credentials/route.ts` line 20, `src/server/context.ts` line 13. +- Current mitigation: Nginx passes `X-Forwarded-For` from upstream; in single-proxy deployment this reduces (but does not eliminate) risk. +- Recommendations: Pin IP extraction to `req.headers.get('x-real-ip')` set by Nginx only, or validate the forwarded-for chain against a trusted proxy list. + +**Cron Secret Compared with `!==` (Non–Timing-Safe):** +- Risk: String equality check `cronSecret !== process.env.CRON_SECRET` is vulnerable to timing side-channel attacks on the secret value. +- Files: `src/app/api/cron/audit-cleanup/route.ts`, `src/app/api/cron/digest/route.ts`, `src/app/api/cron/draft-cleanup/route.ts`, `src/app/api/cron/reminders/route.ts` — all at line 8. +- Current mitigation: Cron endpoints are not user-facing and rate limited at Nginx level. +- Recommendations: Replace with `timingSafeEqual(Buffer.from(cronSecret), Buffer.from(process.env.CRON_SECRET))` — the same approach already used in `src/lib/storage/local-provider.ts` line 75. + +**No Content-Security-Policy Header:** +- Risk: No CSP is set in `next.config.ts` or via middleware headers. If an XSS vector exists, there is no second line of defence to limit script execution. +- Files: `next.config.ts` (missing `headers()` function), `docker/nginx/mopc-platform.conf` (missing CSP directive). +- Current mitigation: Nginx sets `X-Frame-Options`, `X-Content-Type-Options`, and `X-XSS-Protection`, but these are legacy headers. No HSTS header is configured in Nginx either (only set post-certbot). +- Recommendations: Add `Content-Security-Policy` and `Strict-Transport-Security` via the Next.js `headers()` config function. + +**MinIO Fallback Credentials Hardcoded:** +- Risk: When `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` are not set in non-production environments, the client defaults to `minioadmin`/`minioadmin`. +- Files: `src/lib/minio.ts` lines 28–29 +- Current mitigation: Production throws an error if credentials are missing (line 20–22). The fallback only applies in development. +- Recommendations: Remove the hardcoded fallback entirely; require credentials in all environments to prevent accidental exposure of a non-production MinIO instance. + +**`NEXT_PUBLIC_MINIO_ENDPOINT` Undefined in Production:** +- Risk: Two admin learning pages read `process.env.NEXT_PUBLIC_MINIO_ENDPOINT` at runtime. This variable is not defined in `docker-compose.yml` and has no `NEXT_PUBLIC_` entry. Next.js requires public env vars to be present at build time; at runtime this will always resolve to the fallback `http://localhost:9000`, making file previews broken in production. +- Files: `src/app/(admin)/admin/learning/new/page.tsx` line 112, `src/app/(admin)/admin/learning/[id]/page.tsx` line 165. +- Fix approach: Add `NEXT_PUBLIC_MINIO_ENDPOINT` to `docker-compose.yml` env section, or use the server-side `MINIO_PUBLIC_ENDPOINT` via a tRPC query rather than client-side env. + +--- + +## Performance Bottlenecks + +**Unbounded `findMany` Queries in Analytics Router:** +- Problem: `src/server/routers/analytics.ts` contains approximately 25 `findMany` calls with no `take` limit. With large competitions (hundreds of projects, thousands of evaluations) these will perform full table scans filtered only by `roundId` or `competitionId`. +- Files: `src/server/routers/analytics.ts` — queries at lines 38, 80, 265, 421, 539, 582, 649, 749, 794, 1207, 1227, 1346, 1406, 1481, 1498, 1654, 1677, 1700. +- Cause: Analytics queries are built for correctness, not scale. They load entire result sets into Node.js memory before aggregation. +- Improvement path: Move aggregation into the database using Prisma `groupBy` and `_count`/`_avg` aggregations, or write `$queryRaw` SQL for complex analytics. Add pagination or date-range limits to the procedure inputs. + +**Tag Rename N+1 Pattern:** +- Problem: Renaming a tag issues one DB write per entity (user or project) that carries the tag rather than a single bulk update. +- Files: `src/server/routers/tag.ts` lines 355–390 +- Cause: Prisma does not support `array_replace` natively; the current implementation works around this with a loop. +- Improvement path: Use `prisma.$executeRaw` with PostgreSQL's `array_replace` function. + +**`assignment.ts` Router is 3,337 Lines:** +- Problem: The assignment router is the single largest file and handles jury assignments, AI assignment, manual overrides, transfer, COI, and coverage checks in one module. +- Files: `src/server/routers/assignment.ts` +- Cause: Organic growth without module splitting. +- Improvement path: Extract into separate files: `src/server/routers/assignment/manual.ts`, `assignment/ai.ts`, `assignment/coverage.ts`. This will also improve IDE performance and test isolation. + +--- + +## Fragile Areas + +**Round Engine: COMPLETED State Has No Guards on Re-Entry:** +- Files: `src/server/services/round-engine.ts` lines 57–64 +- Why fragile: `COMPLETED` is defined as a terminal state (empty transitions array). However, there is no server-side guard preventing a direct Prisma update to a `COMPLETED` project state outside of `transitionProjectState()`. If a bug or data migration bypasses the state machine, projects can end up in unexpected states. +- Safe modification: Always transition through `transitionProjectState()`. Any admin data repair scripts should call this function rather than using `prisma.projectRoundState.update` directly. +- Test coverage: No unit tests for project state transitions. Only `tests/unit/assignment-policy.test.ts` exists, covering a different subsystem. + +**`Prisma.$transaction` Typed as `any`:** +- Files: `src/server/services/round-engine.ts` line 129, `src/server/services/result-lock.ts` lines 87, 169, `src/server/services/mentor-workspace.ts` lines 39, 254, and 50+ other locations. +- Why fragile: `tx: any` disables all type-checking inside transaction callbacks. A mistakenly called method on `tx` (e.g., `tx.round.delete` instead of `tx.round.update`) will compile successfully but may cause silent data corruption. +- Safe modification: Type the callback as `(tx: Parameters[0]>[0]) => ...` or define a `TransactionalPrisma` type alias. The `PrismaClient | any` union also defeats the purpose of typing. + +**`email.ts` is 2,175 Lines:** +- Files: `src/lib/email.ts` +- Why fragile: All email templates, SMTP transport logic, and dynamic config loading are in one file. Adding a new email type requires navigating 2,000+ lines, and any change to transport setup affects all templates. +- Safe modification: Extract individual email functions into `src/lib/email/` subdirectory with one file per template type. Keep shared transport logic in `src/lib/email/transport.ts`. +- Test coverage: No tests for email sending. Email errors are caught and logged but not surfaced to callers consistently. + +**`admin/rounds/[roundId]/page.tsx` is 2,398 Lines:** +- Files: `src/app/(admin)/admin/rounds/[roundId]/page.tsx` +- Why fragile: The entire round management UI (config, assignments, filtering, deliberation controls) lives in a single client component. State from one section can accidentally affect another, and the component re-renders on any state change. +- Safe modification: Extract tab sections into separate `'use client'` components with scoped state. Consider converting to a tab-based layout with lazy loading. + +**SSE Live Voting Stream Relies on Polling:** +- Files: `src/app/api/live-voting/stream/route.ts` lines 184–194 +- Why fragile: The SSE endpoint polls the database every 2 seconds per connected client. Under live ceremony conditions with many simultaneous audience connections, this can produce significant database load. +- Safe modification: Introduce a Redis pub/sub channel that the vote submission path writes to, and have the SSE stream subscribe to the channel rather than polling. Alternatively, implement a debounce on the poll and share the result across all open SSE connections via a singleton broadcaster. + +--- + +## Scaling Limits + +**In-Memory State (Rate Limiter, Login Attempts):** +- Current capacity: Works correctly for a single Node.js process. +- Limit: Breaks under horizontal scaling or after a process restart (all rate limit windows reset). +- Scaling path: Replace `src/lib/rate-limit.ts` with a Redis-backed solution. Replace the `failedAttempts` Map in `src/lib/auth.ts` with Redis counters or database fields on the `User` model. + +**SSE Connection Count vs. Database Poll Rate:** +- Current capacity: Each SSE client issues 1 database query per 2 seconds. +- Limit: At 100 concurrent audience connections, this is 50 queries/second to `liveVotingSession` and related tables during a ceremony. +- Scaling path: Shared broadcaster pattern (one database poll, fan-out to all SSE streams) or Redis pub/sub as described above. + +--- + +## Dependencies at Risk + +**`next-auth` v5 (Auth.js) — Beta API:** +- Risk: Auth.js v5 was in release candidate status at time of integration. The API surface (`authConfig` + `handlers` + `auth`) differs significantly from v4. Upgrading to a stable v5 release may require changes to `src/lib/auth.ts` and `src/lib/auth.config.ts`. +- Impact: Session type definitions, adapter interfaces, and middleware patterns may change. +- Migration plan: Monitor the Auth.js v5 stable release. Changes are likely limited to `src/lib/auth.ts` and `src/types/next-auth.d.ts`. + +--- + +## Missing Critical Features + +**No Database Backup Configuration:** +- Problem: `docker-compose.yml` has no scheduled backup service or volume snapshot configuration for the PostgreSQL container. +- Blocks: Point-in-time recovery after data loss or accidental deletion. +- Recommendation: Add a sidecar backup service (e.g., `prodrigestivill/postgres-backup-local`) or configure WAL archiving to MinIO. + +**No Error Monitoring / Observability:** +- Problem: There is no Sentry, Datadog, or equivalent error monitoring integration. Application errors are only logged to stdout via `console.error`. In production, these are only visible if the Docker logs are actively monitored. +- Files: No integration found in `src/instrumentation.ts` or anywhere else. +- Blocks: Proactive detection of runtime errors, AI service failures, and payment/submission edge cases. +- Recommendation: Add Sentry (`@sentry/nextjs`) in `src/instrumentation.ts` — Next.js has native support for this. Filter out expected errors (e.g., `TRPCError` with `NOT_FOUND`) to reduce noise. + +**No Automated Tests for Core Business Logic (Round Engine, Evaluation, Filtering):** +- Problem: Only one test file exists: `tests/unit/assignment-policy.test.ts`. The round engine state machine (`src/server/services/round-engine.ts`), evaluation submission flow (`src/server/routers/evaluation.ts`), and AI filtering pipeline (`src/server/services/ai-filtering.ts`) have no automated tests. +- Blocks: Confident refactoring of the state machine, advance-criterion logic, and scoring. +- Recommendation: Add unit tests for `activateRound`, `closeRound`, `transitionProjectState` (happy path + guard failures), and `submitEvaluation` (COI check, advance criterion logic, score validation). + +--- + +## Test Coverage Gaps + +**Round Engine State Machine:** +- What's not tested: All `activateRound`, `closeRound`, `archiveRound`, `transitionProjectState` transitions, including guard conditions (e.g., activating an ARCHIVED round, transitioning a COMPLETED project). +- Files: `src/server/services/round-engine.ts` +- Risk: A regression in state transition guards could allow data corruption (e.g., re-activating a closed round, double-passing a project). +- Priority: High + +**Evaluation Submission (advance criterion, COI, scoring):** +- What's not tested: `submitEvaluation` mutation — specifically the advance criterion auto-transition logic (lines 1637–1646 of `src/server/routers/evaluation.ts`), COI auto-reassignment on `declareCOI`, and `upsertForm` criterion validation. +- Files: `src/server/routers/evaluation.ts` +- Risk: Regression in advance criterion will silently skip project advancement. COI declaration failures are caught and logged but untested. +- Priority: High + +**AI Anonymization:** +- What's not tested: `sanitizeText`, `anonymizeProject`, `validateNoPersonalInfo` in `src/server/services/anonymization.ts`. These are GDPR-critical functions. +- Files: `src/server/services/anonymization.ts` +- Risk: A PII leak in AI calls would violate GDPR without detection. +- Priority: High + +**Assignment Policy Execution:** +- What's not tested: End-to-end `executeAssignment` flow in `src/server/services/round-assignment.ts` — specifically the COI filtering, geo-diversity penalty, familiarity bonus, and under-coverage gap-fill. +- Files: `src/server/services/round-assignment.ts`, `src/server/services/smart-assignment.ts` +- Risk: Silent over- or under-assignment when constraints interact. +- Priority: Medium + +--- + +*Concerns audit: 2026-02-26* diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 0000000..7eb6782 --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -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`: `type EvaluationConfig = z.infer` +- 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 {children} +} +``` + +**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` + +## 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* diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md new file mode 100644 index 0000000..2bd0210 --- /dev/null +++ b/.planning/codebase/INTEGRATIONS.md @@ -0,0 +1,213 @@ +# External Integrations + +**Analysis Date:** 2026-02-26 + +## APIs & External Services + +**AI Providers (configurable via SystemSettings DB table):** +- OpenAI - AI filtering, jury assignment suggestions, evaluation summaries, project tagging, award eligibility, shortlist recommendations + - SDK: `openai` ^6.16.0 + - Auth: `OPENAI_API_KEY` env var or `openai_api_key` SystemSetting + - Base URL: `OPENAI_BASE_URL` env var or `openai_base_url` SystemSetting (for OpenAI-compatible proxies) + - Model: `OPENAI_MODEL` env var or `ai_model` SystemSetting (default: `gpt-4o`) + - Client: `src/lib/openai.ts` - lazy singleton, reset via `resetOpenAIClient()` + +- Anthropic Claude - Alternative AI provider, same AI feature set as OpenAI + - SDK: `@anthropic-ai/sdk` ^0.78.0 + - Auth: `ANTHROPIC_API_KEY` env var or `anthropic_api_key` SystemSetting + - Adapter: `src/lib/openai.ts` wraps Anthropic SDK behind OpenAI `.chat.completions.create()` interface + - Supported models: `claude-opus-4-5-20250514`, `claude-sonnet-4-5-20250514`, `claude-haiku-3-5-20241022`, `claude-opus-4-20250514`, `claude-sonnet-4-20250514` + - Extended thinking enabled automatically for Opus models + +- LiteLLM proxy - Third option for ChatGPT subscription routing (no real API key needed) + - Config: `ai_provider = 'litellm'` in SystemSettings + `openai_base_url` pointing to proxy + - Token limit fields stripped for `chatgpt/*` model prefix + +- AI provider selection: `getConfiguredProvider()` in `src/lib/openai.ts` reads `ai_provider` SystemSetting; defaults to `openai` + +**All AI data is anonymized before sending** via `src/server/services/anonymization.ts` + +**Notion:** +- Used for project import (alternative to CSV) +- SDK: `@notionhq/client` ^2.3.0 +- Auth: API key stored in SystemSettings (`notion_api_key`), per-import flow +- Client: `src/lib/notion.ts` - `createNotionClient(apiKey)` per-request (not singleton) +- Router: `src/server/routers/notion-import.ts` + +**Typeform:** +- Used for project import from form responses +- Auth: API key stored in SystemSettings per-import +- Client: `src/lib/typeform.ts` - plain fetch against `https://api.typeform.com`, no SDK +- Router: `src/server/routers/typeform-import.ts` + +**WhatsApp (optional, configurable):** +- Two provider options: Meta WhatsApp Business Cloud API or Twilio WhatsApp + - Meta provider: `src/lib/whatsapp/meta-provider.ts` + - Twilio provider: `src/lib/whatsapp/twilio-provider.ts` + - Abstraction: `src/lib/whatsapp/index.ts` - `getWhatsAppProvider()` reads `whatsapp_provider` SystemSetting +- Auth: API keys stored in SystemSettings (`whatsapp_enabled`, `whatsapp_provider`, provider-specific keys) +- Used for: notification delivery (alternative to email) + +## Data Storage + +**Databases:** +- PostgreSQL 16 (primary datastore) + - Connection: `DATABASE_URL` env var (e.g., `postgresql://mopc:${password}@postgres:5432/mopc`) + - Client: Prisma 6 ORM, `src/lib/prisma.ts` singleton with connection pool (limit=20, timeout=10) + - Schema: `prisma/schema.prisma` (~95KB, ~100+ models) + - Migrations: `prisma/migrations/` directory, deployed via `prisma migrate deploy` on startup + - Test DB: `DATABASE_URL_TEST` env var (falls back to `DATABASE_URL` in test setup) + +**File Storage:** +- MinIO (S3-compatible, self-hosted on VPS) + - Internal endpoint: `MINIO_ENDPOINT` env var (server-to-server) + - Public endpoint: `MINIO_PUBLIC_ENDPOINT` env var (browser-accessible pre-signed URLs) + - Auth: `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY` + - Bucket: `MINIO_BUCKET` (default: `mopc-files`) + - Client: `src/lib/minio.ts` - lazy singleton via Proxy, `getMinioClient()` + - Access pattern: pre-signed URLs only (15-minute expiry by default), never direct public bucket + - Key structure: `{ProjectName}/{RoundName}/{timestamp}-{fileName}` + - File types stored: EXEC_SUMMARY, PRESENTATION, VIDEO, BUSINESS_PLAN, VIDEO_PITCH, SUPPORTING_DOC, OTHER + +**Caching:** +- None (in-memory rate limiter in `src/lib/rate-limit.ts`, not a caching layer) +- Note: rate limiter is in-memory only — not suitable for multi-instance deployments + +## Authentication & Identity + +**Auth Provider: NextAuth v5 (self-hosted)** +- Implementation: `src/lib/auth.ts` + `src/lib/auth.config.ts` +- Adapter: `@auth/prisma-adapter` (stores sessions/tokens in PostgreSQL) +- Strategy: JWT sessions (24-hour default, configurable via `SESSION_MAX_AGE`) +- Session includes: `user.id`, `user.email`, `user.name`, `user.role`, `user.roles[]`, `user.mustSetPassword` + +**Auth Providers:** +1. Email (Magic Links) + - NextAuth `EmailProvider` — magic link sent via Nodemailer + - Link expiry: 15 minutes (`MAGIC_LINK_EXPIRY` env var or default 900s) + - Custom send function: `sendMagicLinkEmail()` in `src/lib/email.ts` +2. Credentials (Password + Invite Token) + - Email/password with bcryptjs hashing (`src/lib/password.ts`) + - Invite token flow: one-time token clears on first use, sets `mustSetPassword: true` + - Failed login tracking: 5-attempt lockout, 15-minute duration (in-memory, not persistent) + - `mustSetPassword` flag forces redirect to `/set-password` before any other page + +**Role System:** +- User model has `role` (primary, legacy scalar) and `roles` (array, multi-role) +- `userHasRole()` helper in `src/server/trpc.ts` checks `roles[]` with `[role]` fallback +- 8 roles: `SUPER_ADMIN`, `PROGRAM_ADMIN`, `JURY_MEMBER`, `MENTOR`, `OBSERVER`, `APPLICANT`, `AWARD_MASTER`, `AUDIENCE` + +## Monitoring & Observability + +**Error Tracking:** +- Not detected (no Sentry, Datadog, or similar third-party service) + +**Logs:** +- Custom structured logger: `src/lib/logger.ts` +- Tagged format: `{timestamp} [LEVEL] [Tag] message data` +- Levels: debug, info, warn, error +- Default: `debug` in development, `warn` in production +- Configurable via `LOG_LEVEL` env var +- All output to `console.*` (stdout/stderr) + +**Audit Logging:** +- All auth events and admin mutations logged to `AuditLog` DB table +- `src/server/utils/audit.ts` - `logAudit()` helper +- Events tracked: LOGIN_SUCCESS, LOGIN_FAILED, INVITATION_ACCEPTED, all CRUD operations on major entities +- Cron for cleanup: `src/app/api/cron/audit-cleanup/` + +**Application Metrics:** +- Health check endpoint: `GET /api/health` (used by Docker healthcheck) + +## CI/CD & Deployment + +**Hosting:** +- Self-hosted VPS with Docker +- Nginx reverse proxy with SSL (external to compose stack) +- Domain: `monaco-opc.com` + +**CI Pipeline:** +- Gitea Actions (self-hosted Gitea at `code.monaco-opc.com/MOPC/MOPC-Portal`) +- Pipeline builds Docker image and pushes to private container registry +- `REGISTRY_URL` env var configures the registry in `docker/docker-compose.yml` + +**Docker Setup:** +- Production: `docker/docker-compose.yml` — app + postgres services +- Dev: `docker/docker-compose.dev.yml` — dev stack variant +- App image: standalone Next.js build (`output: 'standalone'`) +- Entrypoint: `docker/docker-entrypoint.sh` — migrations → generate → seed → start + +## Environment Configuration + +**Required env vars:** +- `DATABASE_URL` - PostgreSQL connection string +- `NEXTAUTH_URL` - Full URL of the app (e.g., `https://monaco-opc.com`) +- `NEXTAUTH_SECRET` / `AUTH_SECRET` - JWT signing secret +- `MINIO_ENDPOINT` - MinIO server URL (internal) +- `MINIO_ACCESS_KEY` - MinIO access key +- `MINIO_SECRET_KEY` - MinIO secret key +- `MINIO_BUCKET` - MinIO bucket name +- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` - SMTP credentials +- `EMAIL_FROM` - Sender address +- `CRON_SECRET` - Shared secret for cron endpoint authentication + +**Optional env vars:** +- `MINIO_PUBLIC_ENDPOINT` - Public-facing MinIO URL for pre-signed URLs +- `OPENAI_API_KEY` - OpenAI API key (also in SystemSettings) +- `OPENAI_MODEL` - Default AI model (default: `gpt-4o`) +- `OPENAI_BASE_URL` - Custom base URL for OpenAI-compatible providers +- `ANTHROPIC_API_KEY` - Anthropic Claude API key +- `POSTE_API_URL` - Poste.io mail server API URL +- `POSTE_ADMIN_EMAIL`, `POSTE_ADMIN_PASSWORD`, `POSTE_MAIL_DOMAIN` - Poste.io admin +- `SESSION_MAX_AGE` - JWT session duration in seconds (default: 86400) +- `MAX_FILE_SIZE` - Max upload size in bytes (default: 524288000 = 500MB) +- `LOG_LEVEL` - Logging verbosity (debug/info/warn/error) +- `MAGIC_LINK_EXPIRY` - Magic link lifetime in seconds (default: 900) + +**Secrets location:** +- `.env` file at repo root (read by Docker Compose via `env_file: .env`) +- Runtime secrets also configurable via `SystemSettings` DB table (admin UI) + +## Webhooks & Callbacks + +**Incoming:** +- `/api/auth/[...nextauth]` - NextAuth callback routes (magic link verification, OAuth if added) +- No third-party webhook receivers detected + +**Outgoing:** +- Configurable webhooks via `Webhook` DB model and `src/server/services/webhook-dispatcher.ts` +- Admin-managed via `src/server/routers/webhook.ts` (SUPER_ADMIN only) +- Signed with HMAC-SHA256 (`X-Webhook-Signature: sha256={sig}`) +- Events dispatched: `evaluation.submitted`, `evaluation.updated`, `project.created`, `project.statusChanged`, `round.activated`, `round.closed`, `assignment.created`, `assignment.completed`, `user.invited`, `user.activated` +- Retry logic: configurable max retries per webhook (0–10), retry via cron + +## Real-Time + +**Server-Sent Events (SSE):** +- Endpoint: `/api/sse/` - in-app notifications push +- Used for: real-time notification delivery to connected clients + +**Live Voting Stream:** +- Endpoint: `/api/live-voting/stream/` - SSE stream for live ceremony voting cursor +- Service: `src/server/services/live-control.ts` + +## Cron Jobs + +All cron endpoints protected by `CRON_SECRET` header check: +- `GET /api/cron/reminders` - Evaluation reminders via `src/server/services/evaluation-reminders.ts` +- `GET /api/cron/digest` - Email digests via `src/server/services/email-digest.ts` +- `GET /api/cron/draft-cleanup` - Remove stale draft evaluations +- `GET /api/cron/audit-cleanup` - Purge old audit log entries + +## Email + +**SMTP Transport:** +- Provider: Poste.io (self-hosted mail server, port 587) +- Client: Nodemailer 7 via `src/lib/email.ts` +- Config priority: SystemSettings DB > env vars +- Transporter cached, rebuilt when config hash changes +- Error handling: email errors logged but never thrown (non-blocking) + +--- + +*Integration audit: 2026-02-26* diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md new file mode 100644 index 0000000..5aceabd --- /dev/null +++ b/.planning/codebase/STACK.md @@ -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* diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md new file mode 100644 index 0000000..acd4dec --- /dev/null +++ b/.planning/codebase/STRUCTURE.md @@ -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()` 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* diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md new file mode 100644 index 0000000..33429de --- /dev/null +++ b/.planning/codebase/TESTING.md @@ -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 { + 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> + + 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*