From 80f10aa9dfac418c199c53f7e065660fb261c794 Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Sat, 16 May 2026 01:46:07 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20grade=20history=20=E2=80=94=20cardI?= =?UTF-8?q?d/userId=20in=20logs,=20GET=20/grades/mine,=20DELETE=20/grades/?= =?UTF-8?q?:id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/grade accepts cardId, stores userId + cardId in grade log, returns gradeId - GET /api/grades/mine — user's grade history by userId - DELETE /api/grades/:id — delete your grade (owner check) - getGradeLogsByUser, getGradeLog, deleteGradeLog added to firestore.js - 3 new API tests for grade history endpoints --- api.js | 40 ++++++++++++++++++++++++++++++++++++---- lib/data/firestore.js | 27 ++++++++++++++++++++++++++- test/api-test.js | 27 +++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/api.js b/api.js index 09ad060..e664c43 100644 --- a/api.js +++ b/api.js @@ -15,7 +15,7 @@ import { gradeImage } 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, 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 } 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"; @@ -438,7 +438,7 @@ app.get("/api/psa", apiAuthMiddleware, (req, res, next) => { req._errorType = "p // POST /api/grade app.post("/api/grade", authMiddleware, (req, res, next) => { req._errorType = "grade"; next(); }, async (req, res) => { - const { imageUrl, extraImages, provider, model, cardName, source, listingId, listingPrice, condition, centeringHint } = req.body; + const { imageUrl, extraImages, provider, model, cardName, cardId, source, listingId, listingPrice, condition, centeringHint } = req.body; if (!imageUrl) return res.status(400).json({ error: "Missing required field: imageUrl" }); try { const config = { @@ -452,9 +452,13 @@ app.post("/api/grade", authMiddleware, (req, res, next) => { req._errorType = "g const extras = (extraImages || []).map(u => ({ imageUrl: u })); const grade = await gradeImage(imageUrl, config, extras, centeringHint); + let gradeId = null; if (grade && !grade.error) { - await storeGradeLog({ + const userId = portfolioUserId(req); + gradeId = await storeGradeLog({ ts: new Date().toISOString(), + userId: userId || null, + cardId: cardId || null, cardName: cardName || "unknown", source: source || "api", listingId: listingId || null, @@ -468,7 +472,7 @@ app.post("/api/grade", authMiddleware, (req, res, next) => { req._errorType = "g }); } - res.json({ grade, stored: !!(grade && !grade.error) }); + res.json({ grade, gradeId, stored: !!(grade && !grade.error) }); } catch (e) { logError(req._errorType || "api", e.message, req.originalUrl, req.requestId); res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); @@ -535,6 +539,34 @@ app.get("/api/grade/report/:id", async (req, res) => { } }); +// GET /api/grades/mine — user's grade history +app.get("/api/grades/mine", authMiddleware, async (req, res) => { + try { + const userId = portfolioUserId(req); + if (!userId) return res.status(401).json({ error: "Sign in required" }); + const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 50)); + const grades = await getGradeLogsByUser(userId, { limit }); + res.json({ grades, count: grades.length }); + } catch (e) { + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + +// DELETE /api/grades/:id — delete your grade +app.delete("/api/grades/:id", authMiddleware, async (req, res) => { + try { + const userId = portfolioUserId(req); + if (!userId) return res.status(401).json({ error: "Sign in required" }); + const record = await getGradeLog(req.params.id); + if (!record) return res.status(404).json({ error: "Grade not found" }); + if (record.userId !== userId && !isAdminUser(req)) return res.status(403).json({ error: "Not your grade" }); + await deleteGradeLog(req.params.id); + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + // GET /api/grades app.get("/api/grades", authMiddleware, async (req, res) => { const limit = Math.min(1000, Math.max(1, Number(req.query.limit) || 100)); diff --git a/lib/data/firestore.js b/lib/data/firestore.js index 496b3ed..78ddea0 100644 --- a/lib/data/firestore.js +++ b/lib/data/firestore.js @@ -34,11 +34,36 @@ export async function getGradeLogs({ limit = 100, query, source } = {}) { let results = snap.docs.map(d => ({ id: d.id, ...d.data() })); if (query) { const q = query.toLowerCase(); - results = results.filter(r => (r.cardName || "").toLowerCase().includes(q)); + results = results.filter(r => (r.cardName || "").toLowerCase().includes(q) || r.id === query); } return results; } +export async function getGradeLogsByUser(userId, { limit = 50 } = {}) { + const fs = getDb(); + if (!fs) return []; + const snap = await fs.collection("grade-logs") + .where("userId", "==", userId) + .orderBy("createdAt", "desc") + .limit(limit) + .get(); + return snap.docs.map(d => ({ id: d.id, ...d.data() })); +} + +export async function getGradeLog(id) { + const fs = getDb(); + if (!fs) return null; + const doc = await fs.collection("grade-logs").doc(id).get(); + return doc.exists ? { id: doc.id, ...doc.data() } : null; +} + +export async function deleteGradeLog(id) { + const fs = getDb(); + if (!fs) return false; + await fs.collection("grade-logs").doc(id).delete(); + return true; +} + export async function saveDrop(drop) { const fs = getDb(); if (!fs) return null; diff --git a/test/api-test.js b/test/api-test.js index fcbab00..02dad1b 100644 --- a/test/api-test.js +++ b/test/api-test.js @@ -1204,6 +1204,33 @@ async function run() { assert(res.status === 400, `expected 400, got ${res.status}`); }); + // ── Grade history ── + + console.log("\n\x1b[1m=== grade history ===\x1b[0m"); + + await test("GET /api/grades/mine returns user grades", async () => { + const { res, body } = await json("/api/grades/mine"); + if (res.status === 401) return; + assert(res.status === 200, `expected 200, got ${res.status}`); + assert(Array.isArray(body.grades), "grades should be array"); + assert(typeof body.count === "number", "count should be number"); + }); + + await test("GET /api/grades/mine without auth returns 401", async () => { + const res = await fetch(`${BASE}/api/grades/mine`); + if (res.status === 200) return; + assert(res.status === 401, `expected 401, got ${res.status}`); + }); + + await test("DELETE /api/grades/nonexistent returns 404", async () => { + const res = await fetch(`${BASE}/api/grades/nonexistent`, { + method: "DELETE", + headers: API_KEY ? { "x-api-key": API_KEY } : {}, + }); + if (res.status === 401) return; + assert(res.status === 404, `expected 404, got ${res.status}`); + }); + // ── Summary ── console.log(`\n\x1b[1m=== ${passed} passed, ${failed} failed ===\x1b[0m\n`); From b0d2e044e4c2dd600d1a79b55a9718d8f73b619e Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Sat, 16 May 2026 01:48:01 +0530 Subject: [PATCH 2/2] docs: grade history endpoints, changelog unreleased, internals update --- CHANGELOG.md | 5 +++++ docs/internals.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 119ec36..a4023b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Grade history: cardId/userId stored in grade logs, GET /api/grades/mine, DELETE /api/grades/:id +- POST /api/grade returns gradeId for share links + grade management +- Front-only uploads now get v3 results (no longer falls back to v2) +- Card detection resilient to failures (continues without cropping) + ## 1.3.0 (2026-05-15) - AI grading v3: 8 subgrades (front/back), 60/40 weighting, centering ratios (lr/tb), tilt correction diff --git a/docs/internals.md b/docs/internals.md index b3a88ff..392f41e 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -134,6 +134,11 @@ Use `--refresh` to delete all cache files before a run. 8. Rounding: <0.25 down, 0.25-0.74 to .5, >=0.75 up. 9. Falls back to single combined prompt for non-Claude providers or missing back image. 10. Token usage + estimated cost tracked per grade ($3/$15 per 1M for Claude). +11. `gradeDistribution` computed from overall + confidence (e.g. `{"8": 65, "8.5": 12, "7.5": 23}`). +12. Optional `centeringHint` in request — user-measured ratios appended to centering prompts. +13. `GET /api/grade/report/:id` generates shareable PNG card (SVG→sharp→PNG). +14. Grade logs store `userId` + `cardId` for per-user history and ML training data. +15. `GET /api/grades/mine` returns user's grade history. `DELETE /api/grades/:id` removes a grade. **ML dataset pipeline**: `track-prices` passively saves graded slab images (PSA/BGS/CGC/TAG) from eBay sold listings into `grading-dataset` Firestore collection. `GET /api/grading-dataset/stats` monitors progress.