docs: map existing codebase
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
267
.planning/codebase/CONVENTIONS.md
Normal file
267
.planning/codebase/CONVENTIONS.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# 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*
|
||||
Reference in New Issue
Block a user