268 lines
11 KiB
Markdown
268 lines
11 KiB
Markdown
|
|
# 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*
|