Files
MOPC-Portal/.planning/codebase/ARCHITECTURE.md
Matt 8cc86bae20 docs: map existing codebase
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:14:08 +01:00

10 KiB

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., roundEngineRoutersrc/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.tsIntakeConfigSchema, 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