diff --git a/.env.example b/.env.example index 7495c70..e0d1b64 100644 --- a/.env.example +++ b/.env.example @@ -75,6 +75,16 @@ LOCAL_GRADER_URL= # Google OAuth client ID for dashboard login #GOOGLE_OAUTH_CLIENT_ID= # +# ── Email notifications (Resend) ───────────────────────────── +# API key from https://resend.com/api-keys +# Used by check-alerts to send price/arbitrage alert emails. +# If unset, alerts trigger but no email is sent. +#RESEND_API_KEY=re_ +# +# Set to any value once casecomp.xyz is verified in Resend to send from alerts@casecomp.xyz +# Without this, emails come from onboarding@resend.dev +#RESEND_VERIFIED_DOMAIN=1 + # Firestore for persistent storage (grades, drops, webhooks). # On Cloud Run, auto-authenticates via the service account — no config needed. # Locally, set this to your GCP project ID and run: gcloud auth application-default login diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d52b68..7bbaafe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ - AI grading: corner crop preprocessing via sharp (8 magnified crops from front+back for corners subgrade) - AI grading: all listing images passed to centering/edges/surface (corners uses front+back + crops only) - eBay image resolution upgrade: s-l500 (500px) to s-l1600 (full resolution) +- Email notifications: Resend integration for price and arbitrage alerts with 6h dedup +- Portfolio tracker: Firestore CRUD, 5 API endpoints (GET/POST/DELETE/PATCH /api/portfolio + /api/portfolio/summary) +- Portfolio demo data: 3 cards (Umbreon, Greninja, Pikachu) with purchase prices and current values +- Portfolio dashboard UI section with stats grid and card list showing ROI ### Changed - Dashboard UI synced with casecomp.xyz frontend: Inter Tight + JetBrains Mono fonts, pill-style tabs/hints, ghost view button @@ -43,7 +47,7 @@ - Card identity: cleaned up long names (strips pack names, condition text from titles) - track-prices: now also tracks cards from active alerts, not just 3 hardcoded defaults - Demo condition filter: checks detectedCondition in addition to raw condition field -- Tests: 214 total (98 unit + 76 API + 40 smoke), up from 183 +- Tests: 224 total (108 unit + 76 API + 40 smoke), up from 183 - AI grading prompts: full PSA rubric (5-10), perspective correction, per-corner/edge detail, holo-specific surface guidance - Demo grades re-evaluated with improved prompts (more conservative scores, honest confidence) - Removed dead code: Redis import from api.js, updateCardField from card-identity.js diff --git a/README.md b/README.md index eac32d8..9831378 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ public/admin/ Admin dashboard (keys, stats, errors) extension/ Chrome extension: queue auto-join, drop intel terraform/ GCP infra: Cloud Run ×2, Firestore, LB + CDN, Secret Manager test/ - unit-test.js 98 unit tests + unit-test.js 108 unit tests api-test.js 76 API integration tests smoke-test.js 40 Playwright smoke tests (dashboard UI) ``` @@ -234,7 +234,7 @@ Load unpacked from `extension/` in `chrome://extensions`. ## Tests -214 tests: 98 unit (filters, grading, query builder, card identity, condition detection, demo data, resolveCardIdToQuery, findDemoByNumber, image preprocessing, image resolution) + 76 API (health, drops, webhooks, search, sold, PSA, grade, auth, admin keys, arbitrage, price-history, condition, alerts, share pages, demo validation) + 40 Playwright smoke (dashboard UI, detail panel, tabs, PSA stats, arbitrage, mobile viewport). +224 tests: 108 unit (filters, grading, query builder, card identity, condition detection, demo data, resolveCardIdToQuery, findDemoByNumber, image preprocessing, image resolution, email alerts, portfolio ROI) + 76 API (health, drops, webhooks, search, sold, PSA, grade, auth, admin keys, arbitrage, price-history, condition, alerts, share pages, demo validation, portfolio) + 40 Playwright smoke (dashboard UI, detail panel, tabs, PSA stats, arbitrage, mobile viewport). ## Contributing diff --git a/api.js b/api.js index dfde7f6..7272277 100644 --- a/api.js +++ b/api.js @@ -15,10 +15,11 @@ import { gradeImage } from "./lib/grading/grading.js"; import { parseListingLanguagesFromInput, filterByCondition, detectCondition, flagPriceOutliers, filterRelevantResults } 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 } from "./lib/data/firestore.js"; +import { saveGradeLog, getGradeLogs, saveDrop, getDrops, getDrop, saveWebhook, getWebhooks, deleteWebhook, getFirestoreStatus, saveAlert, getActiveAlerts, updateAlert, getAlertsByEmail, saveErrorLog, getErrorLogs, clearErrorLogs, getPortfolio, addToPortfolio, removeFromPortfolio, updatePortfolioCard } from "./lib/data/firestore.js"; import { getDemoSearchResult, listDemoCards, findDemoByNumber } 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"; +import { sendAlertEmail } from "./lib/data/email.js"; import { seedFromTCGPlayer } from "./lib/sources/tcgplayer.js"; import { getOrCreateCard, findCardByQuery, parseCardIdentity, resolveCardIdToQuery, SET_NAME_MAP } from "./lib/data/card-identity.js"; import { fileURLToPath } from "url"; @@ -42,7 +43,7 @@ app.use((req, res, next) => { req.requestId = crypto.randomUUID().slice(0, 8); res.setHeader("X-Request-Id", req.requestId); res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); if (req.method === "OPTIONS") return res.sendStatus(204); next(); @@ -857,11 +858,15 @@ app.post("/api/check-alerts", ownerOnly, async (req, res) => { const triggered = []; const checked = []; + const SIX_HOURS_MS = 6 * 60 * 60 * 1000; + for (const alert of alerts) { try { const now = new Date().toISOString(); await updateAlert(alert.id, { lastChecked: now }); + const recentlyTriggered = alert.lastTriggered && (Date.now() - new Date(alert.lastTriggered).getTime()) < SIX_HOURS_MS; + if (alert.type === "arbitrage") { const sources = ["ebay", "magi", "yahoo", "snkrdunk"]; const pricesBySource = {}; @@ -901,7 +906,7 @@ app.post("/api/check-alerts", ownerOnly, async (req, res) => { const spreadPct = Math.round((spread / pricesBySource[most].lowest) * 100); const threshold = alert.spreadThreshold || 10; if (spreadPct >= threshold) { - triggered.push({ + const triggerData = { alertId: alert.id, type: "arbitrage", email: alert.email, @@ -913,7 +918,13 @@ app.post("/api/check-alerts", ownerOnly, async (req, res) => { spread, spreadPct, threshold, - }); + }; + triggered.push(triggerData); + if (!recentlyTriggered) { + const emailResult = await sendAlertEmail(alert, triggerData).catch(() => ({ sent: false })); + const ts = new Date().toISOString(); + await updateAlert(alert.id, { lastTriggered: ts, lastNotified: ts, lastEmailResult: emailResult }).catch(() => {}); + } } } @@ -933,14 +944,20 @@ app.post("/api/check-alerts", ownerOnly, async (req, res) => { } catch {} if (lowestPrice != null && alert.targetPrice != null && lowestPrice <= alert.targetPrice) { - triggered.push({ + const triggerData = { alertId: alert.id, type: "price", email: alert.email, query: alert.query, currentPrice: lowestPrice, targetPrice: alert.targetPrice, - }); + }; + triggered.push(triggerData); + if (!recentlyTriggered) { + const emailResult = await sendAlertEmail(alert, triggerData).catch(() => ({ sent: false })); + const ts = new Date().toISOString(); + await updateAlert(alert.id, { lastTriggered: ts, lastNotified: ts, lastEmailResult: emailResult }).catch(() => {}); + } } checked.push({ alertId: alert.id, type: "price", query: alert.query, currentPrice: lowestPrice }); @@ -957,6 +974,171 @@ app.post("/api/check-alerts", ownerOnly, async (req, res) => { } }); +// ============ Portfolio ============ + +const DEMO_PORTFOLIO = [ + { cardId: "sv8a/217-187", query: "Umbreon ex SAR 217/187", addedAt: "2026-04-20T10:00:00Z", purchasePrice: 370, purchaseSource: "ebay", quantity: 1, notes: "" }, + { cardId: "m4/114-083", query: "Mega Greninja ex SAR", addedAt: "2026-04-22T14:30:00Z", purchasePrice: 310, purchaseSource: "snkrdunk", quantity: 1, notes: "" }, + { cardId: "m2a/234-193", query: "Pikachu ex SAR 234/193 PSA 10", addedAt: "2026-04-25T09:15:00Z", purchasePrice: 720, purchaseSource: "magi", quantity: 1, notes: "" }, +]; + +const DEMO_CURRENT_PRICES = { + "sv8a/217-187": { currentPrice: 400, source: "ebay" }, + "m4/114-083": { currentPrice: 384, source: "snkrdunk" }, + "m2a/234-193": { currentPrice: 741, source: "magi" }, +}; + +function portfolioUserId(req) { + const token = getRequestToken(req); + if (!token) return isLocal ? "local-dev" : null; + return crypto.createHash("sha256").update(token).digest("hex").slice(0, 16); +} + +function calculatePortfolioStats(cards) { + const totalCost = cards.reduce((sum, c) => sum + (c.purchasePrice || 0) * (c.quantity || 1), 0); + const totalValue = cards.reduce((sum, c) => sum + (c.currentPrice || 0) * (c.quantity || 1), 0); + const totalROI = totalValue - totalCost; + const roiPercent = totalCost > 0 ? Math.round((totalROI / totalCost) * 10000) / 100 : 0; + return { totalValue: Math.round(totalValue * 100) / 100, totalCost: Math.round(totalCost * 100) / 100, totalROI: Math.round(totalROI * 100) / 100, roiPercent }; +} + +function getDemoPortfolioCards() { + return DEMO_PORTFOLIO.map(card => { + const prices = DEMO_CURRENT_PRICES[card.cardId] || {}; + const currentPrice = prices.currentPrice || 0; + const roi = card.purchasePrice > 0 ? Math.round(((currentPrice - card.purchasePrice) / card.purchasePrice) * 10000) / 100 : 0; + return { ...card, currentPrice, currentSource: prices.source || "", roi }; + }); +} + +async function enrichPortfolioCards(cards) { + return Promise.all(cards.map(async (card) => { + let currentPrice = 0; + let currentSource = ""; + try { + const history = await getPriceHistory(card.query, { days: 30 }); + if (history.length) { + currentPrice = history[0].price; + currentSource = history[0].source || ""; + } + } catch {} + const roi = card.purchasePrice > 0 ? Math.round(((currentPrice - card.purchasePrice) / card.purchasePrice) * 10000) / 100 : 0; + return { ...card, currentPrice, currentSource, roi }; + })); +} + +app.get("/api/portfolio/summary", apiAuthMiddleware, async (req, res) => { + const isDemo = req.query.demo === "true"; + + try { + let cards; + if (isDemo) { + cards = getDemoPortfolioCards(); + } else { + const userId = portfolioUserId(req); + if (!userId) return res.status(401).json({ error: "Invalid or missing API key" }); + const raw = await getPortfolio(userId); + cards = await enrichPortfolioCards(raw); + } + + const stats = calculatePortfolioStats(cards); + let bestPerformer = null; + let worstPerformer = null; + for (const c of cards) { + if (!bestPerformer || c.roi > bestPerformer.roi) bestPerformer = c; + if (!worstPerformer || c.roi < worstPerformer.roi) worstPerformer = c; + } + + res.json({ + totalCards: cards.reduce((n, c) => n + (c.quantity || 1), 0), + uniqueCards: cards.length, + ...stats, + bestPerformer: bestPerformer ? { cardId: bestPerformer.cardId, query: bestPerformer.query, roi: bestPerformer.roi } : null, + worstPerformer: worstPerformer ? { cardId: worstPerformer.cardId, query: worstPerformer.query, roi: worstPerformer.roi } : null, + _demo: isDemo || undefined, + }); + } catch (e) { + logError("portfolio", e.message, req.originalUrl, req.requestId); + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + +app.get("/api/portfolio", apiAuthMiddleware, async (req, res) => { + const isDemo = req.query.demo === "true"; + + try { + let cards; + if (isDemo) { + cards = getDemoPortfolioCards(); + } else { + const userId = portfolioUserId(req); + if (!userId) return res.status(401).json({ error: "Invalid or missing API key" }); + const raw = await getPortfolio(userId); + cards = await enrichPortfolioCards(raw); + } + + const stats = calculatePortfolioStats(cards); + res.json({ cards, ...stats, _demo: isDemo || undefined }); + } catch (e) { + logError("portfolio", e.message, req.originalUrl, req.requestId); + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + +app.post("/api/portfolio", authMiddleware, async (req, res) => { + const { cardId, query, purchasePrice, purchaseSource, quantity } = req.body; + if (!cardId || !/^[a-z0-9.]+\/[\d]+-[\d]+$/i.test(cardId)) { + return res.status(400).json({ error: "Invalid or missing cardId. Format: setCode/number-total (e.g. sv8a/217-187)" }); + } + + const userId = portfolioUserId(req); + if (!userId) return res.status(401).json({ error: "Invalid or missing API key" }); + + try { + const card = await addToPortfolio(userId, { + cardId, + query: query || "", + purchasePrice: purchasePrice != null ? Number(purchasePrice) : 0, + purchaseSource: purchaseSource || "", + quantity: quantity != null ? Number(quantity) : 1, + }); + res.status(201).json({ ok: true, card }); + } catch (e) { + logError("portfolio", e.message, req.originalUrl, req.requestId); + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + +app.delete("/api/portfolio/:cardId", authMiddleware, async (req, res) => { + const cardId = decodeURIComponent(req.params.cardId); + const userId = portfolioUserId(req); + if (!userId) return res.status(401).json({ error: "Invalid or missing API key" }); + + try { + const removed = await removeFromPortfolio(userId, cardId); + if (!removed) return res.status(404).json({ error: "Card not found in portfolio" }); + res.json({ ok: true, cardId }); + } catch (e) { + logError("portfolio", e.message, req.originalUrl, req.requestId); + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + +app.patch("/api/portfolio/:cardId", authMiddleware, async (req, res) => { + const cardId = decodeURIComponent(req.params.cardId); + const userId = portfolioUserId(req); + if (!userId) return res.status(401).json({ error: "Invalid or missing API key" }); + + try { + const updated = await updatePortfolioCard(userId, cardId, req.body); + if (!updated) return res.status(404).json({ error: "Card not found in portfolio" }); + res.json({ ok: true, card: updated }); + } catch (e) { + logError("portfolio", 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 defaultCards = [ diff --git a/lib/data/email.js b/lib/data/email.js new file mode 100644 index 0000000..2dd7622 --- /dev/null +++ b/lib/data/email.js @@ -0,0 +1,137 @@ +import { Resend } from "resend"; + +const FROM_ADDRESS = "Casecomp Alerts "; +const FALLBACK_FROM = "Casecomp Alerts "; + +let resend = null; + +function getClient() { + if (resend) return resend; + const key = process.env.RESEND_API_KEY; + if (!key) return null; + resend = new Resend(key); + return resend; +} + +export function buildAlertEmailSubject(alert, triggerData) { + if (alert.type === "arbitrage") { + return `Arbitrage alert: ${alert.query} spread ${triggerData.spreadPct}%`; + } + return `Price alert: ${alert.query} below $${triggerData.currentPrice}`; +} + +function buildPriceEmailHtml(alert, triggerData) { + return ` + + + + + +
+ + +
+

