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
29 changes: 27 additions & 2 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { gradeImage, medianGrade } from "./lib/grading/grading.js";
import { parseListingLanguagesFromInput, filterByCondition, detectCondition, flagPriceOutliers, filterRelevantResults, isGradedCard } from "./lib/search/filters.js";
import { buildEbaySearchQuery } from "./lib/search/listingQuery.js";
import { EBAY_CATEGORY_TCG_SINGLE_CARDS_US } from "./lib/search/ebayCategories.js";
import { saveGradeLog, getGradeLogs, getGradeLogsByUser, getGradeLog, deleteGradeLog, saveDrop, getDrops, getDrop, saveWebhook, getWebhooks, deleteWebhook, getFirestoreStatus, saveAlert, getActiveAlerts, updateAlert, getAlertsByEmail, saveErrorLog, getErrorLogs, clearErrorLogs, getPortfolio, addToPortfolio, removeFromPortfolio, updatePortfolioCard, savePortfolioSnapshot, getPortfolioSnapshots, listPortfolioUserIds, trackSearchFrequency, getTopSearchedCards } from "./lib/data/firestore.js";
import { saveGradeLog, getGradeLogs, getGradeLogsByUser, getGradeLog, deleteGradeLog, saveDrop, getDrops, getDrop, saveWebhook, getWebhooks, deleteWebhook, getFirestoreStatus, saveAlert, getActiveAlerts, updateAlert, getAlertsByEmail, saveErrorLog, getErrorLogs, clearErrorLogs, getPortfolio, addToPortfolio, removeFromPortfolio, updatePortfolioCard, savePortfolioSnapshot, getPortfolioSnapshots, listPortfolioUserIds, trackSearchFrequency, getTopSearchedCards, recordMilestone, getFunnelStats } from "./lib/data/firestore.js";
import { getDemoSearchResult, getDemoResult, listDemoCards, findDemoByNumber } from "./lib/cards/demo.js";
import { csvEscape, csvRow } from "./lib/data/csv.js";
import { createApiKey, listApiKeys, listAllKeys, listKeysByOwner, getApiKey, updateApiKey, deleteApiKey, rotateApiKey, validateApiKey } from "./lib/auth/api-keys.js";
Expand Down Expand Up @@ -372,6 +372,7 @@ app.get("/api/search", apiAuthMiddleware, (req, res, next) => { req._errorType =
}
getOrCreateCard(q, { source: result.source, lang: config.language }).catch(() => {});
trackSearchFrequency(q).catch(() => {});
if (req.userId) recordMilestone(req.userId, "firstSearch").catch(() => {});
res.json(result);
} catch (e) {
logError(req._errorType || "api", e.message, req.originalUrl, req.requestId);
Expand Down Expand Up @@ -503,6 +504,7 @@ app.post("/api/grade", authMiddleware, (req, res, next) => { req._errorType = "g
}
}

if (req.userId && grade && !grade.error) recordMilestone(req.userId, "firstGrade").catch(() => {});
res.json({ grade, gradeId, stored: !!(grade && !grade.error) });
} catch (e) {
logError(req._errorType || "api", e.message, req.originalUrl, req.requestId);
Expand Down Expand Up @@ -613,8 +615,9 @@ app.get("/api/grades", authMiddleware, async (req, res) => {
// GET /api/errors
app.get("/api/errors", authMiddleware, async (req, res) => {
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 20));
const type = req.query.type || undefined;
try {
const errors = await getErrorLogs({ limit });
const errors = await getErrorLogs({ limit, type });
res.json({ errors, count: errors.length });
} catch (e) {
res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId });
Expand Down Expand Up @@ -642,6 +645,16 @@ app.get("/api/analytics", ownerOnly, async (req, res) => {
}
});

// GET /api/funnel
app.get("/api/funnel", ownerOnly, async (req, res) => {
try {
const stats = await getFunnelStats();
res.json(stats);
} catch (e) {
res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId });
}
});

