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} */ 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} 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} */ 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), }; }