diff --git a/api.js b/api.js index ee5afc6..c610b31 100644 --- a/api.js +++ b/api.js @@ -16,7 +16,7 @@ import { parseListingLanguagesFromInput, filterByCondition, detectCondition, fla import { buildEbaySearchQuery } from "./lib/search/listingQuery.js"; import { EBAY_CATEGORY_TCG_SINGLE_CARDS_US } from "./lib/search/ebayCategories.js"; import { getRedisStatus, sha256 } from "./lib/data/redis-cache.js"; -import { saveGradeLog, getGradeLogs, saveDrop, getDrops, getDrop, saveWebhook, getWebhooks, deleteWebhook, getFirestoreStatus, saveAlert, saveErrorLog, getErrorLogs, clearErrorLogs } from "./lib/data/firestore.js"; +import { saveGradeLog, getGradeLogs, saveDrop, getDrops, getDrop, saveWebhook, getWebhooks, deleteWebhook, getFirestoreStatus, saveAlert, getActiveAlerts, updateAlert, getAlertsByEmail, saveErrorLog, getErrorLogs, clearErrorLogs } from "./lib/data/firestore.js"; import { getDemoSearchResult, listDemoCards } from "./lib/data/demo.js"; import { createApiKey, listApiKeys, getApiKey, updateApiKey, deleteApiKey, rotateApiKey, validateApiKey } from "./lib/data/api-keys.js"; import { recordSoldPrices, getPriceHistory } from "./lib/data/price-history.js"; @@ -720,12 +720,23 @@ app.get("/api/price-history", apiAuthMiddleware, async (req, res) => { } }); -// POST /api/alerts — collect price alert signups app.post("/api/alerts", authMiddleware, async (req, res) => { - const { email, targetPrice, query } = req.body; + const { email, targetPrice, query, type, spreadThreshold } = req.body; if (!email || !query) return res.status(400).json({ error: "Missing email or query" }); + const alertType = type === "arbitrage" ? "arbitrage" : "price"; try { - await saveAlert({ email, targetPrice: targetPrice || null, query, createdAt: new Date().toISOString() }); + const alert = { + email, + query, + type: alertType, + createdAt: new Date().toISOString(), + }; + if (alertType === "price") { + alert.targetPrice = targetPrice || null; + } else { + alert.spreadThreshold = spreadThreshold != null ? Number(spreadThreshold) : 10; + } + await saveAlert(alert); res.json({ ok: true }); } catch (e) { logError(req._errorType || "api", e.message, req.originalUrl, req.requestId); @@ -733,6 +744,123 @@ app.post("/api/alerts", authMiddleware, async (req, res) => { } }); +app.get("/api/alerts", authMiddleware, async (req, res) => { + const { email } = req.query; + if (!email) return res.status(400).json({ error: "Missing email" }); + try { + const alerts = await getAlertsByEmail(email); + res.json({ alerts, count: alerts.length }); + } catch (e) { + logError("alerts", e.message, req.originalUrl, req.requestId); + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + +app.post("/api/check-alerts", ownerOnly, async (req, res) => { + try { + const alerts = await getActiveAlerts(); + const triggered = []; + const checked = []; + + for (const alert of alerts) { + try { + const now = new Date().toISOString(); + await updateAlert(alert.id, { lastChecked: now }); + + if (alert.type === "arbitrage") { + const sources = ["ebay", "magi", "yahoo", "snkrdunk"]; + const pricesBySource = {}; + + for (const source of sources) { + try { + let data; + const config = buildConfig({ source }); + config._cachePrefix = ""; + if (source === "snkrdunk") { + data = await searchSnkrdunk(alert.query, config); + } else if (source === "magi") { + data = await searchMagi(alert.query, config); + } else if (source === "yahoo") { + data = await searchYahooAuctions(alert.query, config); + } else { + const ebayQuery = buildEbaySearchQuery(alert.query, config); + const activeRes = await searchActive({ query: ebayQuery, relevanceQuery: alert.query, deliveryCountries: config.deliveryCountries, languages: config.languages, config, refresh: false, noEbay: false, getToken, on401 }); + data = { activeByCountry: activeRes.itemsByCountry || {}, source: "ebay" }; + } + const items = []; + for (const arr of Object.values(data.activeByCountry || {})) items.push(...arr); + if (items.length) { + const prices = items.map(i => i.totalCost || i.price).filter(Boolean).sort((a, b) => a - b); + pricesBySource[source] = { lowest: prices[0], count: prices.length }; + } + } catch {} + } + + const sourceNames = Object.keys(pricesBySource); + if (sourceNames.length >= 2) { + const sorted = sourceNames.sort((a, b) => pricesBySource[a].lowest - pricesBySource[b].lowest); + const cheapest = sorted[0]; + const most = sorted[sorted.length - 1]; + const spread = Math.round((pricesBySource[most].lowest - pricesBySource[cheapest].lowest) * 100) / 100; + const spreadPct = Math.round((spread / pricesBySource[most].lowest) * 100); + const threshold = alert.spreadThreshold || 10; + if (spreadPct >= threshold) { + triggered.push({ + alertId: alert.id, + type: "arbitrage", + email: alert.email, + query: alert.query, + cheapestSource: cheapest, + cheapestPrice: pricesBySource[cheapest].lowest, + mostExpensiveSource: most, + mostExpensivePrice: pricesBySource[most].lowest, + spread, + spreadPct, + threshold, + }); + } + } + + checked.push({ alertId: alert.id, type: "arbitrage", query: alert.query }); + } else { + const config = buildConfig({}); + config._cachePrefix = ""; + const ebayQuery = buildEbaySearchQuery(alert.query, config); + let lowestPrice = null; + + try { + const activeRes = await searchActive({ query: ebayQuery, relevanceQuery: alert.query, deliveryCountries: config.deliveryCountries, languages: config.languages, config, refresh: false, noEbay: false, getToken, on401 }); + const items = []; + for (const arr of Object.values(activeRes.itemsByCountry || {})) items.push(...arr); + const prices = items.map(i => i.totalCost || i.price).filter(Boolean).sort((a, b) => a - b); + if (prices.length) lowestPrice = prices[0]; + } catch {} + + if (lowestPrice != null && alert.targetPrice != null && lowestPrice <= alert.targetPrice) { + triggered.push({ + alertId: alert.id, + type: "price", + email: alert.email, + query: alert.query, + currentPrice: lowestPrice, + targetPrice: alert.targetPrice, + }); + } + + checked.push({ alertId: alert.id, type: "price", query: alert.query, currentPrice: lowestPrice }); + } + } catch (e) { + checked.push({ alertId: alert.id, query: alert.query, error: safeErrorMessage(e) }); + } + } + + res.json({ checked: checked.length, triggered: triggered.length, results: triggered, details: checked }); + } catch (e) { + logError("check-alerts", e.message, req.originalUrl, req.requestId); + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + // POST /api/track-prices — scheduled job to record prices for tracked cards app.post("/api/track-prices", authMiddleware, async (req, res) => { const cards = req.body.cards || [ @@ -740,18 +868,62 @@ app.post("/api/track-prices", authMiddleware, async (req, res) => { "Umbreon ex SAR 217/187", "Mega Greninja ex SAR", ]; + const hasEbay = !!(clientId && clientSecret); const results = []; for (const card of cards) { try { - const demoResult = getDemoSearchResult(card); - if (demoResult.sold?.length) { - await recordSoldPrices(card, demoResult.sold, demoResult.source); - results.push({ card, recorded: demoResult.sold.length }); - } else { - results.push({ card, recorded: 0 }); + let ebaySold = []; + let magiSold = []; + let usedDemo = false; + + if (hasEbay) { + try { + const ebayQuery = buildEbaySearchQuery(card, {}); + const soldRes = await Promise.race([ + searchSold({ query: ebayQuery, relevanceQuery: card, languages: [], config: {}, refresh: false, noEbay: false, getToken, on401, soldBrowser: false }), + new Promise(r => setTimeout(() => r({ items: [], source: "timeout" }), 30000)), + ]); + ebaySold = soldRes.items || []; + if (ebaySold.length) { + await recordSoldPrices(card, ebaySold, "ebay"); + } + } catch (e) { + logError("track-prices", `eBay fetch failed for "${card}": ${e.message}`, "/api/track-prices"); + } } + + try { + const magiRes = await searchMagi(card, {}); + magiSold = magiRes.sold || []; + if (magiSold.length) { + await recordSoldPrices(card, magiSold, "magi"); + } + } catch (e) { + logError("track-prices", `Magi fetch failed for "${card}": ${e.message}`, "/api/track-prices"); + } + + if (!ebaySold.length && !magiSold.length) { + const demoResult = getDemoSearchResult(card); + if (demoResult.sold?.length) { + await recordSoldPrices(card, demoResult.sold, demoResult.source); + usedDemo = true; + ebaySold = demoResult.sold; + } + } + + const total = ebaySold.length + magiSold.length; + results.push({ + card, + recorded: total, + sources: { + ebay: ebaySold.length, + magi: magiSold.length, + }, + usedDemo, + lastTracked: new Date().toISOString(), + }); } catch (e) { - results.push({ card, error: e.message }); + results.push({ card, error: e.message, lastTracked: new Date().toISOString() }); } } res.json({ tracked: results.length, results }); diff --git a/lib/data/firestore.js b/lib/data/firestore.js index 1557af5..958ccfe 100644 --- a/lib/data/firestore.js +++ b/lib/data/firestore.js @@ -103,6 +103,31 @@ export async function saveAlert(alert) { return ref.id; } +export async function getActiveAlerts() { + const fs = getDb(); + if (!fs) return []; + const snap = await fs.collection("alerts").where("active", "==", true).get(); + return snap.docs.map(d => ({ id: d.id, ...d.data() })); +} + +export async function updateAlert(id, data) { + const fs = getDb(); + if (!fs) return null; + const ref = fs.collection("alerts").doc(id); + const doc = await ref.get(); + if (!doc.exists) return null; + await ref.update(data); + const updated = await ref.get(); + return { id: updated.id, ...updated.data() }; +} + +export async function getAlertsByEmail(email) { + const fs = getDb(); + if (!fs) return []; + const snap = await fs.collection("alerts").where("email", "==", email).get(); + return snap.docs.map(d => ({ id: d.id, ...d.data() })); +} + // ── Error logs ── export async function saveErrorLog(record) { diff --git a/public/app.js b/public/app.js index b046f6a..5ac7061 100644 --- a/public/app.js +++ b/public/app.js @@ -867,18 +867,44 @@ const fadeObserver = new IntersectionObserver((entries) => { }, { threshold: 0.1 }); document.querySelectorAll(".fade-up").forEach(el => fadeObserver.observe(el)); +document.querySelectorAll(".alert-type-btn").forEach(btn => { + btn.addEventListener("click", () => { + document.querySelectorAll(".alert-type-btn").forEach(b => b.classList.remove("active")); + btn.classList.add("active"); + const type = btn.dataset.type; + document.getElementById("alert-type").value = type; + document.getElementById("price-fields").classList.toggle("hidden", type !== "price"); + document.getElementById("arb-fields").classList.toggle("hidden", type !== "arbitrage"); + document.getElementById("alert-desc").textContent = type === "price" + ? "Get notified when the price drops below your target." + : "Get notified when the cross-source spread exceeds your threshold."; + }); +}); + alertForm.addEventListener("submit", async (e) => { e.preventDefault(); const email = document.getElementById("alert-email").value.trim(); - const price = parseFloat(document.getElementById("alert-price").value); - if (!email || !price || !currentQuery) return; + const type = document.getElementById("alert-type").value; + if (!email || !currentQuery) return; + + const body = { email, query: currentQuery, type }; + if (type === "price") { + body.targetPrice = parseFloat(document.getElementById("alert-price").value); + if (!body.targetPrice) return; + } else { + body.spreadThreshold = parseInt(document.getElementById("alert-spread").value) || 10; + } + try { const res = await fetch("/api/alerts", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, targetPrice: price, query: currentQuery }), + body: JSON.stringify(body), }); - alertMsg.textContent = res.ok ? "Alert set! We'll email you when the price drops." : "Saved — we'll notify you when alerts go live."; + const msg = type === "price" + ? `Price alert set for ${formatPrice(body.targetPrice, "USD")}` + : `Arbitrage alert set for ${body.spreadThreshold}% spread`; + alertMsg.textContent = res.ok ? msg : "Saved — we'll notify you when alerts go live."; alertMsg.style.color = res.ok ? "var(--green)" : "var(--gold)"; } catch { alertMsg.textContent = "Saved — we'll notify you when alerts go live."; diff --git a/public/index.html b/public/index.html index 410db1e..4f337c2 100644 --- a/public/index.html +++ b/public/index.html @@ -70,11 +70,21 @@

Research any Pokemon card
in seconds