// GET /api/grading-dataset/stats
app.get("/api/grading-dataset/stats", ownerOnly, async (req, res) => {
try {
Expand Down Expand Up @@ -708,6 +721,7 @@ app.post("/auth/google", authLimiter, async (req, res) => {
}

const isAdmin = gUser.sub === process.env.CASECOMP_ADMIN_SUB;
recordMilestone(gUser.sub, "signup").catch(() => {});
res.json({ jwt, apiKey, isAdmin, user: { id: gUser.sub, email: gUser.email, name: gUser.name, picture: gUser.picture } });
} catch (e) {
res.status(401).json({ error: "Invalid Google token" });
Expand Down Expand Up @@ -1999,6 +2013,7 @@ app.post("/api/portfolio", authMiddleware, async (req, res) => {
purchaseSource: purchaseSource || "",
quantity: quantity != null ? Number(quantity) : 1,
});
recordMilestone(userId, "firstPortfolioAdd").catch(() => {});
res.status(201).json({ ok: true, card });
} catch (e) {
logError("portfolio", e.message, req.originalUrl, req.requestId);
Expand Down Expand Up @@ -2426,3 +2441,13 @@ app.listen(PORT, async () => {
}
initCardDatabase().catch(() => {});
});

process.on("unhandledRejection", (reason) => {
const msg = reason instanceof Error ? reason.message : String(reason);
logError("unhandledRejection", msg, reason instanceof Error ? reason.stack?.split("\n")[1]?.trim() : "");
});

process.on("uncaughtException", (err) => {
logError("uncaughtException", err.message, err.stack?.split("\n")[1]?.trim());
setTimeout(() => process.exit(1), 1000);
});
19 changes: 19 additions & 0 deletions lib/data/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,28 +68,47 @@ export async function getAnalytics({ days = 7, limit = 1000 } = {}) {

const byTier = {};
const byPath = {};
const byStatus = {};
const queries = {};
const dailyMap = {};
const users = new Set();
let totalLatency = 0;

for (const d of docs) {
byTier[d.tier] = (byTier[d.tier] || 0) + 1;
byPath[d.path] = (byPath[d.path] || 0) + 1;
const sc = d.status ? String(d.status).charAt(0) + "xx" : "unknown";
byStatus[sc] = (byStatus[sc] || 0) + 1;
if (d.query) queries[d.query] = (queries[d.query] || 0) + 1;
if (d.userId) users.add(d.userId);
totalLatency += d.latencyMs || 0;
const day = d.ts?.split("T")[0];
if (day) {
if (!dailyMap[day]) dailyMap[day] = { count: 0, latency: 0 };
dailyMap[day].count++;
dailyMap[day].latency += d.latencyMs || 0;
}
}

const topQueries = Object.entries(queries)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([query, count]) => ({ query, count }));

const daily = Object.entries(dailyMap)
.sort()
.map(([date, v]) => ({ date, count: v.count, avgLatencyMs: v.count > 0 ? Math.round(v.latency / v.count) : 0 }));

return {
total,
days,
byTier,
byPath,
byStatus,
topQueries,
avgLatencyMs: total > 0 ? Math.round(totalLatency / total) : 0,
uniqueUsers: users.size,
daily,
};
} catch {
return { total: 0, byTier: {}, byPath: {}, topQueries: [], avgLatencyMs: 0 };
Expand Down
51 changes: 49 additions & 2 deletions lib/data/firestore.js
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ export async function saveErrorLog(record) {
await fs.collection("error-logs").add({
...record,
createdAt: Firestore.FieldValue.serverTimestamp(),
expiresAt: new Date(Date.now() + 30 * 86400000),
});
} catch {}
}
Expand All @@ -281,17 +282,63 @@ export async function clearErrorLogs() {
} catch { return 0; }
}

export async function getErrorLogs({ limit = 20 } = {}) {
export async function getErrorLogs({ limit = 20, type } = {}) {
const fs = getDb();
if (!fs) return [];
try {
const snap = await fs.collection("error-logs").orderBy("createdAt", "desc").limit(limit).get();
let q = fs.collection("error-logs");
if (type) q = q.where("type", "==", type);
const snap = await q.orderBy("createdAt", "desc").limit(limit).get();
return snap.docs.map(d => ({ id: d.id, ...d.data() }));
} catch {
return [];
}
}

// ── User milestones (funnel tracking) ──

const MILESTONES_COLLECTION = "user-milestones";
const MILESTONE_FIELDS = ["signup", "firstSearch", "firstGrade", "firstPortfolioAdd"];

export async function recordMilestone(userId, field) {
if (!userId || !MILESTONE_FIELDS.includes(field)) return;
const fs = getDb();
if (!fs) return;
try {
const ref = fs.collection(MILESTONES_COLLECTION).doc(userId);
const doc = await ref.get();
if (doc.exists && doc.data()[field]) return;
await ref.set(
{ userId, [field]: new Date().toISOString(), updatedAt: new Date().toISOString() },
{ merge: true },
);
} catch {}
}

let funnelCache = null;
let funnelCacheAt = 0;

export async function getFunnelStats() {
if (funnelCache && Date.now() - funnelCacheAt < 300000) return funnelCache;
const fs = getDb();
if (!fs) return { signup: 0, firstSearch: 0, firstGrade: 0, firstPortfolioAdd: 0 };
try {
const snap = await fs.collection(MILESTONES_COLLECTION).limit(1000).get();
const result = { signup: 0, firstSearch: 0, firstGrade: 0, firstPortfolioAdd: 0 };
for (const doc of snap.docs) {
const d = doc.data();
for (const f of MILESTONE_FIELDS) {
if (d[f]) result[f]++;
}
}
funnelCache = result;
funnelCacheAt = Date.now();
return result;
} catch {
return { signup: 0, firstSearch: 0, firstGrade: 0, firstPortfolioAdd: 0 };
}
}

// ── Cache (replaces file-based JSON caches) ──

export async function cacheGet(collection, key) {
Expand Down
99 changes: 99 additions & 0 deletions public/admin/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@ main {
justify-content: space-between;
margin-bottom: 16px;
}
.toolbar-right { display: flex; gap: 8px; align-items: center; }

select {
padding: 6px 10px;
background: var(--inset);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 12px;
font-family: inherit;
outline: none;
cursor: pointer;
}
select:focus { border-color: rgba(217, 182, 118, 0.4); }

.card {
background: var(--panel);
Expand Down Expand Up @@ -264,4 +278,89 @@ main {
font-size: 13px;
}

.tab-bar {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
margin-bottom: 20px;
}
.tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--muted);
padding: 10px 20px;
font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border-radius: 0;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--gold); border-bottom-color: var(--gold); }

.analytics-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }

