diff --git a/src/app/api/stations/[id]/route.ts b/src/app/api/stations/[id]/route.ts index 8dc66e0..43362bd 100644 --- a/src/app/api/stations/[id]/route.ts +++ b/src/app/api/stations/[id]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { Station } from '@/models/Station'; import { verifyToken } from '@/lib/auth'; import { encryptString } from '@/lib/lotw'; +import { gridLocatorError } from '@/lib/grid'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { @@ -61,14 +62,13 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ data.callsign = data.callsign.toUpperCase(); } - // Validate grid locator if provided + // Validate grid locator if provided. Uses the canonical validator so an + // 8-char extended locator (the on-air grid a VHF/microwave op transmits from, + // and the origin of the logging-form distance readout) saves rather than 400s. if (data.grid_locator) { - const gridRegex = /^[A-R]{2}[0-9]{2}([A-X]{2})?$/; - if (!gridRegex.test(data.grid_locator.toUpperCase())) { - return NextResponse.json( - { error: 'Invalid grid locator format' }, - { status: 400 } - ); + const gridError = gridLocatorError(data.grid_locator); + if (gridError) { + return NextResponse.json({ error: gridError }, { status: 400 }); } data.grid_locator = data.grid_locator.toUpperCase(); } diff --git a/src/app/api/stations/route.ts b/src/app/api/stations/route.ts index 4c5a839..021855b 100644 --- a/src/app/api/stations/route.ts +++ b/src/app/api/stations/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { Station } from '@/models/Station'; import { verifyToken } from '@/lib/auth'; +import { gridLocatorError } from '@/lib/grid'; export async function GET(request: NextRequest) { try { @@ -47,14 +48,13 @@ export async function POST(request: NextRequest) { ); } - // Validate grid locator if provided + // Validate grid locator if provided. Uses the canonical validator so an + // 8-char extended locator (the on-air grid a VHF/microwave op transmits from, + // and the origin of the logging-form distance readout) saves rather than 400s. if (data.grid_locator) { - const gridRegex = /^[A-R]{2}[0-9]{2}([A-X]{2})?$/; - if (!gridRegex.test(data.grid_locator.toUpperCase())) { - return NextResponse.json( - { error: 'Invalid grid locator format' }, - { status: 400 } - ); + const gridError = gridLocatorError(data.grid_locator); + if (gridError) { + return NextResponse.json({ error: gridError }, { status: 400 }); } } diff --git a/src/app/new-contact/page.tsx b/src/app/new-contact/page.tsx index c0ab0c2..f3dcb75 100644 --- a/src/app/new-contact/page.tsx +++ b/src/app/new-contact/page.tsx @@ -40,6 +40,7 @@ import { frequencyToBand, AMATEUR_BANDS } from '@/lib/bands'; import { AMATEUR_MODES, defaultRstForMode } from '@/lib/modes'; import { gridToLatLon, + gridLocatorError, distanceKm, bearingDeg, compassPoint, @@ -349,12 +350,9 @@ export default function NewContactPage() { ? null : 'Invalid callsign format'; }; - const validateGridLocator = (grid: string): string | null => { - if (!grid.trim()) return null; - return /^[A-R]{2}[0-9]{2}([A-X]{2})?$/i.test(grid) - ? null - : 'Invalid grid locator format (e.g., FN31pr)'; - }; + // Delegates to the canonical validator in @/lib/grid so the logging form and + // the stations API stay in lockstep and both accept 8-char extended locators. + const validateGridLocator = (grid: string): string | null => gridLocatorError(grid); const validateFrequency = (frequency: string): string | null => { if (!frequency.trim()) return null; const freq = parseFloat(frequency); diff --git a/src/lib/grid.ts b/src/lib/grid.ts index c38cae9..e6eec80 100644 --- a/src/lib/grid.ts +++ b/src/lib/grid.ts @@ -23,6 +23,19 @@ export function isValidGrid(grid: string): boolean { return GRID_RE.test(grid.trim().toUpperCase()); } +// Validation helper for form/API inputs where the grid is optional: returns null +// when the field is blank OR a well-formed locator, and a human-readable error +// message otherwise. Centralizing this on isValidGrid keeps the logging form and +// the stations API from re-deriving their own regex — which is how they drifted +// behind the 8-char (extended) locators the rest of the app already accepts, +// silently rejecting a valid VHF/microwave grid on save. +export function gridLocatorError(grid: string): string | null { + if (!grid.trim()) return null; + return isValidGrid(grid) + ? null + : 'Invalid grid locator format (e.g., FN31, FN31pr, or FN31pr55)'; +} + // Convert a Maidenhead locator to the latitude/longitude of the *center* of the // square (4-char), subsquare (6-char), or extended square (8-char). Returns // null for anything that isn't a valid locator. Centering matches diff --git a/tests/grid.spec.ts b/tests/grid.spec.ts index b095967..46b30d9 100644 --- a/tests/grid.spec.ts +++ b/tests/grid.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@playwright/test'; import { isValidGrid, + gridLocatorError, gridToLatLon, distanceKm, bearingDeg, @@ -41,6 +42,28 @@ test.describe('isValidGrid', () => { }); }); +test.describe('gridLocatorError', () => { + test('treats blank/whitespace as no error (grid is optional)', () => { + expect(gridLocatorError('')).toBeNull(); + expect(gridLocatorError(' ')).toBeNull(); + }); + + test('accepts 4-, 6-, and 8-character locators like isValidGrid', () => { + expect(gridLocatorError('FN31')).toBeNull(); + expect(gridLocatorError('FN31pr')).toBeNull(); + // The regression this guards: an 8-char extended locator is valid app-wide + // (isValidGrid, gridToLatLon) and must not be rejected by form/API checks. + expect(gridLocatorError('FN31pr55')).toBeNull(); + expect(gridLocatorError(' jn58td99 ')).toBeNull(); + }); + + test('returns a helpful message for a malformed locator', () => { + expect(gridLocatorError('nope')).toContain('Invalid grid locator'); + expect(gridLocatorError('FN31p')).toContain('Invalid grid locator'); + expect(gridLocatorError('FN3155')).toContain('Invalid grid locator'); + }); +}); + test.describe('gridToLatLon', () => { test('returns the center of a 4-character square', () => { // JJ00 straddles the prime meridian / equator origin of the grid; its