deckhearth/lib/pricing/pricing-service.js
Randall Stillwell d0468015eb
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
feat(pricing): convoy for online prices and local stores
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>
2026-09-08 22:47:46 -05:00

256 lines
6.8 KiB
JavaScript

import Redis from 'ioredis';
import { sql } from '../sql.js';
import { isPriceStale } from './card-price.js';
import { buildOutboundLinks } from './outbound-links.js';
import { recordPriceSnapshots } from './price-history.js';
import { getTcgdexBaseUrl, parseTcgdexPricing } from './tcgdex-client.js';
import { fetchFolioPriceIndex, matchFolioPrice } from './foolio-client.js';
const REFRESH_LOCK_TTL_SEC = 60;
const REFRESH_LOCK_PREFIX = 'deckhearth:pricing-refresh:';
let redisClient = null;
function getRedis() {
const url = process.env.REDIS_URL;
if (!url) return null;
if (!redisClient) {
redisClient = new Redis(url, {
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
});
}
return redisClient;
}
/**
* @param {number} cardId
* @returns {Promise<boolean>}
*/
async function acquireRefreshLock(cardId) {
const redis = getRedis();
if (!redis) return true;
try {
const result = await redis.set(
`${REFRESH_LOCK_PREFIX}${cardId}`,
'1',
'EX',
REFRESH_LOCK_TTL_SEC,
'NX'
);
return result === 'OK';
} catch (err) {
console.error('[pricing-service] lock acquire failed', err);
return false;
}
}
/**
* @param {number} cardId
*/
async function releaseRefreshLock(cardId) {
const redis = getRedis();
if (!redis) return;
try {
await redis.del(`${REFRESH_LOCK_PREFIX}${cardId}`);
} catch (err) {
console.error('[pricing-service] lock release failed', err);
}
}
/**
* @param {string} scryfallId
*/
async function fetchScryfallPrices(scryfallId) {
const response = await fetch(`https://api.scryfall.com/cards/${scryfallId}`, {
headers: { 'User-Agent': 'DeckHearth/1.0', Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Scryfall API error: ${response.status}`);
}
const card = await response.json();
let currentPrice = null;
let marketPrice = null;
if (card.prices) {
currentPrice = card.prices.usd ? parseFloat(card.prices.usd) : null;
marketPrice = card.prices.usd_foil ? parseFloat(card.prices.usd_foil) : null;
}
return { currentPrice, marketPrice, currency: 'USD' };
}
/**
* @param {{ set_code?: string, card_number?: string, name?: string }} card
*/
async function fetchPokemonPrices(card) {
const setCode = card.set_code;
if (!setCode) return null;
const base = getTcgdexBaseUrl();
if (!card.card_number) return null;
const paths = [
`/cards/${encodeURIComponent(setCode)}-${encodeURIComponent(card.card_number)}`,
];
for (const path of paths) {
try {
const response = await fetch(`${base}${path}`, {
headers: { Accept: 'application/json', 'User-Agent': 'DeckHearth/1.0' },
});
if (!response.ok) continue;
const tcgCard = await response.json();
const parsed = parseTcgdexPricing(tcgCard);
if (parsed.currentPrice != null || parsed.marketPrice != null) {
return {
currentPrice: parsed.currentPrice,
marketPrice: parsed.marketPrice,
currency: parsed.currency ?? 'USD',
};
}
} catch {
// try next path
}
}
return null;
}
/**
* @param {{ name?: string, set_code?: string, scryfall_id?: string }} card
*/
async function fetchLorcanaPrices(card) {
const index = await fetchFolioPriceIndex();
const matched = matchFolioPrice(index, {
name: card.name,
setCode: card.set_code,
scryfallId: card.scryfall_id,
});
if (!matched) return null;
return {
currentPrice: matched.currentPrice,
marketPrice: matched.marketPrice,
currency: matched.currency ?? 'USD',
};
}
/**
* @param {Record<string, unknown>} card
*/
async function refreshLivePrices(card) {
const game = String(card.game || '').toLowerCase();
let prices = null;
if (game === 'mtg' && card.scryfall_id) {
prices = await fetchScryfallPrices(card.scryfall_id);
} else if (game === 'pokemon') {
prices = await fetchPokemonPrices(card);
} else if (game === 'lorcana') {
prices = await fetchLorcanaPrices(card);
}
if (!prices || (prices.currentPrice == null && prices.marketPrice == null)) {
return null;
}
await sql`
UPDATE cards
SET
current_price = ${prices.currentPrice},
market_price = ${prices.marketPrice},
prices_updated_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ${card.id}
`;
const snapshots = [];
if (prices.currentPrice != null) {
snapshots.push({
cardId: card.id,
source: game === 'mtg' ? 'scryfall' : game === 'pokemon' ? 'tcgdex' : 'foilio',
priceType: 'market',
currency: prices.currency ?? 'USD',
amount: prices.currentPrice,
});
}
if (prices.marketPrice != null && prices.marketPrice !== prices.currentPrice) {
snapshots.push({
cardId: card.id,
source: game === 'mtg' ? 'scryfall' : game === 'pokemon' ? 'tcgdex' : 'foilio',
priceType: 'foil',
currency: prices.currency ?? 'USD',
amount: prices.marketPrice,
});
}
if (snapshots.length) {
await recordPriceSnapshots(snapshots);
}
return prices;
}
/**
* @param {number} cardId
* @returns {Promise<null | {
* prices: { current: number | null, market: number | null, currency: string },
* pricesUpdatedAt: string | null,
* stale: boolean,
* outbound: { tcgplayer: string, ebay: string },
* }>}
*/
export async function getCardPricing(cardId) {
const result = await sql`
SELECT
id, name, game, set_name, set_code, card_number,
current_price, market_price, prices_updated_at, scryfall_id
FROM cards
WHERE id = ${cardId}
`;
if (result.rows.length === 0) {
return null;
}
let card = result.rows[0];
let stale = isPriceStale(card.prices_updated_at);
if (stale) {
const gotLock = await acquireRefreshLock(cardId);
if (gotLock) {
try {
await refreshLivePrices(card).catch((err) => {
console.error('[pricing-service] live refresh failed', err);
});
const refreshed = await sql`
SELECT
id, name, game, set_name, set_code, card_number,
current_price, market_price, prices_updated_at, scryfall_id
FROM cards
WHERE id = ${cardId}
`;
if (refreshed.rows.length) {
card = refreshed.rows[0];
stale = isPriceStale(card.prices_updated_at);
}
} finally {
await releaseRefreshLock(cardId);
}
}
}
const current = card.current_price != null ? parseFloat(card.current_price) : null;
const market = card.market_price != null ? parseFloat(card.market_price) : null;
return {
prices: {
current: Number.isFinite(current) ? current : null,
market: Number.isFinite(market) ? market : null,
currency: 'USD',
},
pricesUpdatedAt: card.prices_updated_at
? new Date(card.prices_updated_at).toISOString()
: null,
stale,
outbound: buildOutboundLinks(card),
};
}