.bar-chart {
display: flex;
align-items: flex-end;
gap: 3px;
height: 140px;
padding: 8px 0;
}
.bar-col { display: flex; flex-direction: column; align-items: center; flex: 1; min-width: 0; }
.bar {
width: 100%;
background: var(--gold);
border-radius: 3px 3px 0 0;
min-height: 2px;
}
.bar-date {
font-size: 9px;
color: var(--muted);
margin-top: 4px;
white-space: nowrap;
}

.hbar-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; }
.hbar-label {
font-size: 12px;
color: var(--text);
min-width: 100px;
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hbar-track { flex: 1; height: 18px; background: var(--inset); border-radius: 4px; overflow: hidden; }
.hbar-fill { height: 100%; background: var(--gold); border-radius: 4px; transition: width 0.3s; }
.hbar-count { font-size: 12px; color: var(--muted); min-width: 40px; text-align: right; font-family: monospace; }

.query-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.query-table th {
text-align: left;
color: var(--muted);
font-weight: 500;
padding: 6px 8px;
border-bottom: 1px solid var(--border);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.query-table td { padding: 6px 8px; border-bottom: 1px solid var(--border); }
.query-table td:last-child { text-align: right; color: var(--muted); font-family: monospace; }

.funnel-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; }
.funnel-label { font-size: 13px; color: var(--text); min-width: 140px; font-weight: 500; }
.funnel-track { flex: 1; height: 28px; background: var(--inset); border-radius: 6px; overflow: hidden; }
.funnel-fill { height: 100%; background: var(--gold); border-radius: 6px; }
.funnel-count {
font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 18px;
font-weight: 700;
min-width: 40px;
text-align: right;
}
.funnel-pct { font-size: 12px; color: var(--muted); min-width: 40px; }

.hidden { display: none !important; }
Loading
Loading