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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 36 additions & 4 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = {
Expand All @@ -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,
Expand All @@ -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 });
Expand Down Expand Up @@ -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));
Expand Down
5 changes: 5 additions & 0 deletions docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
27 changes: 26 additions & 1 deletion lib/data/firestore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions test/api-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
Loading