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
DecisionAuditLogthroughsrc/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:
- React component calls
trpc.domain.procedure.useQuery()viasrc/lib/trpc/client.ts - Request hits
src/app/api/trpc/[trpc]/route.ts— rate limited at 100 req/min per IP - tRPC resolves context (
src/server/context.ts): auth session + prisma singleton + IP/UA - Middleware chain runs: authentication check → role check → procedure handler
- Router delegates to service (e.g.,
roundEngineRouter→src/server/services/round-engine.ts) - Service queries Prisma, may call external APIs, writes audit log
- Superjson-serialized result returns to React Query cache
Mutation Flow (with audit trail):
- Component calls
trpc.domain.action.useMutation() - tRPC middleware validates auth + role
- Router calls
logAudit()before or after service call - Service performs database work inside
prisma.$transaction()when atomicity required - Service writes its own
logAudit()for state machine transitions - Cache invalidated via
utils.trpc.invalidate()
Server-Sent Events Flow (live voting/deliberation):
- Client subscribes via
src/hooks/use-live-voting-sse.tsorsrc/hooks/use-stage-live-sse.ts - SSE route
src/app/api/live-voting/stream/route.tspolls database on interval - Events emitted for vote count changes, cursor position changes, status changes
- Admin cursor controlled via
src/server/services/live-control.ts→ tRPCliveRouter
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_TRANSITIONSandVALID_PROJECT_TRANSITIONSconstants 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 providesresolveMemberContext()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 inRound.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:
StorageProviderinterface withgetUploadUrl,getDownloadUrl,deleteObject,putObject,getObject
AI Pipeline with Anonymization:
- Purpose: All AI calls strip PII before sending to OpenAI
- Examples:
src/server/services/anonymization.ts+ anysrc/server/services/ai-*.ts - Pattern:
anonymizeProjectsForAI()returnsAnonymizedProjectForAI[]+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),
fetchRequestHandlerwithappRouter+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_SECRETheader 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, usessrc/lib/auth.config.ts) - Triggers: Every request
- Responsibilities: Auth check (edge-compatible), redirect to
/loginif unauthenticated, redirect to/set-passwordifmustSetPasswordflag 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
TRPCErrorwith 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()fromsrc/server/services/ai-errors.tsto 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), respectsLOG_LEVELenv var, defaults todebugin development andwarnin production- Pattern:
logger.info('ServiceName', 'message', { data })— tag identifies the calling service
Validation:
- Zod schemas on all tRPC procedure inputs (
.input(z.object({...}))) ZodErroris formatted and included in tRPC error response viaerrorFormatterinsrc/server/trpc.ts- Round config Zod schemas in
src/types/competition-configs.tsvalidateconfigJsonat activation time
Authentication:
- NextAuth v5 with JWT strategy; session available server-side via
auth()fromsrc/lib/auth.ts requireRole(...roles)insrc/lib/auth-redirect.tsused by all role layouts — checksuser.roles[]array withuser.rolefallbackuserHasRole()insrc/server/trpc.tsused inline for fine-grained procedure-level checks
Audit Trail:
logAudit()insrc/server/utils/audit.ts— writes toAuditLogtable- Called from both routers (with
ctx.prismato share transaction) and services - Never throws — always wrapped in try-catch
Feature Flags:
src/lib/feature-flags.ts— readsSystemSettingrecords withcategory: FEATURE_FLAGS- Currently one active flag:
feature.useCompetitionModel(defaults totrue)
Architecture analysis: 2026-02-26