Price Alert Triggered

+

${alert.query}

+ + + + + + + + +
+ CURRENT PRICE
+ $${triggerData.currentPrice} +
+ YOUR TARGET
+ $${alert.targetPrice} +
+ SOURCE
+ eBay +
+

Casecomp -- casecomp.xyz

+
+
+ +`; +} + +function buildArbitrageEmailHtml(alert, triggerData) { + return ` + + + + + +
+ + +
+

Arbitrage Alert Triggered

+

${alert.query}

+ + + + + + + + + +
+ CHEAPEST
+ $${triggerData.cheapestPrice} + ${triggerData.cheapestSource} +
+ MOST EXPENSIVE
+ $${triggerData.mostExpensivePrice} + ${triggerData.mostExpensiveSource} +
+ SPREAD
+ $${triggerData.spread} (${triggerData.spreadPct}%) +
+ THRESHOLD
+ ${triggerData.threshold}% +
+

Casecomp -- casecomp.xyz

+
+
+ +`; +} + +export async function sendAlertEmail(alert, triggerData) { + const client = getClient(); + if (!client) { + console.warn("[email] RESEND_API_KEY not set, skipping email notification"); + return { skipped: true, reason: "no_api_key" }; + } + + const subject = buildAlertEmailSubject(alert, triggerData); + const html = alert.type === "arbitrage" + ? buildArbitrageEmailHtml(alert, triggerData) + : buildPriceEmailHtml(alert, triggerData); + + const from = process.env.RESEND_VERIFIED_DOMAIN ? FROM_ADDRESS : FALLBACK_FROM; + + try { + const { data, error } = await client.emails.send({ + from, + to: [alert.email], + subject, + html, + }); + + if (error) { + console.error(`[email] Resend error for ${alert.email}:`, error.message); + return { sent: false, error: error.message }; + } + + return { sent: true, id: data.id }; + } catch (e) { + console.error(`[email] Failed to send to ${alert.email}:`, e.message); + return { sent: false, error: e.message }; + } +} diff --git a/lib/data/firestore.js b/lib/data/firestore.js index 958ccfe..c29f904 100644 --- a/lib/data/firestore.js +++ b/lib/data/firestore.js @@ -128,6 +128,65 @@ export async function getAlertsByEmail(email) { return snap.docs.map(d => ({ id: d.id, ...d.data() })); } +// ── Portfolio ── + +export async function getPortfolio(userId) { + const fs = getDb(); + if (!fs) return []; + try { + const snap = await fs.collection("portfolios").doc(userId).collection("cards").get(); + return snap.docs.map(d => ({ ...d.data() })); + } catch { + return []; + } +} + +export async function addToPortfolio(userId, card) { + const fs = getDb(); + if (!fs) return null; + const docId = card.cardId.replace(/\//g, "_"); + const doc = { + cardId: card.cardId, + query: card.query || "", + addedAt: card.addedAt || new Date().toISOString(), + purchasePrice: card.purchasePrice || 0, + purchaseSource: card.purchaseSource || "", + quantity: card.quantity || 1, + notes: card.notes || "", + }; + await fs.collection("portfolios").doc(userId).collection("cards").doc(docId).set(doc); + return doc; +} + +export async function removeFromPortfolio(userId, cardId) { + const fs = getDb(); + if (!fs) return false; + const docId = cardId.replace(/\//g, "_"); + const ref = fs.collection("portfolios").doc(userId).collection("cards").doc(docId); + const doc = await ref.get(); + if (!doc.exists) return false; + await ref.delete(); + return true; +} + +export async function updatePortfolioCard(userId, cardId, data) { + const fs = getDb(); + if (!fs) return null; + const docId = cardId.replace(/\//g, "_"); + const ref = fs.collection("portfolios").doc(userId).collection("cards").doc(docId); + const doc = await ref.get(); + if (!doc.exists) return null; + const allowed = {}; + if (data.purchasePrice != null) allowed.purchasePrice = Number(data.purchasePrice); + if (data.quantity != null) allowed.quantity = Number(data.quantity); + if (data.notes != null) allowed.notes = String(data.notes); + if (data.purchaseSource != null) allowed.purchaseSource = String(data.purchaseSource); + if (data.query != null) allowed.query = String(data.query); + await ref.update(allowed); + const updated = await ref.get(); + return updated.data(); +} + // ── Error logs ── export async function saveErrorLog(record) { diff --git a/lib/swagger.js b/lib/swagger.js index 7c225f7..8523073 100644 --- a/lib/swagger.js +++ b/lib/swagger.js @@ -186,6 +186,78 @@ export const swaggerSpec = { }, }, }, + "/api/portfolio": { + get: { + tags: ["Portfolio"], + summary: "Get portfolio cards with current values", + description: "Returns all cards in the user's portfolio enriched with current market prices and ROI. Use ?demo=true for sample data.", + security: [{ bearerAuth: [] }], + parameters: [ + { name: "demo", in: "query", schema: { type: "string", enum: ["true"] }, description: "Use sample portfolio data" }, + ], + responses: { + 200: { description: "Portfolio with cards and stats", content: { "application/json": { schema: { $ref: "#/components/schemas/PortfolioResponse" } } } }, + 401: { description: "Invalid or missing API key", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + }, + }, + post: { + tags: ["Portfolio"], + summary: "Add a card to portfolio", + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { "application/json": { schema: { $ref: "#/components/schemas/PortfolioCardRequest" } } }, + }, + responses: { + 201: { description: "Card added", content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, card: { $ref: "#/components/schemas/PortfolioCard" } } } } } }, + 400: { description: "Invalid cardId format", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + }, + }, + }, + "/api/portfolio/summary": { + get: { + tags: ["Portfolio"], + summary: "Portfolio summary stats", + description: "Total cards, cost, current value, ROI, best/worst performers.", + security: [{ bearerAuth: [] }], + parameters: [ + { name: "demo", in: "query", schema: { type: "string", enum: ["true"] }, description: "Use sample portfolio data" }, + ], + responses: { + 200: { description: "Portfolio summary", content: { "application/json": { schema: { $ref: "#/components/schemas/PortfolioSummary" } } } }, + }, + }, + }, + "/api/portfolio/{cardId}": { + delete: { + tags: ["Portfolio"], + summary: "Remove a card from portfolio", + security: [{ bearerAuth: [] }], + parameters: [ + { name: "cardId", in: "path", required: true, schema: { type: "string" }, description: "URL-encoded card ID (e.g. sv8a%2F217-187)" }, + ], + responses: { + 200: { description: "Card removed", content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, cardId: { type: "string" } } } } } }, + 404: { description: "Card not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + }, + }, + patch: { + tags: ["Portfolio"], + summary: "Update a portfolio card", + security: [{ bearerAuth: [] }], + parameters: [ + { name: "cardId", in: "path", required: true, schema: { type: "string" }, description: "URL-encoded card ID" }, + ], + requestBody: { + required: true, + content: { "application/json": { schema: { type: "object", properties: { purchasePrice: { type: "number" }, quantity: { type: "integer" }, notes: { type: "string" }, purchaseSource: { type: "string" } } } } }, + }, + responses: { + 200: { description: "Card updated", content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, card: { $ref: "#/components/schemas/PortfolioCard" } } } } } }, + 404: { description: "Card not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + }, + }, + }, }, components: { securitySchemes: { @@ -351,6 +423,55 @@ export const swaggerSpec = { type: "object", properties: { error: { type: "string" } }, }, + PortfolioCard: { + type: "object", + properties: { + cardId: { type: "string", example: "sv8a/217-187" }, + query: { type: "string", example: "Umbreon ex SAR 217/187" }, + addedAt: { type: "string", format: "date-time" }, + purchasePrice: { type: "number", example: 370 }, + purchaseSource: { type: "string", example: "ebay" }, + quantity: { type: "integer", example: 1 }, + notes: { type: "string" }, + currentPrice: { type: "number", example: 400 }, + currentSource: { type: "string" }, + roi: { type: "number", example: 8.11, description: "ROI percentage" }, + }, + }, + PortfolioCardRequest: { + type: "object", + required: ["cardId"], + properties: { + cardId: { type: "string", example: "sv8a/217-187", description: "Canonical card ID: setCode/number-total" }, + query: { type: "string", example: "Umbreon ex SAR 217/187" }, + purchasePrice: { type: "number", example: 370 }, + purchaseSource: { type: "string", example: "ebay" }, + quantity: { type: "integer", default: 1 }, + }, + }, + PortfolioResponse: { + type: "object", + properties: { + cards: { type: "array", items: { $ref: "#/components/schemas/PortfolioCard" } }, + totalValue: { type: "number" }, + totalCost: { type: "number" }, + totalROI: { type: "number" }, + roiPercent: { type: "number" }, + }, + }, + PortfolioSummary: { + type: "object", + properties: { + totalCards: { type: "integer" }, + uniqueCards: { type: "integer" }, + totalValue: { type: "number" }, + totalCost: { type: "number" }, + totalROI: { type: "number" }, + roiPercent: { type: "number" }, + bestPerformer: { type: "object", nullable: true, properties: { cardId: { type: "string" }, query: { type: "string" }, roi: { type: "number" } } }, + worstPerformer: { type: "object", nullable: true, properties: { cardId: { type: "string" }, query: { type: "string" }, roi: { type: "number" } } }, + }, + }, }, }, }; diff --git a/package.json b/package.json index 495ad08..be9dfa1 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "ioredis": "^5.10.1", "minimist": "^1.2.8", "playwright": "^1.59.1", + "resend": "^6.12.3", "sharp": "^0.34.5", "swagger-ui-express": "^5.0.1", "tough-cookie": "^5.1.2" diff --git a/public/app.js b/public/app.js index 5ac7061..d664bcd 100644 --- a/public/app.js +++ b/public/app.js @@ -912,3 +912,56 @@ alertForm.addEventListener("submit", async (e) => { } alertMsg.classList.remove("hidden"); }); + +// ── Portfolio ── + +const portfolioLoadBtn = document.getElementById("portfolio-load"); +const portfolioStatsEl = document.getElementById("portfolio-stats"); +const portfolioCardsEl = document.getElementById("portfolio-cards"); + +if (portfolioLoadBtn) { + portfolioLoadBtn.addEventListener("click", loadPortfolio); +} + +async function loadPortfolio() { + portfolioLoadBtn?.classList.add("hidden"); + portfolioCardsEl.innerHTML = "

Loading...

"; + try { + const res = await fetch("/api/portfolio?demo=true"); + const data = await res.json(); + renderPortfolio(data); + } catch { + portfolioCardsEl.innerHTML = "

Failed to load portfolio

"; + } +} + +function renderPortfolio(data) { + const roiClass = data.roiPercent >= 0 ? "positive" : "negative"; + const roiSign = data.roiPercent >= 0 ? "+" : ""; + + portfolioStatsEl.innerHTML = ` +
Cards
${data.cards.length}
+
Total Cost
${formatPrice(data.totalCost, "USD")}
+
Current Value
${formatPrice(data.totalValue, "USD")}
+
ROI
${roiSign}${data.roiPercent}%
+ `; + portfolioStatsEl.classList.remove("hidden"); + + portfolioCardsEl.innerHTML = data.cards.map(c => { + const cRoiClass = c.roi >= 0 ? "positive" : "negative"; + const cRoiSign = c.roi >= 0 ? "+" : ""; + return `
+
+ ${c.query} + ${c.cardId} · ${c.purchaseSource} +
+
+
+ ${formatPrice(c.currentPrice, "USD")} + Bought: ${formatPrice(c.purchasePrice, "USD")} +
+ ${cRoiSign}${c.roi}% +
+
`; + }).join(""); +} diff --git a/public/index.html b/public/index.html index 4f337c2..1b483da 100644 --- a/public/index.html +++ b/public/index.html @@ -110,6 +110,14 @@

AI Pre-Grade

+ +
+

Portfolio Tracker

+

Track your collection value and ROI across all sources.

+ +
+ +