docs: map existing codebase
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
193
.planning/codebase/ARCHITECTURE.md
Normal file
193
.planning/codebase/ARCHITECTURE.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Architecture
|
||||
|
||||
**Analysis Date:** 2026-02-26
|
||||
|
||||
## Pattern Overview
|
||||
|
||||
**Overall:** Role-gated multi-tenant monolith with end-to-end type safety (Prisma → tRPC → React)
|
||||
|
||||
**Key Characteristics:**
|
||||
- Single Next.js 15 App Router application serving all roles (admin, jury, applicant, mentor, observer, public)
|
||||
- All API calls go through tRPC with superjson serialization; no separate REST API for client data
|
||||
- Role enforcement happens at two levels: layout-level (`requireRole()`) and procedure-level (tRPC middleware)
|
||||
- Service layer (`src/server/services/`) contains all business logic — routers delegate immediately to services
|
||||
- All state machine transitions are audited via `DecisionAuditLog` through `src/server/utils/audit.ts`
|
||||
|
||||
## Layers
|
||||
|
||||
**Data Layer:**
|
||||
- Purpose: Schema definition, migrations, query client
|
||||
- Location: `prisma/schema.prisma`, `src/lib/prisma.ts`
|
||||
- Contains: Prisma schema, singleton PrismaClient with connection pool (limit=20, timeout=10)
|
||||
- Depends on: PostgreSQL 16
|
||||
- Used by: Service layer, tRPC context
|
||||
|
||||
**Service Layer:**
|
||||
- Purpose: Business logic, state machines, external integrations
|
||||
- Location: `src/server/services/`
|
||||
- Contains: Round engine, deliberation, assignment, AI services, submission manager, live control, result lock, notification
|
||||
- Depends on: Prisma client (passed as param to allow transactional usage), `src/lib/` utilities
|
||||
- Used by: tRPC routers only
|
||||
|
||||
**API Layer (tRPC):**
|
||||
- Purpose: Type-safe RPC procedures with role-based access control
|
||||
- Location: `src/server/routers/`, `src/server/trpc.ts`, `src/server/context.ts`
|
||||
- Contains: 44+ domain routers assembled in `src/server/routers/_app.ts`, middleware hierarchy, tRPC context
|
||||
- Depends on: Service layer, Prisma (via context)
|
||||
- Used by: Client components via `src/lib/trpc/client.ts`
|
||||
|
||||
**UI Layer:**
|
||||
- Purpose: Server and client React components, pages, layouts
|
||||
- Location: `src/app/`, `src/components/`
|
||||
- Contains: Route groups per role, layouts with role guards, client components using tRPC hooks
|
||||
- Depends on: tRPC client, shadcn/ui, Tailwind CSS
|
||||
- Used by: Browser clients
|
||||
|
||||
**Shared Utilities:**
|
||||
- Purpose: Cross-cutting helpers available everywhere
|
||||
- Location: `src/lib/`
|
||||
- Contains: Auth config, Prisma singleton, email, MinIO client, OpenAI client, logger, rate limiter, feature flags, storage provider abstraction
|
||||
- Depends on: External services
|
||||
- Used by: Service layer, routers, layouts
|
||||
|
||||
## Data Flow
|
||||
|
||||
**Client Query Flow:**
|
||||
|
||||
1. React component calls `trpc.domain.procedure.useQuery()` via `src/lib/trpc/client.ts`
|
||||
2. Request hits `src/app/api/trpc/[trpc]/route.ts` — rate limited at 100 req/min per IP
|
||||
3. tRPC resolves context (`src/server/context.ts`): auth session + prisma singleton + IP/UA
|
||||
4. Middleware chain runs: authentication check → role check → procedure handler
|
||||
5. Router delegates to service (e.g., `roundEngineRouter` → `src/server/services/round-engine.ts`)
|
||||
6. Service queries Prisma, may call external APIs, writes audit log
|
||||
7. Superjson-serialized result returns to React Query cache
|
||||
|
||||
**Mutation Flow (with audit trail):**
|
||||
|
||||
1. Component calls `trpc.domain.action.useMutation()`
|
||||
2. tRPC middleware validates auth + role
|
||||
3. Router calls `logAudit()` before or after service call
|
||||
4. Service performs database work inside `prisma.$transaction()` when atomicity required
|
||||
5. Service writes its own `logAudit()` for state machine transitions
|
||||
6. Cache invalidated via `utils.trpc.invalidate()`
|
||||
|
||||
**Server-Sent Events Flow (live voting/deliberation):**
|
||||
|
||||
1. Client subscribes via `src/hooks/use-live-voting-sse.ts` or `src/hooks/use-stage-live-sse.ts`
|
||||
2. SSE route `src/app/api/live-voting/stream/route.ts` polls database on interval
|
||||
3. Events emitted for vote count changes, cursor position changes, status changes
|
||||
4. Admin cursor controlled via `src/server/services/live-control.ts` → tRPC `liveRouter`
|
||||
|
||||
**State Management:**
|
||||
- Server state: React Query via tRPC hooks (cache + invalidation)
|
||||
- Edition/program selection: `src/contexts/edition-context.tsx` (localStorage + URL param + React Context)
|
||||
- Form state: Local React state with autosave timers (evaluation page uses refs to prevent race conditions)
|
||||
- No global client state library (no Redux/Zustand)
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
**Competition/Round State Machine:**
|
||||
- Purpose: Governs round lifecycle and per-project states within rounds
|
||||
- Examples: `src/server/services/round-engine.ts`
|
||||
- Pattern: Pure functions with explicit transition maps; `VALID_ROUND_TRANSITIONS` and `VALID_PROJECT_TRANSITIONS` constants define allowed moves. All transitions are transactional and audited.
|
||||
- Round transitions: `ROUND_DRAFT → ROUND_ACTIVE → ROUND_CLOSED → ROUND_ARCHIVED`
|
||||
- Project-in-round transitions: `PENDING → IN_PROGRESS → PASSED/REJECTED → COMPLETED/WITHDRAWN`
|
||||
|
||||
**tRPC Procedure Types (RBAC middleware):**
|
||||
- Purpose: Enforce role-based access at the API boundary
|
||||
- Examples: `src/server/trpc.ts`
|
||||
- Pattern: `publicProcedure`, `protectedProcedure`, `adminProcedure`, `superAdminProcedure`, `juryProcedure`, `mentorProcedure`, `observerProcedure`, `awardMasterProcedure`, `audienceProcedure`
|
||||
- Each is a pre-configured middleware chain; routers simply choose the correct type
|
||||
|
||||
**CompetitionContext resolver:**
|
||||
- Purpose: Loads the full typed context for a round (competition + round + parsed configJson + juryGroup + submissionWindows)
|
||||
- Examples: `src/server/services/competition-context.ts`
|
||||
- Pattern: `resolveCompetitionContext(roundId)` used by any service that needs the full picture. Also provides `resolveMemberContext()` for jury-member-specific context with assignment counts.
|
||||
|
||||
**Round-Type Config Schemas:**
|
||||
- Purpose: Each RoundType (`INTAKE`, `FILTERING`, `EVALUATION`, etc.) has a dedicated Zod config schema stored in `Round.configJson`
|
||||
- Examples: `src/types/competition-configs.ts` — `IntakeConfigSchema`, `FilteringConfigSchema`, `EvaluationConfigSchema`, etc.
|
||||
- Pattern: `safeValidateRoundConfig(roundType, configJson)` returns typed config or null; `validateRoundConfig()` throws on invalid
|
||||
|
||||
**Storage Provider Abstraction:**
|
||||
- Purpose: Swap MinIO (production) for local filesystem (dev/test) without changing service code
|
||||
- Examples: `src/lib/storage/types.ts`, `src/lib/storage/s3-provider.ts`, `src/lib/storage/local-provider.ts`, `src/lib/storage/index.ts`
|
||||
- Pattern: `StorageProvider` interface with `getUploadUrl`, `getDownloadUrl`, `deleteObject`, `putObject`, `getObject`
|
||||
|
||||
**AI Pipeline with Anonymization:**
|
||||
- Purpose: All AI calls strip PII before sending to OpenAI
|
||||
- Examples: `src/server/services/anonymization.ts` + any `src/server/services/ai-*.ts`
|
||||
- Pattern: `anonymizeProjectsForAI()` returns `AnonymizedProjectForAI[]` + `ProjectAIMapping`; AI service uses anonymous IDs; results mapped back via mapping object
|
||||
|
||||
## Entry Points
|
||||
|
||||
**tRPC API Handler:**
|
||||
- Location: `src/app/api/trpc/[trpc]/route.ts`
|
||||
- Triggers: All client data queries and mutations
|
||||
- Responsibilities: Rate limiting (100 req/min), `fetchRequestHandler` with `appRouter` + `createContext`, error logging
|
||||
|
||||
**Auth Handler:**
|
||||
- Location: `src/app/api/auth/[...nextauth]/route.ts`
|
||||
- Triggers: Login, magic link verification, session management
|
||||
- Responsibilities: NextAuth v5 with Email + Credentials providers, 5-attempt lockout
|
||||
|
||||
**Cron Endpoints:**
|
||||
- Location: `src/app/api/cron/` (audit-cleanup, digest, draft-cleanup, reminders)
|
||||
- Triggers: External scheduler via `CRON_SECRET` header check
|
||||
- Responsibilities: Periodic maintenance — evaluation reminders, draft cleanup, digest emails, audit log rotation
|
||||
|
||||
**SSE Stream:**
|
||||
- Location: `src/app/api/live-voting/stream/route.ts`
|
||||
- Triggers: Live voting/deliberation pages subscribe as long-running GET connections
|
||||
- Responsibilities: Poll DB for changes, push events for vote counts, cursor position, status transitions
|
||||
|
||||
**Next.js Middleware:**
|
||||
- Location: `middleware.ts` (root, uses `src/lib/auth.config.ts`)
|
||||
- Triggers: Every request
|
||||
- Responsibilities: Auth check (edge-compatible), redirect to `/login` if unauthenticated, redirect to `/set-password` if `mustSetPassword` flag set
|
||||
|
||||
**Role Layout Guards:**
|
||||
- Location: `src/app/(admin)/layout.tsx`, `src/app/(jury)/layout.tsx`, etc.
|
||||
- Triggers: Navigation into role-specific route group
|
||||
- Responsibilities: Server-side `requireRole()` call, redirect to role dashboard if unauthorized, onboarding gate (jury)
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Strategy:** Boundary-based — tRPC errors propagate to React Query; service errors use `TRPCError`; audit never throws
|
||||
|
||||
**Patterns:**
|
||||
- Services return typed result objects (`{ success: boolean, errors?: string[] }`) for state machine operations — no throwing
|
||||
- tRPC procedures throw `TRPCError` with code (`NOT_FOUND`, `FORBIDDEN`, `CONFLICT`, `BAD_REQUEST`) for client-visible errors
|
||||
- `logAudit()` is wrapped in try-catch — audit failures are logged to console but never surface to users
|
||||
- AI services use `classifyAIError()` from `src/server/services/ai-errors.ts` to translate OpenAI errors
|
||||
- Round notification functions explicitly catch all errors and log them — notifications never block round transitions
|
||||
- Client uses `toast.error()` from sonner for user-facing error display
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
**Logging:**
|
||||
- `src/lib/logger.ts` — tagged, level-aware (`debug/info/warn/error`), respects `LOG_LEVEL` env var, defaults to `debug` in development and `warn` in production
|
||||
- Pattern: `logger.info('ServiceName', 'message', { data })` — tag identifies the calling service
|
||||
|
||||
**Validation:**
|
||||
- Zod schemas on all tRPC procedure inputs (`.input(z.object({...}))`)
|
||||
- `ZodError` is formatted and included in tRPC error response via `errorFormatter` in `src/server/trpc.ts`
|
||||
- Round config Zod schemas in `src/types/competition-configs.ts` validate `configJson` at activation time
|
||||
|
||||
**Authentication:**
|
||||
- NextAuth v5 with JWT strategy; session available server-side via `auth()` from `src/lib/auth.ts`
|
||||
- `requireRole(...roles)` in `src/lib/auth-redirect.ts` used by all role layouts — checks `user.roles[]` array with `user.role` fallback
|
||||
- `userHasRole()` in `src/server/trpc.ts` used inline for fine-grained procedure-level checks
|
||||
|
||||
**Audit Trail:**
|
||||
- `logAudit()` in `src/server/utils/audit.ts` — writes to `AuditLog` table
|
||||
- Called from both routers (with `ctx.prisma` to share transaction) and services
|
||||
- Never throws — always wrapped in try-catch
|
||||
|
||||
**Feature Flags:**
|
||||
- `src/lib/feature-flags.ts` — reads `SystemSetting` records with `category: FEATURE_FLAGS`
|
||||
- Currently one active flag: `feature.useCompetitionModel` (defaults to `true`)
|
||||
|
||||
---
|
||||
|
||||
*Architecture analysis: 2026-02-26*
|
||||
217
.planning/codebase/CONCERNS.md
Normal file
217
.planning/codebase/CONCERNS.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# Codebase Concerns
|
||||
|
||||
**Analysis Date:** 2026-02-26
|
||||
|
||||
---
|
||||
|
||||
## Tech Debt
|
||||
|
||||
**Award Router is a Stub:**
|
||||
- Issue: Entire award router is commented out and non-functional. All award procedures were deleted when the Pipeline/Track models were removed and have not been reimplemented.
|
||||
- Files: `src/server/routers/award.ts`
|
||||
- Impact: Any UI that references award management procedures will fail at runtime. The `SpecialAward` model still exists in the schema but has no tRPC exposure via this router.
|
||||
- Fix approach: Reimplement the router procedures against the current `SpecialAward` → `Competition` FK relationship. See the TODO comment at line 9 for the list of procedures to reimplement.
|
||||
|
||||
**Deliberation Page Has Incomplete Implementation:**
|
||||
- Issue: The jury-facing deliberation page has two hardcoded stub values that break actual vote submission.
|
||||
- `juryMemberId: ''` — submitted vote will have an empty juror ID.
|
||||
- `const hasVoted = false` — the "already voted" guard never fires, allowing duplicate vote submissions.
|
||||
- Files: `src/app/(jury)/jury/competitions/deliberation/[sessionId]/page.tsx` lines 34 and 66
|
||||
- Impact: Jury members can submit blank/duplicate votes in deliberation sessions. The submitted vote will be associated with an empty string `juryMemberId`, which will likely fail at the Prisma level or silently create bad data.
|
||||
- Fix approach: Derive `juryMemberId` from `session.participants` by matching `ctx.user.id`. Derive `hasVoted` by checking if a `DeliberationVote` with the current user's jury member ID already exists in `session.votes`.
|
||||
|
||||
**Audit Middleware is a No-Op:**
|
||||
- Issue: The `withAuditLog` middleware in `src/server/trpc.ts` (lines 99–114) identifies mutation calls by path pattern but does nothing with them — the body contains only a comment: `// We'll implement this in the audit service`.
|
||||
- Files: `src/server/trpc.ts`
|
||||
- Impact: Automatic centralised audit logging for all admin mutations does not occur through this middleware. Manual `logAudit()` / `DecisionAuditLog.create()` calls are present in many routers but coverage is inconsistent.
|
||||
- Fix approach: Implement the middleware body to call `logAudit()` with `ctx.user.id`, `path`, and serialized input/output. This provides a safety net for any procedure that doesn't call `logAudit` manually.
|
||||
|
||||
**In-Memory Rate Limiter Not Suitable for Multi-Instance Deployment:**
|
||||
- Issue: `src/lib/rate-limit.ts` uses a module-level `Map` for rate limit state. This works in a single process but does not share state across multiple Node.js instances or after a process restart.
|
||||
- Files: `src/lib/rate-limit.ts`
|
||||
- Impact: Rate limits can be trivially bypassed by hitting different server instances. Auth brute-force protection (5-attempt lockout) also uses this same in-memory store (`src/lib/auth.ts` line 12).
|
||||
- Fix approach: Replace with Redis-based rate limiting (e.g., `@upstash/ratelimit` or `ioredis`). The comment at line 5 already acknowledges this: "For production with multiple instances, replace with Redis-based solution."
|
||||
|
||||
**`configJson` Widely Cast Without Validation:**
|
||||
- Issue: `configJson` (a Prisma `Json` field) is cast directly to `Record<string, unknown>` in 65 locations across server routers and services without running it through the Zod validators. The validators (`safeValidateRoundConfig`, `EvaluationConfigSchema.safeParse`) are only called in 4 locations.
|
||||
- Files: `src/server/routers/assignment.ts`, `src/server/routers/evaluation.ts`, `src/server/routers/filtering.ts`, `src/server/services/round-engine.ts`, and many others.
|
||||
- Impact: Stale or malformed config JSON stored in the database can cause silent runtime failures deep in business logic (e.g., missing criteria, wrong field names) without a clear validation error.
|
||||
- Fix approach: Extract a typed `parseRoundConfig(roundType, configJson)` utility that returns a typed config or throws a `TRPCError`. Replace bare `as Record<string, unknown>` casts with this utility at query boundaries.
|
||||
|
||||
---
|
||||
|
||||
## Known Bugs
|
||||
|
||||
**Tag Rename Performs N+1 Database Writes:**
|
||||
- Symptoms: Renaming a tag iterates over every user and every project that has the tag, issuing one `UPDATE` per record.
|
||||
- Files: `src/server/routers/tag.ts` lines 361–389 and 421–438
|
||||
- Trigger: Admin renames any tag that is widely used.
|
||||
- Workaround: None. Will time out for large datasets.
|
||||
- Fix approach: Use a raw SQL `UPDATE ... SET tags = array_replace(tags, $old, $new)` or a Prisma `$executeRaw` to perform the rename in a single query.
|
||||
|
||||
**Jury Deliberation Vote: `juryMemberId` Is Hardcoded Empty String:**
|
||||
- Symptoms: Votes submitted via the jury deliberation page will have `juryMemberId: ''`.
|
||||
- Files: `src/app/(jury)/jury/competitions/deliberation/[sessionId]/page.tsx` line 34
|
||||
- Trigger: Any jury member visits a deliberation session and submits a vote.
|
||||
- Workaround: None — the vote will silently pass or fail depending on Prisma validation.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**IP Header Spoofing on Rate Limiter:**
|
||||
- Risk: All rate limiters extract the client IP from `x-forwarded-for` without validating that the header originates from a trusted proxy. A client can set this header to any value, bypassing per-IP rate limits.
|
||||
- Files: `src/app/api/trpc/[trpc]/route.ts` lines 13–18, `src/app/api/auth/[...nextauth]/route.ts` lines 7–10, `src/app/api/email/change-password/route.ts` line 36, `src/app/api/email/verify-credentials/route.ts` line 20, `src/server/context.ts` line 13.
|
||||
- Current mitigation: Nginx passes `X-Forwarded-For` from upstream; in single-proxy deployment this reduces (but does not eliminate) risk.
|
||||
- Recommendations: Pin IP extraction to `req.headers.get('x-real-ip')` set by Nginx only, or validate the forwarded-for chain against a trusted proxy list.
|
||||
|
||||
**Cron Secret Compared with `!==` (Non–Timing-Safe):**
|
||||
- Risk: String equality check `cronSecret !== process.env.CRON_SECRET` is vulnerable to timing side-channel attacks on the secret value.
|
||||
- Files: `src/app/api/cron/audit-cleanup/route.ts`, `src/app/api/cron/digest/route.ts`, `src/app/api/cron/draft-cleanup/route.ts`, `src/app/api/cron/reminders/route.ts` — all at line 8.
|
||||
- Current mitigation: Cron endpoints are not user-facing and rate limited at Nginx level.
|
||||
- Recommendations: Replace with `timingSafeEqual(Buffer.from(cronSecret), Buffer.from(process.env.CRON_SECRET))` — the same approach already used in `src/lib/storage/local-provider.ts` line 75.
|
||||
|
||||
**No Content-Security-Policy Header:**
|
||||
- Risk: No CSP is set in `next.config.ts` or via middleware headers. If an XSS vector exists, there is no second line of defence to limit script execution.
|
||||
- Files: `next.config.ts` (missing `headers()` function), `docker/nginx/mopc-platform.conf` (missing CSP directive).
|
||||
- Current mitigation: Nginx sets `X-Frame-Options`, `X-Content-Type-Options`, and `X-XSS-Protection`, but these are legacy headers. No HSTS header is configured in Nginx either (only set post-certbot).
|
||||
- Recommendations: Add `Content-Security-Policy` and `Strict-Transport-Security` via the Next.js `headers()` config function.
|
||||
|
||||
**MinIO Fallback Credentials Hardcoded:**
|
||||
- Risk: When `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` are not set in non-production environments, the client defaults to `minioadmin`/`minioadmin`.
|
||||
- Files: `src/lib/minio.ts` lines 28–29
|
||||
- Current mitigation: Production throws an error if credentials are missing (line 20–22). The fallback only applies in development.
|
||||
- Recommendations: Remove the hardcoded fallback entirely; require credentials in all environments to prevent accidental exposure of a non-production MinIO instance.
|
||||
|
||||
**`NEXT_PUBLIC_MINIO_ENDPOINT` Undefined in Production:**
|
||||
- Risk: Two admin learning pages read `process.env.NEXT_PUBLIC_MINIO_ENDPOINT` at runtime. This variable is not defined in `docker-compose.yml` and has no `NEXT_PUBLIC_` entry. Next.js requires public env vars to be present at build time; at runtime this will always resolve to the fallback `http://localhost:9000`, making file previews broken in production.
|
||||
- Files: `src/app/(admin)/admin/learning/new/page.tsx` line 112, `src/app/(admin)/admin/learning/[id]/page.tsx` line 165.
|
||||
- Fix approach: Add `NEXT_PUBLIC_MINIO_ENDPOINT` to `docker-compose.yml` env section, or use the server-side `MINIO_PUBLIC_ENDPOINT` via a tRPC query rather than client-side env.
|
||||
|
||||
---
|
||||
|
||||
## Performance Bottlenecks
|
||||
|
||||
**Unbounded `findMany` Queries in Analytics Router:**
|
||||
- Problem: `src/server/routers/analytics.ts` contains approximately 25 `findMany` calls with no `take` limit. With large competitions (hundreds of projects, thousands of evaluations) these will perform full table scans filtered only by `roundId` or `competitionId`.
|
||||
- Files: `src/server/routers/analytics.ts` — queries at lines 38, 80, 265, 421, 539, 582, 649, 749, 794, 1207, 1227, 1346, 1406, 1481, 1498, 1654, 1677, 1700.
|
||||
- Cause: Analytics queries are built for correctness, not scale. They load entire result sets into Node.js memory before aggregation.
|
||||
- Improvement path: Move aggregation into the database using Prisma `groupBy` and `_count`/`_avg` aggregations, or write `$queryRaw` SQL for complex analytics. Add pagination or date-range limits to the procedure inputs.
|
||||
|
||||
**Tag Rename N+1 Pattern:**
|
||||
- Problem: Renaming a tag issues one DB write per entity (user or project) that carries the tag rather than a single bulk update.
|
||||
- Files: `src/server/routers/tag.ts` lines 355–390
|
||||
- Cause: Prisma does not support `array_replace` natively; the current implementation works around this with a loop.
|
||||
- Improvement path: Use `prisma.$executeRaw` with PostgreSQL's `array_replace` function.
|
||||
|
||||
**`assignment.ts` Router is 3,337 Lines:**
|
||||
- Problem: The assignment router is the single largest file and handles jury assignments, AI assignment, manual overrides, transfer, COI, and coverage checks in one module.
|
||||
- Files: `src/server/routers/assignment.ts`
|
||||
- Cause: Organic growth without module splitting.
|
||||
- Improvement path: Extract into separate files: `src/server/routers/assignment/manual.ts`, `assignment/ai.ts`, `assignment/coverage.ts`. This will also improve IDE performance and test isolation.
|
||||
|
||||
---
|
||||
|
||||
## Fragile Areas
|
||||
|
||||
**Round Engine: COMPLETED State Has No Guards on Re-Entry:**
|
||||
- Files: `src/server/services/round-engine.ts` lines 57–64
|
||||
- Why fragile: `COMPLETED` is defined as a terminal state (empty transitions array). However, there is no server-side guard preventing a direct Prisma update to a `COMPLETED` project state outside of `transitionProjectState()`. If a bug or data migration bypasses the state machine, projects can end up in unexpected states.
|
||||
- Safe modification: Always transition through `transitionProjectState()`. Any admin data repair scripts should call this function rather than using `prisma.projectRoundState.update` directly.
|
||||
- Test coverage: No unit tests for project state transitions. Only `tests/unit/assignment-policy.test.ts` exists, covering a different subsystem.
|
||||
|
||||
**`Prisma.$transaction` Typed as `any`:**
|
||||
- Files: `src/server/services/round-engine.ts` line 129, `src/server/services/result-lock.ts` lines 87, 169, `src/server/services/mentor-workspace.ts` lines 39, 254, and 50+ other locations.
|
||||
- Why fragile: `tx: any` disables all type-checking inside transaction callbacks. A mistakenly called method on `tx` (e.g., `tx.round.delete` instead of `tx.round.update`) will compile successfully but may cause silent data corruption.
|
||||
- Safe modification: Type the callback as `(tx: Parameters<Parameters<typeof prisma.$transaction>[0]>[0]) => ...` or define a `TransactionalPrisma` type alias. The `PrismaClient | any` union also defeats the purpose of typing.
|
||||
|
||||
**`email.ts` is 2,175 Lines:**
|
||||
- Files: `src/lib/email.ts`
|
||||
- Why fragile: All email templates, SMTP transport logic, and dynamic config loading are in one file. Adding a new email type requires navigating 2,000+ lines, and any change to transport setup affects all templates.
|
||||
- Safe modification: Extract individual email functions into `src/lib/email/` subdirectory with one file per template type. Keep shared transport logic in `src/lib/email/transport.ts`.
|
||||
- Test coverage: No tests for email sending. Email errors are caught and logged but not surfaced to callers consistently.
|
||||
|
||||
**`admin/rounds/[roundId]/page.tsx` is 2,398 Lines:**
|
||||
- Files: `src/app/(admin)/admin/rounds/[roundId]/page.tsx`
|
||||
- Why fragile: The entire round management UI (config, assignments, filtering, deliberation controls) lives in a single client component. State from one section can accidentally affect another, and the component re-renders on any state change.
|
||||
- Safe modification: Extract tab sections into separate `'use client'` components with scoped state. Consider converting to a tab-based layout with lazy loading.
|
||||
|
||||
**SSE Live Voting Stream Relies on Polling:**
|
||||
- Files: `src/app/api/live-voting/stream/route.ts` lines 184–194
|
||||
- Why fragile: The SSE endpoint polls the database every 2 seconds per connected client. Under live ceremony conditions with many simultaneous audience connections, this can produce significant database load.
|
||||
- Safe modification: Introduce a Redis pub/sub channel that the vote submission path writes to, and have the SSE stream subscribe to the channel rather than polling. Alternatively, implement a debounce on the poll and share the result across all open SSE connections via a singleton broadcaster.
|
||||
|
||||
---
|
||||
|
||||
## Scaling Limits
|
||||
|
||||
**In-Memory State (Rate Limiter, Login Attempts):**
|
||||
- Current capacity: Works correctly for a single Node.js process.
|
||||
- Limit: Breaks under horizontal scaling or after a process restart (all rate limit windows reset).
|
||||
- Scaling path: Replace `src/lib/rate-limit.ts` with a Redis-backed solution. Replace the `failedAttempts` Map in `src/lib/auth.ts` with Redis counters or database fields on the `User` model.
|
||||
|
||||
**SSE Connection Count vs. Database Poll Rate:**
|
||||
- Current capacity: Each SSE client issues 1 database query per 2 seconds.
|
||||
- Limit: At 100 concurrent audience connections, this is 50 queries/second to `liveVotingSession` and related tables during a ceremony.
|
||||
- Scaling path: Shared broadcaster pattern (one database poll, fan-out to all SSE streams) or Redis pub/sub as described above.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies at Risk
|
||||
|
||||
**`next-auth` v5 (Auth.js) — Beta API:**
|
||||
- Risk: Auth.js v5 was in release candidate status at time of integration. The API surface (`authConfig` + `handlers` + `auth`) differs significantly from v4. Upgrading to a stable v5 release may require changes to `src/lib/auth.ts` and `src/lib/auth.config.ts`.
|
||||
- Impact: Session type definitions, adapter interfaces, and middleware patterns may change.
|
||||
- Migration plan: Monitor the Auth.js v5 stable release. Changes are likely limited to `src/lib/auth.ts` and `src/types/next-auth.d.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Missing Critical Features
|
||||
|
||||
**No Database Backup Configuration:**
|
||||
- Problem: `docker-compose.yml` has no scheduled backup service or volume snapshot configuration for the PostgreSQL container.
|
||||
- Blocks: Point-in-time recovery after data loss or accidental deletion.
|
||||
- Recommendation: Add a sidecar backup service (e.g., `prodrigestivill/postgres-backup-local`) or configure WAL archiving to MinIO.
|
||||
|
||||
**No Error Monitoring / Observability:**
|
||||
- Problem: There is no Sentry, Datadog, or equivalent error monitoring integration. Application errors are only logged to stdout via `console.error`. In production, these are only visible if the Docker logs are actively monitored.
|
||||
- Files: No integration found in `src/instrumentation.ts` or anywhere else.
|
||||
- Blocks: Proactive detection of runtime errors, AI service failures, and payment/submission edge cases.
|
||||
- Recommendation: Add Sentry (`@sentry/nextjs`) in `src/instrumentation.ts` — Next.js has native support for this. Filter out expected errors (e.g., `TRPCError` with `NOT_FOUND`) to reduce noise.
|
||||
|
||||
**No Automated Tests for Core Business Logic (Round Engine, Evaluation, Filtering):**
|
||||
- Problem: Only one test file exists: `tests/unit/assignment-policy.test.ts`. The round engine state machine (`src/server/services/round-engine.ts`), evaluation submission flow (`src/server/routers/evaluation.ts`), and AI filtering pipeline (`src/server/services/ai-filtering.ts`) have no automated tests.
|
||||
- Blocks: Confident refactoring of the state machine, advance-criterion logic, and scoring.
|
||||
- Recommendation: Add unit tests for `activateRound`, `closeRound`, `transitionProjectState` (happy path + guard failures), and `submitEvaluation` (COI check, advance criterion logic, score validation).
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
**Round Engine State Machine:**
|
||||
- What's not tested: All `activateRound`, `closeRound`, `archiveRound`, `transitionProjectState` transitions, including guard conditions (e.g., activating an ARCHIVED round, transitioning a COMPLETED project).
|
||||
- Files: `src/server/services/round-engine.ts`
|
||||
- Risk: A regression in state transition guards could allow data corruption (e.g., re-activating a closed round, double-passing a project).
|
||||
- Priority: High
|
||||
|
||||
**Evaluation Submission (advance criterion, COI, scoring):**
|
||||
- What's not tested: `submitEvaluation` mutation — specifically the advance criterion auto-transition logic (lines 1637–1646 of `src/server/routers/evaluation.ts`), COI auto-reassignment on `declareCOI`, and `upsertForm` criterion validation.
|
||||
- Files: `src/server/routers/evaluation.ts`
|
||||
- Risk: Regression in advance criterion will silently skip project advancement. COI declaration failures are caught and logged but untested.
|
||||
- Priority: High
|
||||
|
||||
**AI Anonymization:**
|
||||
- What's not tested: `sanitizeText`, `anonymizeProject`, `validateNoPersonalInfo` in `src/server/services/anonymization.ts`. These are GDPR-critical functions.
|
||||
- Files: `src/server/services/anonymization.ts`
|
||||
- Risk: A PII leak in AI calls would violate GDPR without detection.
|
||||
- Priority: High
|
||||
|
||||
**Assignment Policy Execution:**
|
||||
- What's not tested: End-to-end `executeAssignment` flow in `src/server/services/round-assignment.ts` — specifically the COI filtering, geo-diversity penalty, familiarity bonus, and under-coverage gap-fill.
|
||||
- Files: `src/server/services/round-assignment.ts`, `src/server/services/smart-assignment.ts`
|
||||
- Risk: Silent over- or under-assignment when constraints interact.
|
||||
- Priority: Medium
|
||||
|
||||
---
|
||||
|
||||
*Concerns audit: 2026-02-26*
|
||||
267
.planning/codebase/CONVENTIONS.md
Normal file
267
.planning/codebase/CONVENTIONS.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# Coding Conventions
|
||||
|
||||
**Analysis Date:** 2026-02-26
|
||||
|
||||
## Naming Patterns
|
||||
|
||||
**Files:**
|
||||
- `kebab-case` for all TypeScript/TSX source files: `round-engine.ts`, `filtering-dashboard.tsx`, `ai-errors.ts`
|
||||
- `kebab-case` for route directories under `src/app/`: `(admin)/admin/juries/`, `[roundId]/`
|
||||
- Exception: Next.js reserved names remain as-is: `page.tsx`, `layout.tsx`
|
||||
|
||||
**Components:**
|
||||
- `PascalCase` for component functions: `FilteringDashboard`, `EmptyState`, `JuriesPage`
|
||||
- Page components follow the suffix pattern `XxxPage`: `JuriesPage`, `RoundDetailPage`, `AuditLogPage`
|
||||
- Sub-components within a file follow `XxxSection`, `XxxCard`, `XxxDialog`
|
||||
|
||||
**Functions:**
|
||||
- `camelCase` for all functions and methods: `activateRound`, `resolveEffectiveCap`, `createTestUser`
|
||||
- Service functions are named by operation + domain: `activateRound`, `closeRound`, `batchTransitionProjects`
|
||||
- Boolean functions prefixed with `is`, `has`, `should`, `can`: `shouldRetry`, `isParseError`, `shouldLog`
|
||||
|
||||
**Variables:**
|
||||
- `camelCase` for all local variables
|
||||
- `SCREAMING_SNAKE_CASE` for module-level constants: `BATCH_SIZE = 50`, `SYSTEM_DEFAULT_CAP`, `VALID_ROUND_TRANSITIONS`
|
||||
- Enum-like lookup objects in `SCREAMING_SNAKE_CASE`: `ERROR_PATTERNS`, `LOG_LEVELS`
|
||||
|
||||
**Types:**
|
||||
- `type` keyword preferred over `interface` per CLAUDE.md — but both exist in practice
|
||||
- `interface` is used for component props in some files (e.g., `ButtonProps`, `EmptyStateProps`), `type` used in others
|
||||
- Prisma-derived types use `type` aliases with `z.infer<typeof Schema>`: `type EvaluationConfig = z.infer<typeof EvaluationConfigSchema>`
|
||||
- Prop types: `type XxxProps = { ... }` (preferred in most components), `interface XxxProps { ... }` (used in deliberation, some UI components)
|
||||
- Export complex input types from `src/types/competition.ts`: `CreateCompetitionInput`, `UpdateRoundInput`
|
||||
|
||||
## Code Style
|
||||
|
||||
**Formatting:**
|
||||
- Prettier 3.4.2 with `prettier-plugin-tailwindcss` for class sorting
|
||||
- No `.prettierrc` found — uses Prettier defaults: 2-space indent, double quotes, trailing commas (ES5), 80-char print width
|
||||
- Single quotes confirmed absent in codebase: all string literals use double quotes
|
||||
- Tailwind classes sorted automatically by the plugin on format
|
||||
|
||||
**Linting:**
|
||||
- ESLint 9.x with `eslint-config-next` (Next.js configuration)
|
||||
- Run via `npm run lint` (calls `next lint`)
|
||||
- No custom rules file found — relies on Next.js default rules
|
||||
|
||||
**TypeScript:**
|
||||
- Strict mode enabled in `tsconfig.json` (`"strict": true`)
|
||||
- `noEmit: true` — TypeScript used for type checking only, not transpilation
|
||||
- Target: ES2022
|
||||
- Module resolution: `bundler` (Next.js Turbopack compatible)
|
||||
- Path alias: `@/*` maps to `./src/*`
|
||||
|
||||
## Import Organization
|
||||
|
||||
**Order (observed in client components):**
|
||||
1. `'use client'` directive (if needed)
|
||||
2. React/framework hooks: `import { useState, useEffect } from 'react'`
|
||||
3. Next.js imports: `import { useRouter } from 'next/navigation'`, `import Link from 'next/link'`
|
||||
4. tRPC client: `import { trpc } from '@/lib/trpc/client'`
|
||||
5. UI libraries: shadcn/ui components `import { Button } from '@/components/ui/button'`
|
||||
6. Icons: `import { Loader2, Save } from 'lucide-react'`
|
||||
7. Internal utilities/helpers: `import { cn } from '@/lib/utils'`
|
||||
8. Internal components: `import { FilteringDashboard } from '@/components/admin/round/...'`
|
||||
9. Types: `import type { EvaluationConfig } from '@/types/competition-configs'`
|
||||
|
||||
**Order (observed in server/service files):**
|
||||
1. `import { z } from 'zod'` (validation)
|
||||
2. `import { TRPCError } from '@trpc/server'` (errors)
|
||||
3. tRPC router/procedures: `import { router, adminProcedure } from '../trpc'`
|
||||
4. Internal services/utilities: `import { logAudit } from '@/server/utils/audit'`
|
||||
5. Type imports at end: `import type { PrismaClient } from '@prisma/client'`
|
||||
|
||||
**Path Aliases:**
|
||||
- Use `@/` prefix for all internal imports: `@/components/ui/button`, `@/server/services/round-engine`
|
||||
- Never use relative `../../` paths for cross-directory imports
|
||||
- Relative paths (`./`, `../`) only within the same directory level
|
||||
|
||||
## React Component Conventions
|
||||
|
||||
**Server vs Client Components:**
|
||||
- Default to **Server Components** — do not add `'use client'` unless needed
|
||||
- Layouts (`layout.tsx`) are server components: they call `await requireRole()`, fetch data directly from Prisma, and pass to client wrappers
|
||||
- Pages that use tRPC hooks, `useState`, or browser APIs must be `'use client'`
|
||||
- The pattern: server layout fetches session/editions → passes to client wrapper → client components handle interactive state
|
||||
|
||||
**Client Component Pattern:**
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type XxxProps = {
|
||||
competitionId: string
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export default function XxxPage() { ... }
|
||||
// Sub-components in same file as local functions (not exported)
|
||||
function XxxSection({ competition }: XxxSectionProps) { ... }
|
||||
```
|
||||
|
||||
**Server Layout Pattern:**
|
||||
```tsx
|
||||
import { requireRole } from '@/lib/auth-redirect'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await requireRole('SUPER_ADMIN', 'PROGRAM_ADMIN')
|
||||
const data = await prisma.program.findMany({ ... })
|
||||
return <ClientWrapper data={data}>{children}</ClientWrapper>
|
||||
}
|
||||
```
|
||||
|
||||
**Props with Params (Next.js 15):**
|
||||
```tsx
|
||||
type PageProps = {
|
||||
params: Promise<{ roundId: string; projectId: string }>
|
||||
}
|
||||
export default function Page({ params: paramsPromise }: PageProps) {
|
||||
const params = use(paramsPromise) // React.use() for async params
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## tRPC Router Conventions
|
||||
|
||||
**Procedure Selection:**
|
||||
- `adminProcedure` for CRUD on competition/round/jury entities
|
||||
- `protectedProcedure` for shared read access across roles
|
||||
- `juryProcedure` for jury-only operations
|
||||
- Role checks within procedure body use `userHasRole(ctx.user, 'ROLE')` for per-entity authorization
|
||||
|
||||
**Input Validation:**
|
||||
- All inputs validated with Zod `.input(z.object({ ... }))`
|
||||
- Use `.min()`, `.max()`, `.regex()` for strings
|
||||
- Use `.int().positive()` for ID/count integers
|
||||
- Use `.optional().nullable()` for optional fields with null support
|
||||
- Inline schema definition (not shared schema objects) per router
|
||||
|
||||
**Mutation Pattern:**
|
||||
```typescript
|
||||
create: adminProcedure
|
||||
.input(z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// 1. Check business rules (conflict, not found)
|
||||
const existing = await ctx.prisma.xxx.findUnique({ where: { slug: input.slug } })
|
||||
if (existing) {
|
||||
throw new TRPCError({ code: 'CONFLICT', message: '...' })
|
||||
}
|
||||
// 2. Perform operation
|
||||
const result = await ctx.prisma.xxx.create({ data: input })
|
||||
// 3. Audit log (for mutations)
|
||||
await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', ... })
|
||||
// 4. Return result
|
||||
return result
|
||||
})
|
||||
```
|
||||
|
||||
**Error Codes:**
|
||||
- `NOT_FOUND` — entity doesn't exist
|
||||
- `CONFLICT` — duplicate slug/unique constraint
|
||||
- `FORBIDDEN` — user lacks permission for specific entity
|
||||
- `UNAUTHORIZED` — not logged in (handled by middleware)
|
||||
- `BAD_REQUEST` — invalid business state (e.g., no active form)
|
||||
|
||||
## Error Handling
|
||||
|
||||
**tRPC Routers (user-facing errors):**
|
||||
- Always throw `TRPCError` with `{ code, message }` — never plain `throw new Error()`
|
||||
- Message should be human-readable: `'Competition not found'`, not `'Competition_NOT_FOUND'`
|
||||
- Use `findUniqueOrThrow` / `findFirstOrThrow` for implicit 404s on required relations
|
||||
|
||||
**Service Layer (internal errors):**
|
||||
- Services return result objects `{ success: boolean, errors?: string[] }` — they do NOT throw
|
||||
- Callers check `result.success` before proceeding
|
||||
- Error message pattern: `error instanceof Error ? error.message : 'Unknown error during X'`
|
||||
- Non-fatal side effects (notifications, emails) are wrapped in separate try/catch and logged but never propagate
|
||||
|
||||
**AI Services:**
|
||||
- Use `classifyAIError()` from `src/server/services/ai-errors.ts` for all OpenAI errors
|
||||
- Wrap AI calls with `withAIErrorHandling(fn, fallback)` for unified error + fallback handling
|
||||
- All AI errors logged with service tag: `console.error('[AI Assignment] failed:', ...)`
|
||||
|
||||
**Client-Side:**
|
||||
- Mutation errors displayed via `toast.error(err.message)` from Sonner
|
||||
- Success via `toast.success('...')` with query invalidation: `utils.xxx.yyy.invalidate()`
|
||||
- Loading states tracked via `mutation.isPending` and `query.isLoading`
|
||||
|
||||
## Logging
|
||||
|
||||
**Framework:** Custom structured logger at `src/lib/logger.ts`
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
import { logger } from '@/lib/logger'
|
||||
logger.info('RoundEngine', 'Round activated', { roundId, competitionId })
|
||||
logger.error('Storage', 'Upload failed', error)
|
||||
logger.warn('Filtering', 'Non-fatal error in document check', retroError)
|
||||
```
|
||||
|
||||
**Tag Convention:** `[ServiceName]` prefix in brackets — `'RoundEngine'`, `'AIFiltering'`, `'Storage'`
|
||||
|
||||
**Direct console usage** (still common in routers, not yet migrated to logger):
|
||||
- Tagged format: `console.log('[FeatureName] message', data)`
|
||||
- Always uses bracket tag: `'[Filtering]'`, `'[Assignment]'`, `'[COI]'`
|
||||
|
||||
**Log Level Defaults:**
|
||||
- Development: `debug` (all logs)
|
||||
- Production: `warn` (warns and errors only)
|
||||
- Override via `LOG_LEVEL` env var
|
||||
|
||||
## Comments
|
||||
|
||||
**When to Comment:**
|
||||
- All exported service functions get a JSDoc-style comment explaining purpose and invariants
|
||||
- Inline comments for non-obvious business logic: `// re-include after rejection`, `// Bounded to admin max`
|
||||
- Section header separators in large files using box-drawing chars: `// ─── Section Name ──────`
|
||||
- `// =====` separators for major logical sections in long files
|
||||
|
||||
**JSDoc/TSDoc:**
|
||||
- Used on exported functions in services and utilities
|
||||
- Standard `/**` block with plain description — no `@param`/`@returns` annotations in most code
|
||||
- Routers use `/** procedure description */` above each procedure for documentation
|
||||
|
||||
**TODO Comments:**
|
||||
- Present but sparse — only 3 found in entire codebase (deliberation page, award router)
|
||||
- Format: `// TODO: description`
|
||||
|
||||
## Function Design
|
||||
|
||||
**Size:** Service functions can be long (100-200+ lines) for complex state machines; router procedures typically 20-60 lines
|
||||
|
||||
**Parameters:**
|
||||
- Services accept `(entityId: string, actorId: string, prisma: PrismaClient)` — explicit prisma injection for testability
|
||||
- Router procedures destructure `{ ctx, input }` — never access `ctx.prisma` outside routers
|
||||
|
||||
**Return Values:**
|
||||
- Queries return data directly or throw `TRPCError`
|
||||
- Mutations return the created/updated record
|
||||
- Services return typed result objects: `RoundTransitionResult`, `BatchProjectTransitionResult`
|
||||
- Async service results always typed: `Promise<RoundTransitionResult>`
|
||||
|
||||
## Module Design
|
||||
|
||||
**Exports:**
|
||||
- Single responsibility: each service file exports one domain's functions
|
||||
- Named exports preferred over default exports for services and utilities
|
||||
- Default exports used only for React components (`export default function Page()`)
|
||||
|
||||
**Barrel Files:**
|
||||
- Used sparingly — only for chart components (`src/components/charts/index.ts`), form steps, and storage utilities
|
||||
- Most imports are direct path imports: `import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'`
|
||||
|
||||
**Prisma Transactions:**
|
||||
- Use `ctx.prisma.$transaction(async (tx) => { ... })` for multi-step mutations
|
||||
- Always pass `tx` (transaction client) through nested operations
|
||||
- Sequential array syntax `ctx.prisma.$transaction([op1, op2])` for simple atomic batches
|
||||
|
||||
---
|
||||
|
||||
*Convention analysis: 2026-02-26*
|
||||
213
.planning/codebase/INTEGRATIONS.md
Normal file
213
.planning/codebase/INTEGRATIONS.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# External Integrations
|
||||
|
||||
**Analysis Date:** 2026-02-26
|
||||
|
||||
## APIs & External Services
|
||||
|
||||
**AI Providers (configurable via SystemSettings DB table):**
|
||||
- OpenAI - AI filtering, jury assignment suggestions, evaluation summaries, project tagging, award eligibility, shortlist recommendations
|
||||
- SDK: `openai` ^6.16.0
|
||||
- Auth: `OPENAI_API_KEY` env var or `openai_api_key` SystemSetting
|
||||
- Base URL: `OPENAI_BASE_URL` env var or `openai_base_url` SystemSetting (for OpenAI-compatible proxies)
|
||||
- Model: `OPENAI_MODEL` env var or `ai_model` SystemSetting (default: `gpt-4o`)
|
||||
- Client: `src/lib/openai.ts` - lazy singleton, reset via `resetOpenAIClient()`
|
||||
|
||||
- Anthropic Claude - Alternative AI provider, same AI feature set as OpenAI
|
||||
- SDK: `@anthropic-ai/sdk` ^0.78.0
|
||||
- Auth: `ANTHROPIC_API_KEY` env var or `anthropic_api_key` SystemSetting
|
||||
- Adapter: `src/lib/openai.ts` wraps Anthropic SDK behind OpenAI `.chat.completions.create()` interface
|
||||
- Supported models: `claude-opus-4-5-20250514`, `claude-sonnet-4-5-20250514`, `claude-haiku-3-5-20241022`, `claude-opus-4-20250514`, `claude-sonnet-4-20250514`
|
||||
- Extended thinking enabled automatically for Opus models
|
||||
|
||||
- LiteLLM proxy - Third option for ChatGPT subscription routing (no real API key needed)
|
||||
- Config: `ai_provider = 'litellm'` in SystemSettings + `openai_base_url` pointing to proxy
|
||||
- Token limit fields stripped for `chatgpt/*` model prefix
|
||||
|
||||
- AI provider selection: `getConfiguredProvider()` in `src/lib/openai.ts` reads `ai_provider` SystemSetting; defaults to `openai`
|
||||
|
||||
**All AI data is anonymized before sending** via `src/server/services/anonymization.ts`
|
||||
|
||||
**Notion:**
|
||||
- Used for project import (alternative to CSV)
|
||||
- SDK: `@notionhq/client` ^2.3.0
|
||||
- Auth: API key stored in SystemSettings (`notion_api_key`), per-import flow
|
||||
- Client: `src/lib/notion.ts` - `createNotionClient(apiKey)` per-request (not singleton)
|
||||
- Router: `src/server/routers/notion-import.ts`
|
||||
|
||||
**Typeform:**
|
||||
- Used for project import from form responses
|
||||
- Auth: API key stored in SystemSettings per-import
|
||||
- Client: `src/lib/typeform.ts` - plain fetch against `https://api.typeform.com`, no SDK
|
||||
- Router: `src/server/routers/typeform-import.ts`
|
||||
|
||||
**WhatsApp (optional, configurable):**
|
||||
- Two provider options: Meta WhatsApp Business Cloud API or Twilio WhatsApp
|
||||
- Meta provider: `src/lib/whatsapp/meta-provider.ts`
|
||||
- Twilio provider: `src/lib/whatsapp/twilio-provider.ts`
|
||||
- Abstraction: `src/lib/whatsapp/index.ts` - `getWhatsAppProvider()` reads `whatsapp_provider` SystemSetting
|
||||
- Auth: API keys stored in SystemSettings (`whatsapp_enabled`, `whatsapp_provider`, provider-specific keys)
|
||||
- Used for: notification delivery (alternative to email)
|
||||
|
||||
## Data Storage
|
||||
|
||||
**Databases:**
|
||||
- PostgreSQL 16 (primary datastore)
|
||||
- Connection: `DATABASE_URL` env var (e.g., `postgresql://mopc:${password}@postgres:5432/mopc`)
|
||||
- Client: Prisma 6 ORM, `src/lib/prisma.ts` singleton with connection pool (limit=20, timeout=10)
|
||||
- Schema: `prisma/schema.prisma` (~95KB, ~100+ models)
|
||||
- Migrations: `prisma/migrations/` directory, deployed via `prisma migrate deploy` on startup
|
||||
- Test DB: `DATABASE_URL_TEST` env var (falls back to `DATABASE_URL` in test setup)
|
||||
|
||||
**File Storage:**
|
||||
- MinIO (S3-compatible, self-hosted on VPS)
|
||||
- Internal endpoint: `MINIO_ENDPOINT` env var (server-to-server)
|
||||
- Public endpoint: `MINIO_PUBLIC_ENDPOINT` env var (browser-accessible pre-signed URLs)
|
||||
- Auth: `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`
|
||||
- Bucket: `MINIO_BUCKET` (default: `mopc-files`)
|
||||
- Client: `src/lib/minio.ts` - lazy singleton via Proxy, `getMinioClient()`
|
||||
- Access pattern: pre-signed URLs only (15-minute expiry by default), never direct public bucket
|
||||
- Key structure: `{ProjectName}/{RoundName}/{timestamp}-{fileName}`
|
||||
- File types stored: EXEC_SUMMARY, PRESENTATION, VIDEO, BUSINESS_PLAN, VIDEO_PITCH, SUPPORTING_DOC, OTHER
|
||||
|
||||
**Caching:**
|
||||
- None (in-memory rate limiter in `src/lib/rate-limit.ts`, not a caching layer)
|
||||
- Note: rate limiter is in-memory only — not suitable for multi-instance deployments
|
||||
|
||||
## Authentication & Identity
|
||||
|
||||
**Auth Provider: NextAuth v5 (self-hosted)**
|
||||
- Implementation: `src/lib/auth.ts` + `src/lib/auth.config.ts`
|
||||
- Adapter: `@auth/prisma-adapter` (stores sessions/tokens in PostgreSQL)
|
||||
- Strategy: JWT sessions (24-hour default, configurable via `SESSION_MAX_AGE`)
|
||||
- Session includes: `user.id`, `user.email`, `user.name`, `user.role`, `user.roles[]`, `user.mustSetPassword`
|
||||
|
||||
**Auth Providers:**
|
||||
1. Email (Magic Links)
|
||||
- NextAuth `EmailProvider` — magic link sent via Nodemailer
|
||||
- Link expiry: 15 minutes (`MAGIC_LINK_EXPIRY` env var or default 900s)
|
||||
- Custom send function: `sendMagicLinkEmail()` in `src/lib/email.ts`
|
||||
2. Credentials (Password + Invite Token)
|
||||
- Email/password with bcryptjs hashing (`src/lib/password.ts`)
|
||||
- Invite token flow: one-time token clears on first use, sets `mustSetPassword: true`
|
||||
- Failed login tracking: 5-attempt lockout, 15-minute duration (in-memory, not persistent)
|
||||
- `mustSetPassword` flag forces redirect to `/set-password` before any other page
|
||||
|
||||
**Role System:**
|
||||
- User model has `role` (primary, legacy scalar) and `roles` (array, multi-role)
|
||||
- `userHasRole()` helper in `src/server/trpc.ts` checks `roles[]` with `[role]` fallback
|
||||
- 8 roles: `SUPER_ADMIN`, `PROGRAM_ADMIN`, `JURY_MEMBER`, `MENTOR`, `OBSERVER`, `APPLICANT`, `AWARD_MASTER`, `AUDIENCE`
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
**Error Tracking:**
|
||||
- Not detected (no Sentry, Datadog, or similar third-party service)
|
||||
|
||||
**Logs:**
|
||||
- Custom structured logger: `src/lib/logger.ts`
|
||||
- Tagged format: `{timestamp} [LEVEL] [Tag] message data`
|
||||
- Levels: debug, info, warn, error
|
||||
- Default: `debug` in development, `warn` in production
|
||||
- Configurable via `LOG_LEVEL` env var
|
||||
- All output to `console.*` (stdout/stderr)
|
||||
|
||||
**Audit Logging:**
|
||||
- All auth events and admin mutations logged to `AuditLog` DB table
|
||||
- `src/server/utils/audit.ts` - `logAudit()` helper
|
||||
- Events tracked: LOGIN_SUCCESS, LOGIN_FAILED, INVITATION_ACCEPTED, all CRUD operations on major entities
|
||||
- Cron for cleanup: `src/app/api/cron/audit-cleanup/`
|
||||
|
||||
**Application Metrics:**
|
||||
- Health check endpoint: `GET /api/health` (used by Docker healthcheck)
|
||||
|
||||
## CI/CD & Deployment
|
||||
|
||||
**Hosting:**
|
||||
- Self-hosted VPS with Docker
|
||||
- Nginx reverse proxy with SSL (external to compose stack)
|
||||
- Domain: `monaco-opc.com`
|
||||
|
||||
**CI Pipeline:**
|
||||
- Gitea Actions (self-hosted Gitea at `code.monaco-opc.com/MOPC/MOPC-Portal`)
|
||||
- Pipeline builds Docker image and pushes to private container registry
|
||||
- `REGISTRY_URL` env var configures the registry in `docker/docker-compose.yml`
|
||||
|
||||
**Docker Setup:**
|
||||
- Production: `docker/docker-compose.yml` — app + postgres services
|
||||
- Dev: `docker/docker-compose.dev.yml` — dev stack variant
|
||||
- App image: standalone Next.js build (`output: 'standalone'`)
|
||||
- Entrypoint: `docker/docker-entrypoint.sh` — migrations → generate → seed → start
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**Required env vars:**
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `NEXTAUTH_URL` - Full URL of the app (e.g., `https://monaco-opc.com`)
|
||||
- `NEXTAUTH_SECRET` / `AUTH_SECRET` - JWT signing secret
|
||||
- `MINIO_ENDPOINT` - MinIO server URL (internal)
|
||||
- `MINIO_ACCESS_KEY` - MinIO access key
|
||||
- `MINIO_SECRET_KEY` - MinIO secret key
|
||||
- `MINIO_BUCKET` - MinIO bucket name
|
||||
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` - SMTP credentials
|
||||
- `EMAIL_FROM` - Sender address
|
||||
- `CRON_SECRET` - Shared secret for cron endpoint authentication
|
||||
|
||||
**Optional env vars:**
|
||||
- `MINIO_PUBLIC_ENDPOINT` - Public-facing MinIO URL for pre-signed URLs
|
||||
- `OPENAI_API_KEY` - OpenAI API key (also in SystemSettings)
|
||||
- `OPENAI_MODEL` - Default AI model (default: `gpt-4o`)
|
||||
- `OPENAI_BASE_URL` - Custom base URL for OpenAI-compatible providers
|
||||
- `ANTHROPIC_API_KEY` - Anthropic Claude API key
|
||||
- `POSTE_API_URL` - Poste.io mail server API URL
|
||||
- `POSTE_ADMIN_EMAIL`, `POSTE_ADMIN_PASSWORD`, `POSTE_MAIL_DOMAIN` - Poste.io admin
|
||||
- `SESSION_MAX_AGE` - JWT session duration in seconds (default: 86400)
|
||||
- `MAX_FILE_SIZE` - Max upload size in bytes (default: 524288000 = 500MB)
|
||||
- `LOG_LEVEL` - Logging verbosity (debug/info/warn/error)
|
||||
- `MAGIC_LINK_EXPIRY` - Magic link lifetime in seconds (default: 900)
|
||||
|
||||
**Secrets location:**
|
||||
- `.env` file at repo root (read by Docker Compose via `env_file: .env`)
|
||||
- Runtime secrets also configurable via `SystemSettings` DB table (admin UI)
|
||||
|
||||
## Webhooks & Callbacks
|
||||
|
||||
**Incoming:**
|
||||
- `/api/auth/[...nextauth]` - NextAuth callback routes (magic link verification, OAuth if added)
|
||||
- No third-party webhook receivers detected
|
||||
|
||||
**Outgoing:**
|
||||
- Configurable webhooks via `Webhook` DB model and `src/server/services/webhook-dispatcher.ts`
|
||||
- Admin-managed via `src/server/routers/webhook.ts` (SUPER_ADMIN only)
|
||||
- Signed with HMAC-SHA256 (`X-Webhook-Signature: sha256={sig}`)
|
||||
- Events dispatched: `evaluation.submitted`, `evaluation.updated`, `project.created`, `project.statusChanged`, `round.activated`, `round.closed`, `assignment.created`, `assignment.completed`, `user.invited`, `user.activated`
|
||||
- Retry logic: configurable max retries per webhook (0–10), retry via cron
|
||||
|
||||
## Real-Time
|
||||
|
||||
**Server-Sent Events (SSE):**
|
||||
- Endpoint: `/api/sse/` - in-app notifications push
|
||||
- Used for: real-time notification delivery to connected clients
|
||||
|
||||
**Live Voting Stream:**
|
||||
- Endpoint: `/api/live-voting/stream/` - SSE stream for live ceremony voting cursor
|
||||
- Service: `src/server/services/live-control.ts`
|
||||
|
||||
## Cron Jobs
|
||||
|
||||
All cron endpoints protected by `CRON_SECRET` header check:
|
||||
- `GET /api/cron/reminders` - Evaluation reminders via `src/server/services/evaluation-reminders.ts`
|
||||
- `GET /api/cron/digest` - Email digests via `src/server/services/email-digest.ts`
|
||||
- `GET /api/cron/draft-cleanup` - Remove stale draft evaluations
|
||||
- `GET /api/cron/audit-cleanup` - Purge old audit log entries
|
||||
|
||||
## Email
|
||||
|
||||
**SMTP Transport:**
|
||||
- Provider: Poste.io (self-hosted mail server, port 587)
|
||||
- Client: Nodemailer 7 via `src/lib/email.ts`
|
||||
- Config priority: SystemSettings DB > env vars
|
||||
- Transporter cached, rebuilt when config hash changes
|
||||
- Error handling: email errors logged but never thrown (non-blocking)
|
||||
|
||||
---
|
||||
|
||||
*Integration audit: 2026-02-26*
|
||||
141
.planning/codebase/STACK.md
Normal file
141
.planning/codebase/STACK.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Technology Stack
|
||||
|
||||
**Analysis Date:** 2026-02-26
|
||||
|
||||
## Languages
|
||||
|
||||
**Primary:**
|
||||
- TypeScript 5.7 - All application code (strict mode, `noEmit`, `ES2022` target)
|
||||
|
||||
**Secondary:**
|
||||
- CSS (Tailwind utility classes only, no custom CSS files)
|
||||
|
||||
## Runtime
|
||||
|
||||
**Environment:**
|
||||
- Node.js >=20.0.0 (engines field in `package.json`)
|
||||
|
||||
**Package Manager:**
|
||||
- npm (standard)
|
||||
- Lockfile: `package-lock.json` present
|
||||
|
||||
## Frameworks
|
||||
|
||||
**Core:**
|
||||
- Next.js 15.1 - App Router, standalone output, Turbopack dev mode
|
||||
- React 19.0 - Server Components by default; `'use client'` only where needed
|
||||
|
||||
**API Layer:**
|
||||
- tRPC 11 (RC build `11.0.0-rc.678`) - End-to-end typed RPC, superjson transformer
|
||||
- `@trpc/server`, `@trpc/client`, `@trpc/react-query` - All at same version
|
||||
|
||||
**Data:**
|
||||
- Prisma 6.19 - ORM and schema-first migrations; binary targets: `native`, `windows`, `linux-musl-openssl-3.0.x`
|
||||
- `@prisma/client` 6.19 - Generated client with connection pool (limit=20, timeout=10)
|
||||
|
||||
**Auth:**
|
||||
- NextAuth v5 (Beta 25) - JWT strategy, 24-hour sessions; Prisma adapter via `@auth/prisma-adapter`
|
||||
|
||||
**Forms & Validation:**
|
||||
- Zod 3.24 - Input validation for all tRPC procedures
|
||||
- React Hook Form 7.54 - Client-side form state; `@hookform/resolvers` for Zod integration
|
||||
|
||||
**UI Components:**
|
||||
- shadcn/ui (configured via `components.json`) - Radix UI primitives styled with Tailwind
|
||||
- Radix UI primitives: alert-dialog, avatar, checkbox, collapsible, dialog, dropdown-menu, label, popover, progress, radio-group, scroll-area, select, separator, slider, slot, switch, tabs, toggle, tooltip (all `^1.x` or `^2.x`)
|
||||
- Tailwind CSS 4.1 - Utility-first, `@tailwindcss/postcss` plugin
|
||||
- Lucide React 0.563 - Icon library (import-optimized via `next.config.ts`)
|
||||
- Framer Motion 11 (`motion` package) - Animation
|
||||
- Tremor 3.18 - Data visualization / chart components
|
||||
- `@blocknote/react`, `@blocknote/core`, `@blocknote/shadcn` 0.46 - Rich text block editor
|
||||
- next-themes 0.4 - Dark/light mode switching
|
||||
- Sonner 2.0 - Toast notifications
|
||||
|
||||
**Testing:**
|
||||
- Vitest 4.0 - Test runner, `fileParallelism: false`, `pool: 'forks'`
|
||||
- `@playwright/test` 1.49 - E2E test runner
|
||||
|
||||
**Build/Dev:**
|
||||
- Turbopack (built into Next.js 15) - Dev server via `next dev --turbopack`
|
||||
- tsx 4.19 - Direct TypeScript execution for scripts and seeds
|
||||
- ESLint 9.17 + `eslint-config-next` 15.1 - Linting
|
||||
- Prettier 3.4 + `prettier-plugin-tailwindcss` 0.7 - Formatting
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
**Critical:**
|
||||
- `superjson` 2.2 - tRPC transformer; enables Date, Map, Set serialization over the wire
|
||||
- `bcryptjs` 3.0 - Password hashing (no native bcrypt — pure JS for portability)
|
||||
- `minio` 8.0 - S3-compatible object storage client
|
||||
- `nodemailer` 7.0 - SMTP email delivery
|
||||
- `openai` 6.16 - OpenAI SDK for AI features
|
||||
- `@anthropic-ai/sdk` 0.78 - Anthropic Claude SDK; wrapped in adapter matching OpenAI interface
|
||||
- `@notionhq/client` 2.3 - Notion API for project import
|
||||
- `csv-parse` 6.1 - CSV import for candidatures seed
|
||||
|
||||
**Infrastructure:**
|
||||
- `date-fns` 4.1 - Date manipulation
|
||||
- `use-debounce` 10.0 - Input debouncing
|
||||
- `@tanstack/react-query` 5.62 - Server state caching (used via tRPC)
|
||||
- `@dnd-kit/core`, `@dnd-kit/sortable` - Drag-and-drop ordering UI
|
||||
- `leaflet` 1.9 + `react-leaflet` 5.0 - Map rendering
|
||||
- `mammoth` 1.11 - DOCX to HTML conversion for file content extraction
|
||||
- `pdf-parse` 2.4, `unpdf` 1.4 - PDF text extraction
|
||||
- `html2canvas` 1.4, `jspdf` 4.1, `jspdf-autotable` 5.0 - PDF export for reports
|
||||
- `franc` 6.2 - Language detection for multilingual project content
|
||||
- `papaparse` 5.4 - CSV parsing in browser
|
||||
- `cmdk` 1.0 - Command palette component
|
||||
- `react-easy-crop` 5.5 - Avatar image cropping
|
||||
- `react-phone-number-input` 3.4 - International phone number input
|
||||
- `react-day-picker` 9.13 - Date picker calendar
|
||||
|
||||
## Configuration
|
||||
|
||||
**TypeScript:**
|
||||
- `src/tsconfig.json`: strict mode, `ES2022` target, path alias `@/*` → `./src/*`, bundler module resolution
|
||||
- Config file: `tsconfig.json`
|
||||
|
||||
**Next.js:**
|
||||
- Config file: `next.config.ts`
|
||||
- `output: 'standalone'` for Docker deployment
|
||||
- `typedRoutes: true` for compile-time route safety
|
||||
- `serverExternalPackages: ['@prisma/client', 'minio']` — not bundled
|
||||
|
||||
**Tailwind:**
|
||||
- Config file: `tailwind.config.ts`
|
||||
- PostCSS via `postcss.config.mjs` + `@tailwindcss/postcss`
|
||||
- Brand palette: Primary Red `#de0f1e`, Dark Blue `#053d57`, White `#fefefe`, Teal `#557f8c`
|
||||
|
||||
**Vitest:**
|
||||
- Config file: `vitest.config.ts`
|
||||
- `environment: 'node'`, `testTimeout: 30000`, sequential execution
|
||||
- Path alias mirrors tsconfig
|
||||
|
||||
**Environment:**
|
||||
- Required vars: `DATABASE_URL`, `NEXTAUTH_URL`, `NEXTAUTH_SECRET`, `MINIO_ENDPOINT`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`, `MINIO_BUCKET`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `EMAIL_FROM`, `OPENAI_API_KEY`, `CRON_SECRET`
|
||||
- Optional vars: `MINIO_PUBLIC_ENDPOINT`, `OPENAI_MODEL` (default: `gpt-4o`), `OPENAI_BASE_URL`, `ANTHROPIC_API_KEY`, `SESSION_MAX_AGE` (default: 86400), `MAX_FILE_SIZE` (default: 524288000), `LOG_LEVEL`, `MAGIC_LINK_EXPIRY` (default: 900)
|
||||
- All settings also configurable via `SystemSettings` DB table (DB takes priority over env vars)
|
||||
|
||||
**Build:**
|
||||
- `npm run build` → `next build` → produces `.next/standalone/` output
|
||||
- `npm run typecheck` → `tsc --noEmit` (no emit, type checking only)
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
**Development:**
|
||||
- Node.js >=20.0.0
|
||||
- PostgreSQL 16 (via Docker or local)
|
||||
- MinIO instance (optional in dev, defaults to `localhost:9000`)
|
||||
|
||||
**Production:**
|
||||
- Docker (compose file: `docker/docker-compose.yml`)
|
||||
- PostgreSQL 16 (`postgres:16-alpine` image) in Docker network
|
||||
- Next.js app runs as standalone Node.js server on port 7600
|
||||
- MinIO and Poste.io are external pre-existing services on VPS
|
||||
- Nginx reverse proxy with SSL (external, not in compose)
|
||||
- CI/CD: Gitea Actions (image pushed to container registry, `pull_policy: always`)
|
||||
- App entrypoint (`docker/docker-entrypoint.sh`): runs `prisma migrate deploy` → `prisma generate` → auto-seeds if DB empty → `node server.js`
|
||||
|
||||
---
|
||||
|
||||
*Stack analysis: 2026-02-26*
|
||||
327
.planning/codebase/STRUCTURE.md
Normal file
327
.planning/codebase/STRUCTURE.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# Codebase Structure
|
||||
|
||||
**Analysis Date:** 2026-02-26
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
MOPC/
|
||||
├── prisma/ # Database schema and migrations
|
||||
│ ├── schema.prisma # Single source of truth for all models
|
||||
│ ├── seed.ts # Seed script (imports from docs/CSV files)
|
||||
│ └── migrations/ # Prisma migration history (auto-generated)
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router — all routes
|
||||
│ │ ├── (admin)/ # Admin dashboard route group (SUPER_ADMIN, PROGRAM_ADMIN)
|
||||
│ │ ├── (jury)/ # Jury evaluation route group (JURY_MEMBER)
|
||||
│ │ ├── (applicant)/ # Applicant dashboard route group (APPLICANT)
|
||||
│ │ ├── (mentor)/ # Mentor workspace route group (MENTOR)
|
||||
│ │ ├── (observer)/ # Observer read-only route group (OBSERVER)
|
||||
│ │ ├── (auth)/ # Public auth pages (login, verify, onboarding)
|
||||
│ │ ├── (public)/ # Fully public pages (apply, vote, live-scores)
|
||||
│ │ ├── (settings)/ # User settings (profile)
|
||||
│ │ └── api/ # API routes (tRPC, auth, cron, SSE, files, health)
|
||||
│ ├── components/ # React components organized by domain
|
||||
│ │ ├── admin/ # Admin-specific components
|
||||
│ │ ├── jury/ # Jury-specific components
|
||||
│ │ ├── applicant/ # Applicant-specific components
|
||||
│ │ ├── mentor/ # Mentor-specific components
|
||||
│ │ ├── observer/ # Observer-specific components
|
||||
│ │ ├── public/ # Public-facing components
|
||||
│ │ ├── forms/ # Shared form components (apply wizard, COI dialog)
|
||||
│ │ ├── charts/ # Chart/visualization components
|
||||
│ │ ├── dashboard/ # Dashboard widgets
|
||||
│ │ ├── layouts/ # Navigation layouts per role (sidebar, nav bars)
|
||||
│ │ ├── shared/ # Reusable cross-domain components
|
||||
│ │ └── ui/ # shadcn/ui primitives
|
||||
│ ├── server/ # Server-only code
|
||||
│ │ ├── routers/ # tRPC domain routers (44+ files)
|
||||
│ │ │ └── _app.ts # Root router composing all domains
|
||||
│ │ ├── services/ # Business logic services
|
||||
│ │ ├── utils/ # Server utilities (audit, ai-usage, image-upload)
|
||||
│ │ ├── trpc.ts # tRPC init, middleware, procedure types
|
||||
│ │ └── context.ts # tRPC context factory (session + prisma + IP)
|
||||
│ ├── lib/ # Shared libraries (client + server)
|
||||
│ │ ├── trpc/ # tRPC client (client.ts), server caller (server.ts)
|
||||
│ │ ├── storage/ # Storage provider abstraction (S3/local)
|
||||
│ │ ├── whatsapp/ # WhatsApp notification client
|
||||
│ │ ├── auth.ts # NextAuth full configuration
|
||||
│ │ ├── auth.config.ts # Edge-compatible auth config (middleware)
|
||||
│ │ ├── auth-redirect.ts # requireRole() server helper
|
||||
│ │ ├── prisma.ts # Prisma singleton with connection pooling
|
||||
│ │ ├── logger.ts # Structured logger (tagged, level-aware)
|
||||
│ │ ├── email.ts # Nodemailer email sender
|
||||
│ │ ├── minio.ts # MinIO client initialization
|
||||
│ │ ├── openai.ts # OpenAI client initialization
|
||||
│ │ ├── rate-limit.ts # In-memory rate limiter
|
||||
│ │ ├── feature-flags.ts # DB-backed feature flags
|
||||
│ │ ├── round-config.ts # Round config helper utilities
|
||||
│ │ ├── utils.ts # General utilities (cn, formatters)
|
||||
│ │ └── [others] # countries, pdf-generator, typeform, notion, etc.
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ │ ├── competition.ts # Composite types for Competition/Round domain
|
||||
│ │ ├── competition-configs.ts # Per-RoundType Zod schemas + inferred types
|
||||
│ │ └── wizard-config.ts # Application wizard configuration types
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ │ ├── use-debounce.ts
|
||||
│ │ ├── use-live-voting-sse.ts # SSE subscription for live voting
|
||||
│ │ └── use-stage-live-sse.ts # SSE subscription for live stage
|
||||
│ └── contexts/ # React contexts
|
||||
│ └── edition-context.tsx # Edition/Program selector context
|
||||
├── tests/ # Test files (Vitest)
|
||||
│ ├── setup.ts # Test setup (prisma client, helpers)
|
||||
│ ├── helpers.ts # Test factories (createTestUser, createTestCompetition, etc.)
|
||||
│ └── unit/ # Unit test files
|
||||
├── docs/ # Internal documentation and architecture notes
|
||||
├── docker/ # Docker Compose configs and Nginx config
|
||||
├── public/ # Static assets (fonts, images, maps)
|
||||
├── scripts/ # Utility scripts
|
||||
├── middleware.ts # Next.js edge middleware (auth check)
|
||||
├── next.config.ts # Next.js config (standalone output, legacy redirects)
|
||||
└── prisma/ # (see above)
|
||||
```
|
||||
|
||||
## Directory Purposes
|
||||
|
||||
**`src/app/(admin)/admin/`:**
|
||||
- Purpose: All admin pages behind SUPER_ADMIN/PROGRAM_ADMIN role gate
|
||||
- Contains: Competition management, round config, project management, jury groups, members, programs, reports, audit, awards, settings, messages, mentors, partners, learning
|
||||
- Key files: `layout.tsx` (role guard + edition selector), `admin/page.tsx` (dashboard), `admin/rounds/[roundId]/page.tsx` (round detail — largest page)
|
||||
|
||||
**`src/app/(jury)/jury/`:**
|
||||
- Purpose: Jury evaluation interface behind JURY_MEMBER role gate
|
||||
- Contains: Competitions list, round overview, project list, evaluate page, deliberation, live voting, learning resources, awards
|
||||
- Key files: `layout.tsx` (role guard + onboarding check), `competitions/[roundId]/projects/[projectId]/evaluate/page.tsx` (evaluation form)
|
||||
|
||||
**`src/app/(applicant)/applicant/`:**
|
||||
- Purpose: Applicant dashboard behind APPLICANT role gate
|
||||
- Contains: Competition progress, documents, evaluations received, mentor chat, resources, team
|
||||
- Key files: `layout.tsx`, `applicant/page.tsx`
|
||||
|
||||
**`src/app/(mentor)/mentor/`:**
|
||||
- Purpose: Mentor workspace behind MENTOR role gate
|
||||
- Contains: Project list, workspace per project, resources
|
||||
- Key files: `layout.tsx`, `mentor/workspace/[projectId]/page.tsx`
|
||||
|
||||
**`src/app/(observer)/observer/`:**
|
||||
- Purpose: Read-only view behind OBSERVER role gate
|
||||
- Contains: Projects, reports
|
||||
- Key files: `layout.tsx`
|
||||
|
||||
**`src/app/(public)/`:**
|
||||
- Purpose: No-auth-required pages
|
||||
- Contains: Application form (`apply/[slug]`), edition application (`apply/edition/[programSlug]`), live scores display (`live-scores/[sessionId]`), audience vote (`vote/[sessionId]`), submission status (`my-submission/[id]`), email change password
|
||||
- Key files: `apply/[slug]/page.tsx` (application wizard)
|
||||
|
||||
**`src/app/(auth)/`:**
|
||||
- Purpose: Auth flow pages
|
||||
- Contains: Login, verify (magic link), verify-email, accept-invite, onboarding, set-password, error
|
||||
- Key files: `login/page.tsx`, `onboarding/page.tsx`, `accept-invite/page.tsx`
|
||||
|
||||
**`src/app/api/`:**
|
||||
- Purpose: Next.js route handlers for non-tRPC API
|
||||
- Contains:
|
||||
- `trpc/[trpc]/` — tRPC HTTP adapter (GET + POST)
|
||||
- `auth/[...nextauth]/` — NextAuth handler
|
||||
- `cron/` — Cron job endpoints (audit-cleanup, digest, draft-cleanup, reminders)
|
||||
- `live-voting/stream/` — SSE stream for live voting
|
||||
- `files/bulk-download/` — Bulk file download handler
|
||||
- `storage/local/` — Local dev storage handler
|
||||
- `health/` — DB health check endpoint
|
||||
|
||||
**`src/server/routers/`:**
|
||||
- Purpose: tRPC domain routers, one file per domain
|
||||
- Contains: 44+ router files assembled in `_app.ts`
|
||||
- Key files: `competition.ts`, `round.ts`, `roundEngine.ts`, `evaluation.ts`, `filtering.ts`, `deliberation.ts`, `resultLock.ts`, `roundAssignment.ts`, `assignment.ts`, `project.ts`, `user.ts`, `program.ts`
|
||||
|
||||
**`src/server/services/`:**
|
||||
- Purpose: All business logic — state machines, AI integrations, external service calls
|
||||
- Contains:
|
||||
- `round-engine.ts` — Round and project state machine
|
||||
- `deliberation.ts` — Deliberation session lifecycle (DELIB_OPEN → VOTING → TALLYING → DELIB_LOCKED)
|
||||
- `round-assignment.ts` — Jury assignment generation with policy enforcement
|
||||
- `smart-assignment.ts` — Scoring algorithm (tag overlap, bio match, workload, geo-diversity, COI, availability)
|
||||
- `submission-manager.ts` — Submission window lifecycle and file requirement enforcement
|
||||
- `result-lock.ts` — Immutable result locking with snapshot
|
||||
- `live-control.ts` — Live ceremony cursor management
|
||||
- `competition-context.ts` — Cross-cutting context resolver
|
||||
- `ai-filtering.ts`, `ai-assignment.ts`, `ai-evaluation-summary.ts`, `ai-tagging.ts`, `ai-award-eligibility.ts`, `ai-shortlist.ts` — AI feature services
|
||||
- `anonymization.ts` — PII stripping before AI calls
|
||||
- `notification.ts`, `in-app-notification.ts`, `evaluation-reminders.ts`, `email-digest.ts` — Notification services
|
||||
- `assignment-policy.ts`, `assignment-intent.ts` — Policy governance for assignments
|
||||
- `mentor-matching.ts`, `mentor-workspace.ts` — Mentor domain services
|
||||
|
||||
**`src/components/admin/round/`:**
|
||||
- Purpose: Components for the round detail page (the most complex admin page)
|
||||
- Key files: `filtering-dashboard.tsx`, `project-states-table.tsx`
|
||||
|
||||
**`src/components/admin/rounds/config/`:**
|
||||
- Purpose: Per-RoundType config form sections
|
||||
- Contains: Config UI for each round type (`intake-config.tsx`, `evaluation-config.tsx`, etc.)
|
||||
|
||||
**`src/components/shared/`:**
|
||||
- Purpose: Domain-agnostic reusable components
|
||||
- Contains: `page-header.tsx`, `status-badge.tsx`, `file-upload.tsx`, `file-viewer.tsx`, `pagination.tsx`, `notification-bell.tsx`, `edition-selector.tsx`, `empty-state.tsx`, `loading-spinner.tsx`, and others
|
||||
|
||||
**`src/components/ui/`:**
|
||||
- Purpose: shadcn/ui primitive components (never modified directly)
|
||||
- Contains: `button.tsx`, `card.tsx`, `dialog.tsx`, `form.tsx`, `select.tsx`, `table.tsx`, etc.
|
||||
|
||||
**`src/components/layouts/`:**
|
||||
- Purpose: Role-specific navigation shells
|
||||
- Contains: `admin-sidebar.tsx`, `jury-nav.tsx`, `mentor-nav.tsx`, `observer-nav.tsx`, `applicant-nav.tsx`, `role-nav.tsx`, `admin-edition-wrapper.tsx`
|
||||
|
||||
**`src/lib/trpc/`:**
|
||||
- Purpose: tRPC client configuration
|
||||
- Contains:
|
||||
- `client.ts` — `createTRPCReact<AppRouter>()` export (client components use `import { trpc } from '@/lib/trpc/client'`)
|
||||
- `server.ts` — Server-side caller for Server Components
|
||||
- `index.ts` — Provider setup (TRPCProvider + QueryClientProvider)
|
||||
|
||||
**`src/types/`:**
|
||||
- Purpose: Shared TypeScript types not generated by Prisma
|
||||
- Contains:
|
||||
- `competition.ts` — Composite types with nested relations (e.g., `CompetitionWithRounds`, `RoundWithRelations`)
|
||||
- `competition-configs.ts` — Per-RoundType Zod config schemas and inferred TypeScript types
|
||||
- `wizard-config.ts` — Application wizard step configuration types
|
||||
|
||||
## Key File Locations
|
||||
|
||||
**Entry Points:**
|
||||
- `middleware.ts` — Edge middleware (auth check before every request)
|
||||
- `src/app/api/trpc/[trpc]/route.ts` — tRPC HTTP handler
|
||||
- `src/app/api/auth/[...nextauth]/route.ts` — Auth handler
|
||||
- `src/server/routers/_app.ts` — Root tRPC router
|
||||
|
||||
**Configuration:**
|
||||
- `prisma/schema.prisma` — Database schema
|
||||
- `next.config.ts` — Next.js configuration + legacy route redirects
|
||||
- `src/lib/auth.config.ts` — Edge-compatible NextAuth config + Session type augmentations
|
||||
- `src/lib/auth.ts` — Full NextAuth configuration with providers
|
||||
- `src/server/trpc.ts` — tRPC initialization and all procedure type definitions
|
||||
- `src/server/context.ts` — tRPC context (session, prisma, ip, userAgent)
|
||||
- `tsconfig.json` — TypeScript strict mode config with `@/` path alias
|
||||
|
||||
**Core Logic:**
|
||||
- `src/server/services/round-engine.ts` — Round state machine
|
||||
- `src/server/services/deliberation.ts` — Deliberation state machine
|
||||
- `src/server/services/round-assignment.ts` — Assignment generation
|
||||
- `src/server/services/smart-assignment.ts` — Scoring algorithm
|
||||
- `src/server/services/competition-context.ts` — Context resolver
|
||||
- `src/types/competition-configs.ts` — Zod schemas for round configs
|
||||
- `src/server/utils/audit.ts` — Audit logging utility
|
||||
|
||||
**Testing:**
|
||||
- `tests/setup.ts` — Vitest setup with Prisma client
|
||||
- `tests/helpers.ts` — Test data factories
|
||||
- `tests/unit/` — Unit test files
|
||||
- `vitest.config.ts` — Vitest configuration
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
**Files:**
|
||||
- kebab-case for all source files: `round-engine.ts`, `admin-sidebar.tsx`, `use-live-voting-sse.ts`
|
||||
- Router files match domain name: `competition.ts`, `roundEngine.ts` (camelCase variants also seen for compound names)
|
||||
- Service files use kebab-case: `round-assignment.ts`, `ai-filtering.ts`, `result-lock.ts`
|
||||
|
||||
**Directories:**
|
||||
- kebab-case for all directories: `admin/`, `round-assignment/`, `apply-steps/`
|
||||
- Route group segments use parentheses per Next.js convention: `(admin)`, `(jury)`, `(public)`
|
||||
- Dynamic segments use square brackets: `[roundId]`, `[projectId]`, `[trpc]`
|
||||
|
||||
**Components:**
|
||||
- PascalCase exports: `AdminSidebar`, `FilteringDashboard`, `JurorProgressDashboard`
|
||||
- Component files kebab-case: `admin-sidebar.tsx`, `filtering-dashboard.tsx`
|
||||
|
||||
**Types:**
|
||||
- `type` keyword preferred over `interface` (TypeScript strict mode project)
|
||||
- Prisma-generated types used directly where possible; composite types in `src/types/`
|
||||
- Zod schemas named `[Domain]ConfigSchema`; inferred types named `[Domain]Config`
|
||||
|
||||
## Where to Add New Code
|
||||
|
||||
**New tRPC Domain Router:**
|
||||
- Router file: `src/server/routers/[domain].ts`
|
||||
- Register in: `src/server/routers/_app.ts`
|
||||
- Follow pattern: import from `../trpc`, use typed procedure (`adminProcedure`, `juryProcedure`, etc.), call `logAudit()` on mutations
|
||||
|
||||
**New Business Logic Service:**
|
||||
- Implementation: `src/server/services/[domain].ts`
|
||||
- Accept `prisma: PrismaClient | any` as parameter (for transaction compatibility)
|
||||
- Return typed result objects `{ success: boolean, errors?: string[] }` for state machine functions
|
||||
- Call `logAudit()` for all state changes
|
||||
- Never import tRPC types — services are tRPC-agnostic
|
||||
|
||||
**New Admin Page:**
|
||||
- Page file: `src/app/(admin)/admin/[section]/page.tsx`
|
||||
- Layout guard is inherited from `src/app/(admin)/layout.tsx` — no additional role check needed
|
||||
- Use `export const dynamic = 'force-dynamic'` for data-fetching pages
|
||||
- Fetch data server-side in page component using `auth()` + `prisma` directly, or use client component with tRPC hooks
|
||||
|
||||
**New Jury Page:**
|
||||
- Page file: `src/app/(jury)/jury/[section]/page.tsx`
|
||||
- Layout guard in `src/app/(jury)/layout.tsx` checks `JURY_MEMBER` role and onboarding completion
|
||||
|
||||
**New Public Page:**
|
||||
- Page file: `src/app/(public)/[section]/page.tsx`
|
||||
- No auth guard — fully public
|
||||
|
||||
**New Component (domain-specific):**
|
||||
- Admin component: `src/components/admin/[subdomain]/[component-name].tsx`
|
||||
- Jury component: `src/components/jury/[component-name].tsx`
|
||||
- Shared component: `src/components/shared/[component-name].tsx`
|
||||
|
||||
**New shadcn/ui Primitive:**
|
||||
- Location: `src/components/ui/[component].tsx` (generated via `npx shadcn@latest add [component]`)
|
||||
|
||||
**New Round Config Schema:**
|
||||
- Add Zod schema to `src/types/competition-configs.ts` following existing pattern
|
||||
- Add to `RoundConfigMap` discriminated union
|
||||
- Update `validateRoundConfig()` and `safeValidateRoundConfig()` switch statements
|
||||
- Add config UI component to `src/components/admin/rounds/config/`
|
||||
|
||||
**Utilities:**
|
||||
- Shared server+client helpers: `src/lib/utils.ts` or new `src/lib/[utility].ts`
|
||||
- Server-only utilities: `src/server/utils/[utility].ts`
|
||||
- Custom React hooks: `src/hooks/use-[name].ts`
|
||||
|
||||
## Special Directories
|
||||
|
||||
**`prisma/migrations/`:**
|
||||
- Purpose: Auto-generated SQL migration files
|
||||
- Generated: Yes (by `prisma migrate dev`)
|
||||
- Committed: Yes
|
||||
|
||||
**`.next/`:**
|
||||
- Purpose: Next.js build output cache
|
||||
- Generated: Yes
|
||||
- Committed: No
|
||||
|
||||
**`docs/`:**
|
||||
- Purpose: Internal architecture notes, redesign plans, GDPR documentation, feature plans
|
||||
- Generated: No
|
||||
- Committed: Yes
|
||||
|
||||
**`prototypes/`:**
|
||||
- Purpose: HTML/CSS prototype mockups for admin redesign
|
||||
- Generated: No
|
||||
- Committed: Yes
|
||||
|
||||
**`docker/`:**
|
||||
- Purpose: Docker Compose files for production and dev stacks; Nginx reverse proxy config
|
||||
- Generated: No
|
||||
- Committed: Yes
|
||||
|
||||
**`.planning/`:**
|
||||
- Purpose: GSD planning documents (codebase analysis, implementation plans)
|
||||
- Generated: By GSD tooling
|
||||
- Committed: No (gitignored)
|
||||
|
||||
**`.serena/`:**
|
||||
- Purpose: Serena MCP project cache and memories
|
||||
- Generated: Yes
|
||||
- Committed: No
|
||||
|
||||
---
|
||||
|
||||
*Structure analysis: 2026-02-26*
|
||||
289
.planning/codebase/TESTING.md
Normal file
289
.planning/codebase/TESTING.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Testing Patterns
|
||||
|
||||
**Analysis Date:** 2026-02-26
|
||||
|
||||
## Test Framework
|
||||
|
||||
**Runner:**
|
||||
- Vitest 4.0.18
|
||||
- Config: `vitest.config.ts`
|
||||
- Environment: `node` (no jsdom — tests are server-side only)
|
||||
- Globals: `true` — `describe`, `it`, `expect` available without imports (but explicit imports are used in practice)
|
||||
- `fileParallelism: false` — test files run sequentially
|
||||
- `pool: 'forks'` — each test file in isolated subprocess
|
||||
|
||||
**Assertion Library:**
|
||||
- Vitest built-in (`expect`)
|
||||
|
||||
**Path Aliases:**
|
||||
- `@/` resolves to `./src/` in test files (configured in `vitest.config.ts` via `resolve.alias`)
|
||||
|
||||
**Run Commands:**
|
||||
```bash
|
||||
npx vitest # Watch mode (all tests)
|
||||
npx vitest run # Run all tests once
|
||||
npx vitest run tests/unit/assignment-policy.test.ts # Single file
|
||||
npx vitest run -t 'test name' # Single test by name/pattern
|
||||
```
|
||||
|
||||
**Timeout:**
|
||||
- Default `testTimeout: 30000` (30 seconds) — allows for database operations
|
||||
|
||||
## Test File Organization
|
||||
|
||||
**Location:**
|
||||
- All tests live under `tests/` (not co-located with source files)
|
||||
- `tests/unit/` — pure-logic tests, no database
|
||||
- `tests/integration/` — database-backed tests using real Prisma client (currently `assignment-policy.test.ts` in both directories)
|
||||
- Setup: `tests/setup.ts`
|
||||
- Factories: `tests/helpers.ts`
|
||||
|
||||
**Naming:**
|
||||
- `{domain}.test.ts` — matches domain name: `assignment-policy.test.ts`, `round-engine.test.ts`
|
||||
- No `.spec.ts` files — exclusively `.test.ts`
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
tests/
|
||||
├── setup.ts # Global test context, prisma client, createTestContext()
|
||||
├── helpers.ts # Test data factories (createTestUser, createTestRound, etc.)
|
||||
├── unit/
|
||||
│ └── assignment-policy.test.ts # Pure logic, no DB
|
||||
└── integration/
|
||||
└── assignment-policy.test.ts # DB-backed tests
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
**Suite Organization:**
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { CapMode } from '@prisma/client'
|
||||
import { resolveEffectiveCap } from '@/server/services/assignment-policy'
|
||||
|
||||
// ============================================================================
|
||||
// Section Title with box dividers
|
||||
// ============================================================================
|
||||
|
||||
describe('functionName', () => {
|
||||
it('returns expected value when condition', () => {
|
||||
const result = functionName(input)
|
||||
expect(result.value).toBe(expected)
|
||||
expect(result.source).toBe('system')
|
||||
})
|
||||
|
||||
describe('nested scenario group', () => {
|
||||
it('specific behavior', () => { ... })
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Helper/Stub Pattern:**
|
||||
```typescript
|
||||
// Builder functions at top of file construct minimal test objects
|
||||
function baseMemberContext(overrides: Partial<MemberContext> = {}): MemberContext {
|
||||
return {
|
||||
competition: {} as any,
|
||||
round: {} as any,
|
||||
member: { id: 'member-1', role: 'MEMBER', ... } as any,
|
||||
currentAssignmentCount: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function withJuryGroup(ctx: MemberContext, groupOverrides = {}): MemberContext {
|
||||
return { ...ctx, juryGroup: { id: 'jg-1', defaultMaxAssignments: 20, ...groupOverrides } as any }
|
||||
}
|
||||
```
|
||||
|
||||
**Patterns:**
|
||||
- Build minimal context objects inline — no heavy mocking frameworks
|
||||
- Use spread + override: `{ ...ctx, member: { ...ctx.member, maxAssignmentsOverride: 10 } }`
|
||||
- Assert on both value AND metadata: `expect(result.value).toBe(25)` + `expect(result.source).toBe('jury_group')`
|
||||
- Tests are descriptive: `'admin per-member override takes precedence over group default'`
|
||||
|
||||
## Mocking
|
||||
|
||||
**Framework:** None — unit tests avoid mocking entirely by testing pure functions.
|
||||
|
||||
**Approach:**
|
||||
- Unit tests pass plain JavaScript objects (`{} as any`) for unused dependencies
|
||||
- No `vi.mock()`, `vi.fn()`, or `vi.spyOn()` observed in current test files
|
||||
- Prisma is a real client connected to a test database (see integration tests)
|
||||
- tRPC context is constructed via `createTestContext(user)` — a plain object, not mocked
|
||||
|
||||
**What to Mock:**
|
||||
- External I/O (email, MinIO, OpenAI) — not currently tested; fire-and-forget pattern used
|
||||
- Anything not relevant to the assertion being made (`{} as any` for unused context fields)
|
||||
|
||||
**What NOT to Mock:**
|
||||
- Business logic functions under test
|
||||
- Prisma in integration tests — use real database with `DATABASE_URL_TEST`
|
||||
- The `createTestContext` / `createCaller` — these are lightweight stubs, not mocks
|
||||
|
||||
## Fixtures and Factories
|
||||
|
||||
**Test Data (from `tests/helpers.ts`):**
|
||||
```typescript
|
||||
// uid() creates unique prefixed IDs to avoid collisions
|
||||
export function uid(prefix = 'test'): string {
|
||||
return `${prefix}-${randomUUID().slice(0, 12)}`
|
||||
}
|
||||
|
||||
// Factories accept overrides for specific test scenarios
|
||||
export async function createTestUser(
|
||||
role: UserRole = 'JURY_MEMBER',
|
||||
overrides: Partial<{ email: string; name: string; ... }> = {}
|
||||
) {
|
||||
const id = uid('user')
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
email: overrides.email ?? `${id}@test.local`,
|
||||
role,
|
||||
...
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Available Factories:**
|
||||
- `createTestUser(role, overrides)` — creates User in database
|
||||
- `createTestProgram(overrides)` — creates Program
|
||||
- `createTestCompetition(programId, overrides)` — creates Competition
|
||||
- `createTestRound(competitionId, overrides)` — creates Round (default: EVALUATION, ROUND_ACTIVE)
|
||||
- `createTestProject(programId, overrides)` — creates Project
|
||||
- `createTestProjectRoundState(projectId, roundId, overrides)` — creates ProjectRoundState
|
||||
- `createTestAssignment(userId, projectId, roundId, overrides)` — creates Assignment
|
||||
- `createTestEvaluation(assignmentId, formId, overrides)` — creates Evaluation
|
||||
- `createTestEvaluationForm(roundId, criteria)` — creates EvaluationForm
|
||||
- `createTestFilteringRule(roundId, overrides)` — creates FilteringRule
|
||||
- `createTestCOI(assignmentId, userId, projectId, hasConflict)` — creates ConflictOfInterest
|
||||
- `createTestCohort(roundId, overrides)` — creates Cohort
|
||||
- `createTestCohortProject(cohortId, projectId)` — creates CohortProject
|
||||
|
||||
**Location:**
|
||||
- Factories in `tests/helpers.ts`
|
||||
- Shared Prisma client in `tests/setup.ts`
|
||||
|
||||
## Coverage
|
||||
|
||||
**Requirements:** None enforced — no coverage thresholds configured.
|
||||
|
||||
**View Coverage:**
|
||||
```bash
|
||||
npx vitest run --coverage # Requires @vitest/coverage-v8 (not currently installed)
|
||||
```
|
||||
|
||||
## Test Types
|
||||
|
||||
**Unit Tests (`tests/unit/`):**
|
||||
- Scope: Pure business logic functions with no I/O
|
||||
- Approach: Construct in-memory objects, call function, assert return value
|
||||
- Examples: `assignment-policy.test.ts` tests `resolveEffectiveCap`, `evaluateAssignmentPolicy`
|
||||
- No database, no HTTP, no file system
|
||||
|
||||
**Integration Tests (`tests/integration/`):**
|
||||
- Scope: tRPC router procedures via `createCaller`
|
||||
- Approach: Create real database records → call procedure → assert DB state or return value → cleanup
|
||||
- Uses `DATABASE_URL_TEST` (or falls back to `DATABASE_URL`)
|
||||
- Sequential execution (`fileParallelism: false`) to avoid DB conflicts
|
||||
|
||||
**E2E Tests:**
|
||||
- Playwright configured (`@playwright/test` installed, `npm run test:e2e` script)
|
||||
- No test files found yet — framework is available but not implemented
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Integration Test Pattern (calling tRPC procedures):**
|
||||
```typescript
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { prisma } from '../setup'
|
||||
import { createTestUser, createTestProgram, createTestCompetition, cleanupTestData, uid } from '../helpers'
|
||||
import { roundRouter } from '@/server/routers/round'
|
||||
|
||||
describe('round procedures', () => {
|
||||
let programId: string
|
||||
let adminUser: Awaited<ReturnType<typeof createTestUser>>
|
||||
|
||||
beforeAll(async () => {
|
||||
adminUser = await createTestUser('SUPER_ADMIN')
|
||||
const program = await createTestProgram()
|
||||
programId = program.id
|
||||
})
|
||||
|
||||
it('activates a round', async () => {
|
||||
const competition = await createTestCompetition(programId)
|
||||
const caller = createCaller(roundRouter, adminUser)
|
||||
const result = await caller.activate({ roundId: round.id })
|
||||
expect(result.status).toBe('ROUND_ACTIVE')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData(programId, [adminUser.id])
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Unit Test Pattern (pure logic):**
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveEffectiveCap } from '@/server/services/assignment-policy'
|
||||
|
||||
describe('resolveEffectiveCap', () => {
|
||||
it('returns system default when no jury group', () => {
|
||||
const ctx = baseMemberContext() // local builder function
|
||||
const result = resolveEffectiveCap(ctx)
|
||||
expect(result.value).toBe(SYSTEM_DEFAULT_CAP)
|
||||
expect(result.source).toBe('system')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Async Testing:**
|
||||
```typescript
|
||||
it('creates evaluation', async () => {
|
||||
const result = await caller.evaluation.start({ assignmentId: assignment.id })
|
||||
expect(result.status).toBe('DRAFT')
|
||||
})
|
||||
```
|
||||
|
||||
**Error Testing:**
|
||||
```typescript
|
||||
it('throws FORBIDDEN when accessing others evaluation', async () => {
|
||||
const otherUser = await createTestUser('JURY_MEMBER')
|
||||
const caller = createCaller(evaluationRouter, otherUser)
|
||||
await expect(
|
||||
caller.get({ assignmentId: assignment.id })
|
||||
).rejects.toThrow('FORBIDDEN')
|
||||
})
|
||||
```
|
||||
|
||||
**Cleanup (afterAll):**
|
||||
```typescript
|
||||
afterAll(async () => {
|
||||
// Pass programId to cascade-delete competition data, plus explicit user IDs
|
||||
await cleanupTestData(programId, [adminUser.id, jurorUser.id])
|
||||
})
|
||||
```
|
||||
|
||||
## Test Infrastructure Details
|
||||
|
||||
**`createTestContext(user)`** in `tests/setup.ts`:
|
||||
- Builds a fake tRPC context matching `{ session: { user, expires }, prisma, ip, userAgent }`
|
||||
- `prisma` is the shared test client
|
||||
- Used internally by `createCaller`
|
||||
|
||||
**`createCaller(routerModule, user)`** in `tests/setup.ts`:
|
||||
- Shorthand: `const caller = createCaller(evaluationRouter, adminUser)`
|
||||
- Returns type-safe caller — procedures called as `await caller.procedureName(input)`
|
||||
- Import the router module directly, not `appRouter`
|
||||
|
||||
**Database Isolation:**
|
||||
- Tests share one database — isolation is by unique IDs (via `uid()`)
|
||||
- `cleanupTestData(programId)` does ordered deletion respecting FK constraints
|
||||
- Always call `cleanupTestData` in `afterAll`, never skip
|
||||
|
||||
---
|
||||
|
||||
*Testing analysis: 2026-02-26*
|
||||
Reference in New Issue
Block a user