Skip to content

Commit 6f088a0

Browse files
patrickrbclaude
andauthored
chore(api): standardize JSON field casing on snake_case + add CLAUDE.md (#188)
The DB is snake_case throughout and the model interfaces mirror it, but a handful of API routes were translating to camelCase at the boundary. That mapping was the source of "I renamed the column but a route still says gridLocator" bugs. Drop the translation and have the API speak snake_case end-to-end. API surface changes (request + response field names): - POST /api/auth/login response: gridLocator -> grid_locator - POST /api/auth/register request + response: gridLocator -> grid_locator - POST /api/install/create-admin request: gridLocator -> grid_locator - GET /api/stats/advanced response: countryDistribution -> country_distribution continentDistribution -> continent_distribution gridActivity -> grid_activity gridSquare -> grid_square - GET /api/contacts/callsigns response: contactCount -> contact_count lastContact -> last_contact Frontend consumers updated in the same PR (register page, install page, stats page, SearchInput). React form-state variables are intentionally unchanged - they never cross the wire; the translation happens at fetch(). Also: - Add CLAUDE.md documenting the snake_case convention, error response shape, no-console policy, and code layout, so future work doesn't re-introduce drift. - Add `typecheck` script (tsc --noEmit) to package.json and fix the one pre-existing TS error (tests/database-integration.spec.ts null guard) so the script runs clean. Known follow-up (not in this PR): GET /api/contacts/search still accepts camelCase query params (gridLocator, startDate, endDate, qslStatus). Will be addressed in a separate sweep. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d3bb7a0 commit 6f088a0

12 files changed

Lines changed: 104 additions & 30 deletions

File tree

CLAUDE.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# CLAUDE.md
2+
3+
Conventions and guidance for working in this repo. Keep this file short — it loads into every Claude session.
4+
5+
## Stack
6+
7+
- Next.js 16 (App Router), React 19, TypeScript 5.8
8+
- PostgreSQL via raw `pg` (no ORM). Migrations are hand-written SQL in `/migrations/`.
9+
- Playwright for e2e tests; ESLint 9 for linting; no Prettier.
10+
11+
## Naming convention: `snake_case` end-to-end
12+
13+
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:
14+
15+
- **DB columns**: `snake_case` (Postgres native)
16+
- **TS model interfaces** (e.g. `ContactData`, `User`): properties match column names — `grid_locator`, not `gridLocator`
17+
- **API JSON response fields**: `snake_case``{ grid_locator: ... }`
18+
- **API request bodies**: `snake_case` keys
19+
20+
**Exempt from this rule:**
21+
22+
- React component-local form state (`formData.gridLocator`) — purely internal, never crosses the wire. The translation happens at the `fetch()` call site.
23+
- Internal helper function parameters (e.g. `buildLoTWDownloadUrl({ dateFrom, dateTo })`) — not API surface, just JS function args.
24+
25+
**Known still-drifting surfaces** (cleanup candidates, not blockers):
26+
27+
- `GET /api/contacts/search` query params (`gridLocator`, `startDate`, `endDate`, `qslStatus`) still use camelCase. Defer to a follow-up sweep.
28+
29+
## Error response shape
30+
31+
API routes return:
32+
33+
```ts
34+
{ error: string }
35+
```
36+
37+
with an appropriate HTTP status (400 client error, 401 unauthorized, 403 forbidden, 404 not found, 500 server error). Some routes return richer shapes (`{ success: boolean, error?: string, ... }`) — that's fine where it's already established, but new routes should default to the simple shape.
38+
39+
## Logging
40+
41+
- `console.log` in `src/` is being phased out — don't add new ones. (Lint rule coming in a follow-up PR.)
42+
- `console.error` is acceptable in genuine error paths until a real logger is introduced.
43+
44+
## Type discipline
45+
46+
- **No `any`**. The codebase is currently clean of explicit `: any` — keep it that way. If you genuinely don't know a type, use `unknown` and narrow at the use site.
47+
- Run `npm run typecheck` (alias for `tsc --noEmit`) before committing. It must pass.
48+
49+
## Code layout
50+
51+
- `src/app/` — Next.js App Router. Pages live here; API routes under `src/app/api/<route>/route.ts`.
52+
- `src/models/` — DB access. API routes should call models, not `query()` directly, when a model method exists.
53+
- `src/lib/` — utilities, integrations (QRZ, LoTW), auth, db pool, crypto.
54+
- `src/components/` — React components. Radix-based UI primitives in `src/components/ui/`.
55+
- `src/contexts/` — React Context (UserContext, ThemeContext).
56+
- `src/types/` — shared TS types not tied to a single model.
57+
- `/migrations/` — hand-written SQL migrations. Root-level `*.sql` files are install/seed schema.
58+
- `/tests/` — Playwright specs.
59+
60+
## Scripts (run from repo root)
61+
62+
- `npm run dev` — Next dev server (Turbopack)
63+
- `npm run build` — production build
64+
- `npm run lint` — ESLint
65+
- `npm run typecheck``tsc --noEmit`
66+
- `npm test` — Playwright e2e
67+
68+
## Pre-commit checklist
69+
70+
1. `npm run lint` clean (warnings allowed for now, errors no)
71+
2. `npm run typecheck` clean
72+
3. `npm run build` succeeds
73+
4. If you touched API request/response shapes, update both ends in the same PR.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"build": "next build",
88
"start": "next start",
99
"lint": "eslint .",
10+
"typecheck": "tsc --noEmit",
1011
"test": "playwright test",
1112
"test:ui": "playwright test --ui",
1213
"test:debug": "playwright test --debug",

src/app/api/auth/login/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export async function POST(request: NextRequest) {
4444
email: user.email,
4545
name: user.name,
4646
callsign: user.callsign,
47-
gridLocator: user.grid_locator
47+
grid_locator: user.grid_locator
4848
}
4949
},
5050
{ status: 200 }

