194 lines
10 KiB
Markdown
194 lines
10 KiB
Markdown
|
|
# 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*
|