Files
MOPC-Portal/.planning/codebase/CONVENTIONS.md

268 lines
11 KiB
Markdown
Raw Normal View History

# 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:**
```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 <ClientWrapper data={data}>{children}</ClientWrapper>
}
```
**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<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*