Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 183 additions & 11 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -720,38 +720,210 @@ 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);
res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId });
}
});

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 || [
"Pikachu ex SAR 234/193 PSA 10",
"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 });
Expand Down
25 changes: 25 additions & 0 deletions lib/data/firestore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
34 changes: 30 additions & 4 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down
16 changes: 13 additions & 3 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,21 @@ <h1 class="fade-up">Research any Pokemon card<br>in seconds</h1>

<section id="alert-section" class="alert-section hidden">
<div class="alert-box">
<h3>Get price alerts</h3>
<p>We'll email you when this card hits your target price.</p>
<h3>Set an alert</h3>
<div class="alert-type-toggle">
<button type="button" class="alert-type-btn active" data-type="price">Price Drop</button>
<button type="button" class="alert-type-btn" data-type="arbitrage">Arbitrage Spread</button>
</div>
<p id="alert-desc" class="alert-desc">Get notified when the price drops below your target.</p>
<form id="alert-form">
<input type="hidden" id="alert-type" value="price">
<input type="email" id="alert-email" placeholder="you@email.com" required>
<input type="number" id="alert-price" placeholder="Target price ($)" step="0.01" min="0" required>
<div id="price-fields">
<input type="number" id="alert-price" placeholder="Target price ($)" step="0.01" min="0">
</div>
<div id="arb-fields" class="hidden">
<input type="number" id="alert-spread" placeholder="Spread threshold (%)" value="10" min="1" max="100">
</div>
<button type="submit">Set Alert</button>
</form>
<p id="alert-msg" class="alert-msg hidden"></p>
Expand Down
30 changes: 26 additions & 4 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1119,12 +1119,34 @@ main {
}
.alert-box h3 {
font-size: 18px;
margin-bottom: 6px;
margin-bottom: 12px;
}
.alert-type-toggle {
display: flex;
gap: 4px;
justify-content: center;
margin-bottom: 10px;
}
.alert-box > p {
.alert-type-btn {
background: none;
border: 1px solid var(--border);
color: var(--muted);
font-size: 14px;
margin-bottom: 16px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 6px 14px;
border-radius: 9999px;
cursor: pointer;
transition: all 0.2s;
}
.alert-type-btn:hover { color: var(--text); border-color: rgba(255,255,255,0.15); }
.alert-type-btn.active { color: var(--gold); border-color: var(--gold); background: rgba(217, 182, 118, 0.08); }
.alert-desc {
color: var(--muted);
font-size: 13px;
margin-bottom: 14px;
}

#alert-form {
Expand Down
Loading