Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions src/app/api/stations/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
}
Expand Down
14 changes: 7 additions & 7 deletions src/app/api/stations/route.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 });
}
}

Expand Down
10 changes: 4 additions & 6 deletions src/app/new-contact/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { frequencyToBand, AMATEUR_BANDS } from '@/lib/bands';
import { AMATEUR_MODES, defaultRstForMode } from '@/lib/modes';
import {
gridToLatLon,
gridLocatorError,
distanceKm,
bearingDeg,
compassPoint,
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions src/lib/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions tests/grid.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test, expect } from '@playwright/test';
import {
isValidGrid,
gridLocatorError,
gridToLatLon,
distanceKm,
bearingDeg,
Expand Down Expand Up @@ -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
Expand Down
Loading