Some checks failed
CI / Lint (pull_request) Has been cancelled
CI / Schema map up to date (pull_request) Has been cancelled
CI / Forbidden patterns (9 checks) (pull_request) Has been cancelled
CI / Migrations apply (node-pg-migrate) (pull_request) Has been cancelled
Visual diff / Should run? (pull_request) Has been cancelled
CI / Unit tests (vitest) (pull_request) Has been cancelled
Convoy metrics gate / Require role-event telemetry on convoy PRs (pull_request) Has been cancelled
PR Health rollup / Aggregate gate status (pull_request) Has been cancelled
Preview smoke / Should run? (pull_request) Has been cancelled
Preview smoke / Playwright smoke (pull_request) Has been cancelled
Visual diff / Screenshot diff (pull_request) Has been cancelled
Add pricing schema, free API integrations (MTG bulk, TCGdex, Foolio), valuation and sort-by-price, card detail pricing UI, and nearby store discovery via home zip and OSM geo clients. Co-authored-by: Cursor <cursoragent@cursor.com>
110 lines
3.1 KiB
JavaScript
110 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 };
|
|
}
|