import { createHash } from 'node:crypto'; import { sql } from '../sql.js'; import { geocodeUsZip } from './nominatim.js'; import { fetchGameStores } from './overpass.js'; export const STORES_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; /** * @param {number} lat * @param {number} lng * @param {number} radiusMeters * @returns {string} */ export function buildStoresCacheKey(lat, lng, radiusMeters) { const payload = `${lat.toFixed(5)}:${lng.toFixed(5)}:${radiusMeters}`; return createHash('sha256').update(payload).digest('hex').slice(0, 64); } /** * @param {Date | string | null | undefined} fetchedAt * @param {number} [ttlMs] * @returns {boolean} */ export function isStoresCacheFresh(fetchedAt, ttlMs = STORES_CACHE_TTL_MS) { if (!fetchedAt) return false; const ts = fetchedAt instanceof Date ? fetchedAt.getTime() : new Date(fetchedAt).getTime(); if (!Number.isFinite(ts)) return false; return Date.now() - ts < ttlMs; } /** * @param {string} zip * @param {{ fetchImpl?: typeof fetch }} [options] * @returns {Promise<{ lat: number, lng: number, displayName: string, cached: boolean } | null>} */ export async function getZipCoords(zip, options = {}) { const cached = await sql` SELECT lat, lng, display_name FROM geo_zip_cache WHERE zip = ${zip} LIMIT 1 `; if (cached.rows.length > 0) { const row = cached.rows[0]; return { lat: Number(row.lat), lng: Number(row.lng), displayName: row.display_name ?? zip, cached: true, }; } const geocoded = await geocodeUsZip(zip, options); if (!geocoded) { return null; } await sql` INSERT INTO geo_zip_cache (zip, lat, lng, display_name) VALUES (${zip}, ${geocoded.lat}, ${geocoded.lng}, ${geocoded.displayName}) ON CONFLICT (zip) DO UPDATE SET lat = EXCLUDED.lat, lng = EXCLUDED.lng, display_name = EXCLUDED.display_name, geocoded_at = CURRENT_TIMESTAMP `; return { ...geocoded, cached: false }; } /** * @param {number} lat * @param {number} lng * @param {number} radiusMeters * @param {{ fetchImpl?: typeof fetch, forceRefresh?: boolean }} [options] * @returns {Promise<{ stores: unknown[], cached: boolean }>} */ export async function getStoresNear(lat, lng, radiusMeters, options = {}) { const cacheKey = buildStoresCacheKey(lat, lng, radiusMeters); if (!options.forceRefresh) { const cached = await sql` SELECT stores, fetched_at FROM geo_stores_cache WHERE cache_key = ${cacheKey} LIMIT 1 `; if (cached.rows.length > 0 && isStoresCacheFresh(cached.rows[0].fetched_at)) { return { stores: cached.rows[0].stores, cached: true }; } } const stores = await fetchGameStores(lat, lng, radiusMeters, options); await sql` INSERT INTO geo_stores_cache (cache_key, lat, lng, radius_meters, stores, fetched_at) VALUES (${cacheKey}, ${lat}, ${lng}, ${radiusMeters}, ${JSON.stringify(stores)}::jsonb, CURRENT_TIMESTAMP) ON CONFLICT (cache_key) DO UPDATE SET lat = EXCLUDED.lat, lng = EXCLUDED.lng, radius_meters = EXCLUDED.radius_meters, stores = EXCLUDED.stores, fetched_at = CURRENT_TIMESTAMP `; return { stores, cached: false }; }