src/app/api/auth/register/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { User } from "@/models/User";
55

66
export async function POST(request: NextRequest) {
77
try {
8-
const { email, password, name, callsign, gridLocator } =
8+
const { email, password, name, callsign, grid_locator } =
99
await request.json();
1010

1111
if (!email || !password || !name) {
@@ -30,7 +30,7 @@ export async function POST(request: NextRequest) {
3030
password: hashedPassword,
3131
name,
3232
callsign,
33-
grid_locator: gridLocator,
33+
grid_locator,
3434
});
3535

3636
const token = jwt.sign(
@@ -47,7 +47,7 @@ export async function POST(request: NextRequest) {
4747
email: newUser.email,
4848
name: newUser.name,
4949
callsign: newUser.callsign,
50-
gridLocator: newUser.grid_locator,
50+
grid_locator: newUser.grid_locator,
5151
},
5252
},
5353
{ status: 201 }

src/app/api/contacts/callsigns/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ export async function GET(request: NextRequest) {
4646
value: row.callsign,
4747
label: row.callsign,
4848
secondary: row.name ? `${row.name}${row.qth ? ` - ${row.qth}` : ''}` : row.qth,
49-
contactCount: parseInt(row.contact_count),
50-
lastContact: row.last_contact
49+
contact_count: parseInt(row.contact_count),
50+
last_contact: row.last_contact
5151
}));
5252

5353
return NextResponse.json({ callsigns });

src/app/api/install/create-admin/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import pool from '@/lib/db';
44

