111 lines
3.1 KiB
JavaScript
111 lines
3.1 KiB
JavaScript
|
|
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 };
|
||
|
|
}
|