Conventions and guidance for working in this repo. Keep this file short — it loads into every Claude session.
- Next.js 16 (App Router), React 19, TypeScript 5.8
- PostgreSQL via raw
pgfor runtime queries. Drizzle Kit owns schema-as-code and migration generation; the ORM (drizzle-orm) is intentionally not used at runtime — keep queries onpg. Schema lives atdrizzle/schema.ts; migrations atdrizzle/migrations/. - Playwright for e2e tests; ESLint 9 for linting; no Prettier.
The database is snake_case. To eliminate boundary-translation bugs (the kind where a column rename silently breaks a route), we mirror that everywhere data crosses a wire:
- DB columns:
snake_case(Postgres native) - TS model interfaces (e.g.
ContactData,User): properties match column names —grid_locator, notgridLocator - API JSON response fields:
snake_case—{ grid_locator: ... } - API request bodies:
snake_casekeys
Exempt from this rule:
- React component-local form state (
formData.gridLocator) — purely internal, never crosses the wire. The translation happens at thefetch()call site. - Internal helper function parameters (e.g.
buildLoTWDownloadUrl({ dateFrom, dateTo })) — not API surface, just JS function args.
Known still-drifting surfaces (cleanup candidates, not blockers):
GET /api/contacts/searchquery params (gridLocator,startDate,endDate,qslStatus) still use camelCase. Defer to a follow-up sweep.
Two acceptable patterns. Pick based on what consumers already expect.
Default — { error: string } with HTTP status:
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });HTTP status carries the error category (400 client error, 401 unauthorized, 403 forbidden, 404 not found, 500 server error). New internal routes should default to this shape.
Discriminated union — { success, data, error }: used by /api/awards/* and the public-facing /api/cloudlog/*. Consumers check data.success to branch. The shape is part of the contract; don't change it without updating every consumer.
type AwardResponse<T> =
| { success: true; data: T }
| { success: false; error: string };Don't leak raw error messages. A catch block that returns error.message to the client can expose DB constraint names, stack frames, and other internals. Log the real message with console.error and return a generic string:
catch (error) {
console.error('DXCC summary error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}The details: field used by some install/cron routes ({ error, details }) is admin/diagnostic only and not consumed by the frontend — keep it scoped to flows where untrusted callers can't reach.
no-consoleis lint-enforced insrc/(allowswarn,erroronly). Useconsole.errorin genuine error paths; everything else should go throughsrc/lib/logger.ts(logger.debug/logger.info/logger.warn/logger.error).scripts/andtests/are exempt from the rule — CLI utilities and test diagnostics may useconsole.log.
- No
any. The codebase is currently clean of explicit: any— keep it that way. If you genuinely don't know a type, useunknownand narrow at the use site. - Run
npm run typecheck(alias fortsc --noEmit) before committing. It must pass.
src/app/— Next.js App Router. Pages live here; API routes undersrc/app/api/<route>/route.ts.src/models/— DB access. API routes should call models, notquery()directly, when a model method exists.src/lib/— utilities, integrations (QRZ, LoTW), auth, db pool, crypto.src/components/— React components. Radix-based UI primitives insrc/components/ui/.src/contexts/— React Context (UserContext, ThemeContext).src/types/— shared TS types not tied to a single model./drizzle/migrations/— Drizzle Kit-generated SQL migrations (only place SQL migrations live)./tests/— Playwright specs.
The canonical schema lives in TypeScript at drizzle/schema.ts. To evolve it:
- Edit
drizzle/schema.ts(or pull from a running DB withnpm run db:pullto re-sync). - Run
npm run db:generate— Drizzle Kit diffsschema.tsagainst the previous snapshot and emits a new SQL migration indrizzle/migrations/. - Review the generated SQL, commit it alongside the
schema.tschange. - Apply with
npm run db:migrate(usesDATABASE_URL).
Canonical schema: drizzle/schema.ts is the single source of truth. 18 tables, 284 columns. The baseline migration is drizzle/migrations/0000_baseline_canonical_schema.sql — produced by drizzle-kit generate, executable as-is.
Applying migrations at runtime: POST /api/admin/migrate (admin-only) runs the Drizzle migrator with backfill for existing installs:
- Detects existing installs (
public.usersexists,drizzle.__drizzle_migrationsdoesn't) and seeds the tracking table with the baseline marked as applied — so the baseline isn't reapplied against an already-populated schema. - For fresh DBs, applies the baseline normally to create all 18 tables.
- Returns
{ backfilled, migrationsAppliedCount, baselineTag }.
Install flow: /install POSTs /api/install/{validate,migrate,create-admin,finalize} in order. The migrate step calls the runtime migrator (gated on "no users yet exist" instead of admin auth), which creates schema + loads reference data in one shot — same code path as /api/admin/migrate.
Current state (still deliberate limitations):
- Local dev DBs bootstrapped via the old
postgres-init.sql(deleted in #195) need to be wiped and re-installed via the in-app installer for parity. The dev-onlyapi_key_usage_logstable is intentionally not canonical.
- DXCC entities are not countries. The US is split across three: 291 (contiguous + DC, ~49 state rows), 110 (Hawaii, 1 row), 6 (Alaska, 1 row).
states_provinces.dxcc_entityholds the ADIF DXCC number — querying "US states" meansdxcc_entity IN (291, 110, 6), notdxcc_entity = 6.
npm run dev— Next dev server (Turbopack)npm run build— production buildnpm run lint— ESLintnpm run typecheck—tsc --noEmitnpm test— Playwright e2enpm run db:pull— introspectDATABASE_URLand regeneratedrizzle/schema.tsnpm run db:generate— diffschema.tsagainst last snapshot, emit a migration SQLnpm run db:migrate— apply pending migrations toDATABASE_URLnpm run db:studio— open Drizzle Studio (DB browser UI)
npm run lintclean (warnings allowed for now, errors no)npm run typecheckcleannpm run buildsucceeds- If you touched API request/response shapes, update both ends in the same PR.