# Coding Conventions **Analysis Date:** 2026-02-26 ## Naming Patterns **Files:** - `kebab-case` for all TypeScript/TSX source files: `round-engine.ts`, `filtering-dashboard.tsx`, `ai-errors.ts` - `kebab-case` for route directories under `src/app/`: `(admin)/admin/juries/`, `[roundId]/` - Exception: Next.js reserved names remain as-is: `page.tsx`, `layout.tsx` **Components:** - `PascalCase` for component functions: `FilteringDashboard`, `EmptyState`, `JuriesPage` - Page components follow the suffix pattern `XxxPage`: `JuriesPage`, `RoundDetailPage`, `AuditLogPage` - Sub-components within a file follow `XxxSection`, `XxxCard`, `XxxDialog` **Functions:** - `camelCase` for all functions and methods: `activateRound`, `resolveEffectiveCap`, `createTestUser` - Service functions are named by operation + domain: `activateRound`, `closeRound`, `batchTransitionProjects` - Boolean functions prefixed with `is`, `has`, `should`, `can`: `shouldRetry`, `isParseError`, `shouldLog` **Variables:** - `camelCase` for all local variables - `SCREAMING_SNAKE_CASE` for module-level constants: `BATCH_SIZE = 50`, `SYSTEM_DEFAULT_CAP`, `VALID_ROUND_TRANSITIONS` - Enum-like lookup objects in `SCREAMING_SNAKE_CASE`: `ERROR_PATTERNS`, `LOG_LEVELS` **Types:** - `type` keyword preferred over `interface` per CLAUDE.md — but both exist in practice - `interface` is used for component props in some files (e.g., `ButtonProps`, `EmptyStateProps`), `type` used in others - Prisma-derived types use `type` aliases with `z.infer`: `type EvaluationConfig = z.infer` - Prop types: `type XxxProps = { ... }` (preferred in most components), `interface XxxProps { ... }` (used in deliberation, some UI components) - Export complex input types from `src/types/competition.ts`: `CreateCompetitionInput`, `UpdateRoundInput` ## Code Style **Formatting:** - Prettier 3.4.2 with `prettier-plugin-tailwindcss` for class sorting - No `.prettierrc` found — uses Prettier defaults: 2-space indent, double quotes, trailing commas (ES5), 80-char print width - Single quotes confirmed absent in codebase: all string literals use double quotes - Tailwind classes sorted automatically by the plugin on format **Linting:** - ESLint 9.x with `eslint-config-next` (Next.js configuration) - Run via `npm run lint` (calls `next lint`) - No custom rules file found — relies on Next.js default rules **TypeScript:** - Strict mode enabled in `tsconfig.json` (`"strict": true`) - `noEmit: true` — TypeScript used for type checking only, not transpilation - Target: ES2022 - Module resolution: `bundler` (Next.js Turbopack compatible) - Path alias: `@/*` maps to `./src/*` ## Import Organization **Order (observed in client components):** 1. `'use client'` directive (if needed) 2. React/framework hooks: `import { useState, useEffect } from 'react'` 3. Next.js imports: `import { useRouter } from 'next/navigation'`, `import Link from 'next/link'` 4. tRPC client: `import { trpc } from '@/lib/trpc/client'` 5. UI libraries: shadcn/ui components `import { Button } from '@/components/ui/button'` 6. Icons: `import { Loader2, Save } from 'lucide-react'` 7. Internal utilities/helpers: `import { cn } from '@/lib/utils'` 8. Internal components: `import { FilteringDashboard } from '@/components/admin/round/...'` 9. Types: `import type { EvaluationConfig } from '@/types/competition-configs'` **Order (observed in server/service files):** 1. `import { z } from 'zod'` (validation) 2. `import { TRPCError } from '@trpc/server'` (errors) 3. tRPC router/procedures: `import { router, adminProcedure } from '../trpc'` 4. Internal services/utilities: `import { logAudit } from '@/server/utils/audit'` 5. Type imports at end: `import type { PrismaClient } from '@prisma/client'` **Path Aliases:** - Use `@/` prefix for all internal imports: `@/components/ui/button`, `@/server/services/round-engine` - Never use relative `../../` paths for cross-directory imports - Relative paths (`./`, `../`) only within the same directory level ## React Component Conventions **Server vs Client Components:** - Default to **Server Components** — do not add `'use client'` unless needed - Layouts (`layout.tsx`) are server components: they call `await requireRole()`, fetch data directly from Prisma, and pass to client wrappers - Pages that use tRPC hooks, `useState`, or browser APIs must be `'use client'` - The pattern: server layout fetches session/editions → passes to client wrapper → client components handle interactive state **Client Component Pattern:** ```tsx 'use client' import { useState } from 'react' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' type XxxProps = { competitionId: string roundId: string } export default function XxxPage() { ... } // Sub-components in same file as local functions (not exported) function XxxSection({ competition }: XxxSectionProps) { ... } ``` **Server Layout Pattern:** ```tsx import { requireRole } from '@/lib/auth-redirect' import { prisma } from '@/lib/prisma' export default async function AdminLayout({ children }: { children: React.ReactNode }) { const session = await requireRole('SUPER_ADMIN', 'PROGRAM_ADMIN') const data = await prisma.program.findMany({ ... }) return {children} } ``` **Props with Params (Next.js 15):** ```tsx type PageProps = { params: Promise<{ roundId: string; projectId: string }> } export default function Page({ params: paramsPromise }: PageProps) { const params = use(paramsPromise) // React.use() for async params ... } ``` ## tRPC Router Conventions **Procedure Selection:** - `adminProcedure` for CRUD on competition/round/jury entities - `protectedProcedure` for shared read access across roles - `juryProcedure` for jury-only operations - Role checks within procedure body use `userHasRole(ctx.user, 'ROLE')` for per-entity authorization **Input Validation:** - All inputs validated with Zod `.input(z.object({ ... }))` - Use `.min()`, `.max()`, `.regex()` for strings - Use `.int().positive()` for ID/count integers - Use `.optional().nullable()` for optional fields with null support - Inline schema definition (not shared schema objects) per router **Mutation Pattern:** ```typescript create: adminProcedure .input(z.object({ name: z.string().min(1).max(255), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), })) .mutation(async ({ ctx, input }) => { // 1. Check business rules (conflict, not found) const existing = await ctx.prisma.xxx.findUnique({ where: { slug: input.slug } }) if (existing) { throw new TRPCError({ code: 'CONFLICT', message: '...' }) } // 2. Perform operation const result = await ctx.prisma.xxx.create({ data: input }) // 3. Audit log (for mutations) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', ... }) // 4. Return result return result }) ``` **Error Codes:** - `NOT_FOUND` — entity doesn't exist - `CONFLICT` — duplicate slug/unique constraint - `FORBIDDEN` — user lacks permission for specific entity - `UNAUTHORIZED` — not logged in (handled by middleware) - `BAD_REQUEST` — invalid business state (e.g., no active form) ## Error Handling **tRPC Routers (user-facing errors):** - Always throw `TRPCError` with `{ code, message }` — never plain `throw new Error()` - Message should be human-readable: `'Competition not found'`, not `'Competition_NOT_FOUND'` - Use `findUniqueOrThrow` / `findFirstOrThrow` for implicit 404s on required relations **Service Layer (internal errors):** - Services return result objects `{ success: boolean, errors?: string[] }` — they do NOT throw - Callers check `result.success` before proceeding - Error message pattern: `error instanceof Error ? error.message : 'Unknown error during X'` - Non-fatal side effects (notifications, emails) are wrapped in separate try/catch and logged but never propagate **AI Services:** - Use `classifyAIError()` from `src/server/services/ai-errors.ts` for all OpenAI errors - Wrap AI calls with `withAIErrorHandling(fn, fallback)` for unified error + fallback handling - All AI errors logged with service tag: `console.error('[AI Assignment] failed:', ...)` **Client-Side:** - Mutation errors displayed via `toast.error(err.message)` from Sonner - Success via `toast.success('...')` with query invalidation: `utils.xxx.yyy.invalidate()` - Loading states tracked via `mutation.isPending` and `query.isLoading` ## Logging **Framework:** Custom structured logger at `src/lib/logger.ts` **Usage:** ```typescript import { logger } from '@/lib/logger' logger.info('RoundEngine', 'Round activated', { roundId, competitionId }) logger.error('Storage', 'Upload failed', error) logger.warn('Filtering', 'Non-fatal error in document check', retroError) ``` **Tag Convention:** `[ServiceName]` prefix in brackets — `'RoundEngine'`, `'AIFiltering'`, `'Storage'` **Direct console usage** (still common in routers, not yet migrated to logger): - Tagged format: `console.log('[FeatureName] message', data)` - Always uses bracket tag: `'[Filtering]'`, `'[Assignment]'`, `'[COI]'` **Log Level Defaults:** - Development: `debug` (all logs) - Production: `warn` (warns and errors only) - Override via `LOG_LEVEL` env var ## Comments **When to Comment:** - All exported service functions get a JSDoc-style comment explaining purpose and invariants - Inline comments for non-obvious business logic: `// re-include after rejection`, `// Bounded to admin max` - Section header separators in large files using box-drawing chars: `// ─── Section Name ──────` - `// =====` separators for major logical sections in long files **JSDoc/TSDoc:** - Used on exported functions in services and utilities - Standard `/**` block with plain description — no `@param`/`@returns` annotations in most code - Routers use `/** procedure description */` above each procedure for documentation **TODO Comments:** - Present but sparse — only 3 found in entire codebase (deliberation page, award router) - Format: `// TODO: description` ## Function Design **Size:** Service functions can be long (100-200+ lines) for complex state machines; router procedures typically 20-60 lines **Parameters:** - Services accept `(entityId: string, actorId: string, prisma: PrismaClient)` — explicit prisma injection for testability - Router procedures destructure `{ ctx, input }` — never access `ctx.prisma` outside routers **Return Values:** - Queries return data directly or throw `TRPCError` - Mutations return the created/updated record - Services return typed result objects: `RoundTransitionResult`, `BatchProjectTransitionResult` - Async service results always typed: `Promise` ## Module Design **Exports:** - Single responsibility: each service file exports one domain's functions - Named exports preferred over default exports for services and utilities - Default exports used only for React components (`export default function Page()`) **Barrel Files:** - Used sparingly — only for chart components (`src/components/charts/index.ts`), form steps, and storage utilities - Most imports are direct path imports: `import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'` **Prisma Transactions:** - Use `ctx.prisma.$transaction(async (tx) => { ... })` for multi-step mutations - Always pass `tx` (transaction client) through nested operations - Sequential array syntax `ctx.prisma.$transaction([op1, op2])` for simple atomic batches --- *Convention analysis: 2026-02-26*