55
export async function POST(request: Request) {
66
try {
7-
const { name, email, password, callsign, gridLocator } = await request.json();
7+
const { name, email, password, callsign, grid_locator } = await request.json();
88

99
// Validate required fields
1010
if (!name || !email || !password || !callsign) {
@@ -28,7 +28,7 @@ export async function POST(request: Request) {
2828
name, email, password, callsign, grid_locator, role, status, created_at
2929
) VALUES ($1, $2, $3, $4, $5, 'admin', 'active', NOW())
3030
RETURNING id, name, email, callsign, role
31-
`, [name, email, hashedPassword, callsign.toUpperCase(), gridLocator?.toUpperCase() || null]);
31+
`, [name, email, hashedPassword, callsign.toUpperCase(), grid_locator?.toUpperCase() || null]);
3232

3333
const user = userResult.rows[0];
3434

@@ -57,7 +57,7 @@ export async function POST(request: Request) {
5757
callsign.toUpperCase(),
5858
`${callsign.toUpperCase()} Station`,
5959
name,
60-
gridLocator?.toUpperCase() || null
60+
grid_locator?.toUpperCase() || null
6161
]);
6262
} else {
6363
// Fallback schema with minimal columns

src/app/api/stats/advanced/route.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,17 +166,17 @@ async function getGeographicAnalytics(whereClause: string, params: unknown[]) {
166166
const gridActivity = await query(gridActivityQuery, params);
167167

168168
return {
169-
countryDistribution: countryDistribution.rows.map(row => ({
169+
country_distribution: countryDistribution.rows.map(row => ({
170170
country: row.country || 'Unknown',
171171
continent: row.continent || 'Unknown',
172172
qsos: parseInt(row.qsos)
173173
})),
174-
continentDistribution: continentDistribution.rows.map(row => ({
174+
continent_distribution: continentDistribution.rows.map(row => ({
175175
continent: row.continent,
176176
qsos: parseInt(row.qsos)
177177
})),
178-
gridActivity: gridActivity.rows.map(row => ({
179-
gridSquare: row.grid_square,
178+
grid_activity: gridActivity.rows.map(row => ({
179+
grid_square: row.grid_square,
180180
qsos: parseInt(row.qsos)
181181
}))
182182
};

src/app/install/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ export default function InstallPage() {
164164
email: formData.email,
165165
password: formData.password,
166166
callsign: formData.callsign,
167-
gridLocator: formData.gridLocator
167+
grid_locator: formData.gridLocator
168168
}),
169169
});
170170

src/app/register/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export default function RegisterPage() {
5757
password: formData.password,
5858
name: formData.name,
5959
callsign: formData.callsign,
60-
gridLocator: formData.gridLocator,
60+
grid_locator: formData.gridLocator,
6161
}),
6262
});
6363

src/app/stats/page.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ interface AdvancedStatsData {
4646
}
4747

4848
interface GeographicStatsData {
49-
countryDistribution: Array<{ country: string; continent: string; qsos: number }>;
50-
continentDistribution: Array<{ continent: string; qsos: number }>;
51-
gridActivity: Array<{ gridSquare: string; qsos: number }>;
49+
country_distribution: Array<{ country: string; continent: string; qsos: number }>;
50+
continent_distribution: Array<{ continent: string; qsos: number }>;
51+
grid_activity: Array<{ grid_square: string; qsos: number }>;
5252
}
5353

5454
interface HeatmapStatsData {
@@ -430,13 +430,13 @@ export default function StatsPage() {
430430
</CardHeader>
431431
<CardContent>
432432
<div className="space-y-2">
433-
{geographicStats.continentDistribution.map(item => (
433+
{geographicStats.continent_distribution.map(item => (
434434
<div key={item.continent} className="flex justify-between items-center">
435435
<span className="font-medium">{item.continent}</span>
436436
<span className="text-muted-foreground">{item.qsos.toLocaleString()}</span>
437437
</div>
438438
))}
439-
{geographicStats.continentDistribution.length === 0 && (
439+
{geographicStats.continent_distribution.length === 0 && (
440440
<p className="text-muted-foreground text-center py-4">No data available</p>
441441
)}
442442
</div>
@@ -450,13 +450,13 @@ export default function StatsPage() {
450450
</CardHeader>
451451
<CardContent>
452452
<div className="space-y-2">
453-
{geographicStats.countryDistribution.slice(0, 10).map(item => (
453+
{geographicStats.country_distribution.slice(0, 10).map(item => (
454454
<div key={item.country} className="flex justify-between items-center">
455455
<span className="font-medium text-sm">{item.country}</span>
456456
<span className="text-muted-foreground text-sm">{item.qsos.toLocaleString()}</span>
457457
</div>
458458
))}
459-
{geographicStats.countryDistribution.length === 0 && (
459+
{geographicStats.country_distribution.length === 0 && (
460460
<p className="text-muted-foreground text-center py-4">No data available</p>
461461
)}
462462
</div>
@@ -470,13 +470,13 @@ export default function StatsPage() {
470470
</CardHeader>
471471
<CardContent>
472472
<div className="space-y-2">
473-
{geographicStats.gridActivity.slice(0, 10).map(item => (
474-
<div key={item.gridSquare} className="flex justify-between items-center">
475-
<span className="font-medium font-mono">{item.gridSquare}</span>
473+
{geographicStats.grid_activity.slice(0, 10).map(item => (
474+
<div key={item.grid_square} className="flex justify-between items-center">
475+
<span className="font-medium font-mono">{item.grid_square}</span>
476476
<span className="text-muted-foreground">{item.qsos.toLocaleString()}</span>
477477
</div>
478478
))}
479-
{geographicStats.gridActivity.length === 0 && (
479+
{geographicStats.grid_activity.length === 0 && (
480480
<p className="text-muted-foreground text-center py-4">No data available</p>
481481
)}
482482
</div>

0 commit comments

Comments
 (0)