diff --git a/server/src/services/atlasService.ts b/server/src/services/atlasService.ts index c05d060f..5ec45406 100644 --- a/server/src/services/atlasService.ts +++ b/server/src/services/atlasService.ts @@ -146,6 +146,22 @@ export const NAME_TO_CODE: Record = { 'somalia':'SO','papua new guinea':'PG','brunei':'BN', }; +const US_STATE_CODES = new Set([ + 'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA', + 'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD', + 'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ', + 'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC', + 'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY', + 'DC', +]); + +const US_COUNTRY_MARKERS = new Set([ + 'united states', + 'united states of america', + 'usa', + 'us', +]); + export const CONTINENT_MAP: Record = { AF:'Asia',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia', BA:'Europe',BD:'Asia',BF:'Africa',BH:'Asia',BI:'Africa',BJ:'Africa',BN:'Asia',BO:'South America', @@ -222,14 +238,30 @@ export function getCountryFromAddress(address: string | null): string | null { const normalized = last.toLowerCase(); if (NAME_TO_CODE[normalized]) return NAME_TO_CODE[normalized]; if (NAME_TO_CODE[last]) return NAME_TO_CODE[last]; - if (last.length === 2 && last === last.toUpperCase()) return last; + if (US_COUNTRY_MARKERS.has(normalized)) return 'US'; + + const stateMatch = last.match(/\b([A-Z]{2})\b(?:\s+\d{5}(?:-\d{4})?)?$/); + if (stateMatch && US_STATE_CODES.has(stateMatch[1]) && parts.length > 1) return 'US'; + + if (last.length === 2 && last === last.toUpperCase()) { + if (US_STATE_CODES.has(last) && parts.length > 1) return 'US'; + return last; + } return null; } // ── Resolve a place to a country code (address -> bbox -> geocode) ────────── +function getStoredCountryCode(placeId: number): string | null { + const row = db.prepare('SELECT country_code FROM place_regions WHERE place_id = ?').get(placeId) as { country_code: string } | undefined; + return row?.country_code ?? null; +} + async function resolveCountryCode(place: Place): Promise { - let code = getCountryFromAddress(place.address); + let code = getStoredCountryCode(place.id); + if (!code) { + code = getCountryFromAddress(place.address); + } if (!code && place.lat && place.lng) { code = getCountryFromCoords(place.lat, place.lng); } @@ -240,7 +272,10 @@ async function resolveCountryCode(place: Place): Promise { } function resolveCountryCodeSync(place: Place): string | null { - let code = getCountryFromAddress(place.address); + let code = getStoredCountryCode(place.id); + if (!code) { + code = getCountryFromAddress(place.address); + } if (!code && place.lat && place.lng) { code = getCountryFromCoords(place.lat, place.lng); } diff --git a/server/tests/integration/atlas.test.ts b/server/tests/integration/atlas.test.ts index da6d25d8..5b437ba4 100644 --- a/server/tests/integration/atlas.test.ts +++ b/server/tests/integration/atlas.test.ts @@ -41,7 +41,7 @@ import { createApp } from '../../src/app'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { resetTestDb } from '../helpers/test-db'; -import { createUser } from '../helpers/factories'; +import { createUser, createTrip, createPlace } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; @@ -83,6 +83,80 @@ describe('Atlas stats', () => { expect(res.status).toBe(200); expect(Array.isArray(res.body.places)).toBe(true); }); + + it('ATLAS-002 — US state abbreviations in addresses resolve to US, not foreign countries', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { + title: 'Atlanta Weekend', + start_date: '2025-03-14', + end_date: '2025-03-16', + }); + const place = createPlace(testDb, trip.id, { + name: 'Downtown Atlanta', + lat: 33.749, + lng: -84.388, + }); + + testDb.prepare('UPDATE places SET address = ? WHERE id = ?').run('123 Peachtree St NE, Atlanta, GA', place.id); + + const stats = await request(app) + .get('/api/addons/atlas/stats') + .set('Cookie', authCookie(user.id)); + + expect(stats.status).toBe(200); + const codes = (stats.body.countries as any[]).map((country: any) => country.code); + expect(codes).toContain('US'); + expect(codes).not.toContain('GA'); + + const us = await request(app) + .get('/api/addons/atlas/country/US') + .set('Cookie', authCookie(user.id)); + + expect(us.status).toBe(200); + expect((us.body.places as any[]).map((entry: any) => entry.id)).toContain(place.id); + + const ga = await request(app) + .get('/api/addons/atlas/country/GA') + .set('Cookie', authCookie(user.id)); + + expect(ga.status).toBe(200); + expect(ga.body.places).toHaveLength(0); + }); + + it('ATLAS-002 — stored place region country codes override ambiguous addresses', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { + title: 'Colorado Stop', + start_date: '2025-06-20', + end_date: '2025-06-21', + }); + const place = createPlace(testDb, trip.id, { + name: 'Downtown Denver', + lat: 39.7392, + lng: -104.9903, + }); + + testDb.prepare('UPDATE places SET address = ? WHERE id = ?').run('1701 Wynkoop St, Denver, CO', place.id); + testDb.prepare( + 'INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)' + ).run(place.id, 'US', 'US-CO', 'Colorado'); + + const stats = await request(app) + .get('/api/addons/atlas/stats') + .set('Cookie', authCookie(user.id)); + + expect(stats.status).toBe(200); + const codes = (stats.body.countries as any[]).map((country: any) => country.code); + expect(codes).toContain('US'); + expect(codes).not.toContain('CO'); + + const us = await request(app) + .get('/api/addons/atlas/country/US') + .set('Cookie', authCookie(user.id)); + + expect(us.status).toBe(200); + expect((us.body.places as any[]).map((entry: any) => entry.id)).toContain(place.id); + }); }); describe('Mark/unmark country', () => {