11 KiB
Coding Conventions
Analysis Date: 2026-02-26
Naming Patterns
Files:
kebab-casefor all TypeScript/TSX source files:round-engine.ts,filtering-dashboard.tsx,ai-errors.tskebab-casefor route directories undersrc/app/:(admin)/admin/juries/,[roundId]/- Exception: Next.js reserved names remain as-is:
page.tsx,layout.tsx
Components:
PascalCasefor 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:
camelCasefor 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:
camelCasefor all local variablesSCREAMING_SNAKE_CASEfor 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:
typekeyword preferred overinterfaceper CLAUDE.md — but both exist in practiceinterfaceis used for component props in some files (e.g.,ButtonProps,EmptyStateProps),typeused in others- Prisma-derived types use
typealiases withz.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-tailwindcssfor class sorting - No
.prettierrcfound — 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(callsnext 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):
'use client'directive (if needed)- React/framework hooks:
import { useState, useEffect } from 'react' - Next.js imports:
import { useRouter } from 'next/navigation',import Link from 'next/link' - tRPC client:
import { trpc } from '@/lib/trpc/client' - UI libraries: shadcn/ui components
import { Button } from '@/components/ui/button' - Icons:
import { Loader2, Save } from 'lucide-react' - Internal utilities/helpers:
import { cn } from '@/lib/utils' - Internal components:
import { FilteringDashboard } from '@/components/admin/round/...' - Types:
import type { EvaluationConfig } from '@/types/competition-configs'
Order (observed in server/service files):
import { z } from 'zod'(validation)import { TRPCError } from '@trpc/server'(errors)- tRPC router/procedures:
import { router, adminProcedure } from '../trpc' - Internal services/utilities:
import { logAudit } from '@/server/utils/audit' - 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 callawait 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:
'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:
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):
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:
adminProcedurefor CRUD on competition/round/jury entitiesprotectedProcedurefor shared read access across rolesjuryProcedurefor 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:
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 existCONFLICT— duplicate slug/unique constraintFORBIDDEN— user lacks permission for specific entityUNAUTHORIZED— 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
TRPCErrorwith{ code, message }— never plainthrow new Error() - Message should be human-readable:
'Competition not found', not'Competition_NOT_FOUND' - Use
findUniqueOrThrow/findFirstOrThrowfor implicit 404s on required relations
Service Layer (internal errors):
- Services return result objects
{ success: boolean, errors?: string[] }— they do NOT throw - Callers check
result.successbefore 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()fromsrc/server/services/ai-errors.tsfor 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.isPendingandquery.isLoading
Logging
Framework: Custom structured logger at src/lib/logger.ts
Usage:
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_LEVELenv 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/@returnsannotations 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 accessctx.prismaoutside 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