Files
MOPC-Portal/.planning/codebase/CONVENTIONS.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

11 KiB

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<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-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:

'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:

  • 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:

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:

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<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