import index from "./web/index.html"; import { getDb, initDb } from "./database.ts"; import { fetchKeepaDataBatch } from "./keepa.ts"; import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts"; import { analyzeProducts } from "./llm.ts"; import type { EnrichedProduct, ProductRecord, SpApiData } from "./types.ts"; type ProcessType = "lead_analysis" | "category_analysis"; type RunRecord = { processType: ProcessType; runId: number; timestamp: string; status: string; jobType: string; source: string | null; output: string | null; totalProducts: number; fbaCount: number; fbmCount: number; skipCount: number; }; type ProductListRecord = { processType: ProcessType; runId: number; asin: string; product_name: string | null; brand: string | null; category: string | null; verdict: "FBA" | "FBM" | "SKIP"; confidence: number | null; sellability_status: string | null; monthly_sold: number | null; seller_count: number | null; amazon_is_seller: number | null; amazon_buybox_share_pct_90d: number | null; sales_rank: number | null; current_price: number | null; avg_price_90d: number | null; reasoning: string | null; fetched_at: string; }; const DB_PATH = process.env.RESULTS_DB_PATH || "./results.db"; const DEFAULT_PAGE_SIZE = 25; const MAX_PAGE_SIZE = 200; const ASIN_PATTERN = /^[A-Z0-9]{10}$/; initDb(DB_PATH); const db = getDb(DB_PATH); function json(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json; charset=utf-8" }, }); } function csv(text: string, filename: string): Response { return new Response(text, { status: 200, headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="${filename}"`, }, }); } function parseIntParam(value: string | null, fallback: number): number { if (!value) return fallback; const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed < 1) return fallback; return parsed; } function normalizeAsin(value: string): string { return value.trim().toUpperCase(); } function isValidAsin(value: string): boolean { return ASIN_PATTERN.test(value); } function parseSort( sortParam: string | null, allowed: Set, fallback: string, ): string { if (!sortParam) return fallback; const clauses = sortParam .split(",") .map((chunk) => chunk.trim()) .filter(Boolean) .map((chunk) => { const [fieldRaw, dirRaw] = chunk.split(":"); const field = fieldRaw?.trim(); const dir = dirRaw?.trim().toUpperCase() === "DESC" ? "DESC" : "ASC"; if (!field || !allowed.has(field)) return null; return `${field} ${dir}`; }) .filter((value): value is string => value !== null); return clauses.length > 0 ? clauses.join(", ") : fallback; } function parseResultSort( sortParam: string | null, allowed: Set, fallback: string, ): string { if (!sortParam) return fallback; const clauses = sortParam .split(",") .map((chunk) => chunk.trim()) .filter(Boolean) .map((chunk) => { const [fieldRaw, dirRaw] = chunk.split(":"); const field = fieldRaw?.trim(); const dir = dirRaw?.trim().toUpperCase() === "DESC" ? "DESC" : "ASC"; if (!field || !allowed.has(field)) return null; if (field === "monthly_sold") return `CAST(COALESCE(monthly_sold, 0) AS INTEGER) ${dir}`; if (field === "amazon_is_seller") return `CAST(COALESCE(amazon_is_seller, 0) AS INTEGER) ${dir}`; if (field === "amazon_buybox_share_pct_90d") { return `CAST(COALESCE(amazon_buybox_share_pct_90d, 0) AS REAL) ${dir}`; } return `${field} ${dir}`; }) .filter((value): value is string => value !== null); return clauses.length > 0 ? clauses.join(", ") : fallback; } function escapeCsvValue(value: unknown): string { if (value === null || value === undefined) return ""; const text = String(value); const escaped = text.replaceAll('"', '""'); return /[",\n]/.test(escaped) ? `"${escaped}"` : escaped; } function parseResultFilters( processType: ProcessType, runId: number, filters: URLSearchParams, ) { const q = filters.get("q")?.trim() || ""; const verdict = filters.get("verdict")?.trim(); const sellabilityStatus = filters.get("sellabilityStatus")?.trim(); const minConfidence = filters.get("minConfidence")?.trim(); const maxConfidence = filters.get("maxConfidence")?.trim(); const conditions: string[] = ["run_id = ?"]; const params: Array = [runId]; if (verdict) { conditions.push("verdict = ?"); params.push(verdict); } if (sellabilityStatus) { conditions.push("sellability_status = ?"); params.push(sellabilityStatus); } if (minConfidence) { conditions.push("confidence >= ?"); params.push(Number(minConfidence)); } if (maxConfidence) { conditions.push("confidence <= ?"); params.push(Number(maxConfidence)); } if (q) { const wildcard = `%${q}%`; if (processType === "lead_analysis") { conditions.push( "(asin LIKE ? OR product_name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)", ); params.push(wildcard, wildcard, wildcard, wildcard, wildcard); } else { conditions.push( "(asin LIKE ? OR name LIKE ? OR brand LIKE ? OR category LIKE ? OR reasoning LIKE ?)", ); params.push(wildcard, wildcard, wildcard, wildcard, wildcard); } } return { where: `WHERE ${conditions.join(" AND ")}`, params, }; } function getRuns(filters: URLSearchParams) { const q = filters.get("q")?.trim() || ""; const processType = filters.get("processType")?.trim(); const status = filters.get("status")?.trim(); const startDate = filters.get("startDate")?.trim(); const endDate = filters.get("endDate")?.trim(); const page = parseIntParam(filters.get("page"), 1); const pageSize = Math.min( parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE, ); const offset = (page - 1) * pageSize; const allowedSort = new Set([ "timestamp", "status", "totalProducts", "fbaCount", "fbmCount", "skipCount", "runId", "jobType", ]); const orderBy = parseSort( filters.get("sort"), allowedSort, "timestamp DESC, runId DESC", ); const conditions: string[] = []; const params: Array = []; if (processType === "lead_analysis" || processType === "category_analysis") { conditions.push("processType = ?"); params.push(processType); } if (status) { conditions.push("status = ?"); params.push(status); } if (startDate) { conditions.push("timestamp >= ?"); params.push(startDate); } if (endDate) { conditions.push("timestamp <= ?"); params.push(endDate); } if (q) { conditions.push( "(jobType LIKE ? OR source LIKE ? OR output LIKE ? OR CAST(runId AS TEXT) LIKE ?)", ); const wildcard = `%${q}%`; params.push(wildcard, wildcard, wildcard, wildcard); } const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const baseUnion = ` SELECT 'lead_analysis' AS processType, id AS runId, timestamp, 'completed' AS status, 'lead_file_analysis' AS jobType, input_file AS source, output_file AS output, COALESCE(total_products, 0) AS totalProducts, COALESCE(fba_count, 0) AS fbaCount, COALESCE(fbm_count, 0) AS fbmCount, COALESCE(skip_count, 0) AS skipCount FROM runs UNION ALL SELECT 'category_analysis' AS processType, id AS runId, run_timestamp AS timestamp, status, category_label AS jobType, CAST(category_id AS TEXT) AS source, NULL AS output, COALESCE(top_asins_checked, 0) AS totalProducts, COALESCE(fba_count, 0) AS fbaCount, COALESCE(fbm_count, 0) AS fbmCount, COALESCE(skip_count, 0) AS skipCount FROM category_analysis_runs `; const totalRow = db .query(`SELECT COUNT(*) as total FROM (${baseUnion}) all_runs ${where}`) .get(...params) as { total: number }; const items = db .query( `SELECT * FROM (${baseUnion}) all_runs ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`, ) .all(...params, pageSize, offset) as RunRecord[]; return { items, page, pageSize, total: totalRow.total, totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)), }; } function getProductList(filters: URLSearchParams) { const q = filters.get("q")?.trim() || ""; const verdict = filters.get("verdict")?.trim(); const page = parseIntParam(filters.get("page"), 1); const pageSize = Math.min( parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE, ); const offset = (page - 1) * pageSize; const conditions: string[] = []; const params: Array = []; if (verdict === "FBA" || verdict === "FBM" || verdict === "SKIP") { conditions.push("verdict = ?"); params.push(verdict); } if (q) { const wildcard = `%${q}%`; conditions.push( "(asin LIKE ? OR product_name LIKE ? OR brand LIKE ? OR category LIKE ?)", ); params.push(wildcard, wildcard, wildcard, wildcard); } const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const allowedSort = new Set([ "asin", "verdict", "monthly_sold", "seller_count", "amazon_is_seller", "amazon_buybox_share_pct_90d", "sales_rank", "current_price", "product_name", "brand", "category", "avg_price_90d", "confidence", "reasoning", "fetched_at", ]); const orderBy = parseResultSort( filters.get("sort"), allowedSort, "CAST(COALESCE(monthly_sold, 0) AS INTEGER) DESC, fetched_at DESC", ); const baseUnion = ` SELECT 'lead_analysis' AS processType, run_id AS runId, asin, product_name, brand, category, verdict, confidence, sellability_status, monthly_sold, sellers AS seller_count, amazon_is_seller, amazon_buybox_share_pct_90d, sales_rank, current_price, avg_price_90d, reasoning, fetched_at FROM results UNION ALL SELECT 'category_analysis' AS processType, run_id AS runId, asin, name AS product_name, brand, category, verdict, confidence, sellability_status, monthly_sold, seller_count, amazon_is_seller, amazon_buybox_share_pct_90d, sales_rank, current_price, avg_price_90d, reasoning, fetched_at FROM product_analysis_results `; const totalRow = db .query(`SELECT COUNT(*) as total FROM (${baseUnion}) all_products ${where}`) .get(...params) as { total: number }; const items = db .query( `SELECT * FROM (${baseUnion}) all_products ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`, ) .all(...params, pageSize, offset) as ProductListRecord[]; return { items, page, pageSize, total: totalRow.total, totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)), }; } function getRun(processType: ProcessType, runId: number) { if (processType === "lead_analysis") { const run = db .query( `SELECT id AS runId, timestamp, 'completed' AS status, 'lead_file_analysis' AS jobType, input_file AS source, output_file AS output, COALESCE(total_products, 0) AS totalProducts, COALESCE(fba_count, 0) AS fbaCount, COALESCE(fbm_count, 0) AS fbmCount, COALESCE(skip_count, 0) AS skipCount FROM runs WHERE id = ?`, ) .get(runId); return run ?? null; } const run = db .query( `SELECT id AS runId, run_timestamp AS timestamp, status, category_label AS jobType, CAST(category_id AS TEXT) AS source, NULL AS output, COALESCE(top_asins_checked, 0) AS totalProducts, COALESCE(fba_count, 0) AS fbaCount, COALESCE(fbm_count, 0) AS fbmCount, COALESCE(skip_count, 0) AS skipCount, error_message AS errorMessage, available_asins AS availableAsins FROM category_analysis_runs WHERE id = ?`, ) .get(runId); return run ?? null; } function getRunResults( processType: ProcessType, runId: number, filters: URLSearchParams, ) { const page = parseIntParam(filters.get("page"), 1); const pageSize = Math.min( parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE, ); const offset = (page - 1) * pageSize; const tableName = processType === "lead_analysis" ? "results" : "product_analysis_results"; const productNameSelect = processType === "lead_analysis" ? "product_name" : "name AS product_name"; const sellerCountSelect = processType === "lead_analysis" ? "sellers AS seller_count" : "seller_count"; const salesRankAvgSelect = processType === "lead_analysis" ? "rank_avg_90d AS sales_rank_avg_90d" : "sales_rank_avg_90d"; const { where, params } = parseResultFilters(processType, runId, filters); const allowedSort = new Set([ "asin", "product_name", "brand", "category", "current_price", "avg_price_90d", "sales_rank", "seller_count", "amazon_is_seller", "amazon_buybox_share_pct_90d", "monthly_sold", "verdict", "confidence", "fetched_at", ]); const orderBy = parseResultSort( filters.get("sort"), allowedSort, "CAST(COALESCE(monthly_sold, 0) AS INTEGER) DESC, asin ASC", ); const totalRow = db .query(`SELECT COUNT(*) as total FROM ${tableName} ${where}`) .get(...params) as { total: number }; const items = db .query( `SELECT id, run_id, asin, ${productNameSelect}, brand, category, unit_cost, current_price, avg_price_90d, avg_price_90d_sheet, selling_price_sheet, sales_rank, ${salesRankAvgSelect}, ${sellerCountSelect}, amazon_is_seller, amazon_buybox_share_pct_90d, monthly_sold, rank_drops_30d, rank_drops_90d, fba_fee, fbm_fee, referral_percent, can_sell, sellability_status, sellability_reason, verdict, confidence, reasoning, fetched_at FROM ${tableName} ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`, ) .all(...params, pageSize, offset); return { items, page, pageSize, total: totalRow.total, totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)), }; } function deleteRun(processType: ProcessType, runId: number) { if (processType === "lead_analysis") { const resultRows = db .query("DELETE FROM results WHERE run_id = ?") .run(runId); const runRows = db.query("DELETE FROM runs WHERE id = ?").run(runId); return { deletedRun: runRows.changes > 0, deletedResults: resultRows.changes, }; } const resultRows = db .query("DELETE FROM product_analysis_results WHERE run_id = ?") .run(runId); const runRows = db .query("DELETE FROM category_analysis_runs WHERE id = ?") .run(runId); return { deletedRun: runRows.changes > 0, deletedResults: resultRows.changes, }; } function exportRunResultsCsv( processType: ProcessType, runId: number, filters: URLSearchParams, ) { const tableName = processType === "lead_analysis" ? "results" : "product_analysis_results"; const productNameSelect = processType === "lead_analysis" ? "product_name" : "name AS product_name"; const sellerCountSelect = processType === "lead_analysis" ? "sellers AS seller_count" : "seller_count"; const salesRankAvgSelect = processType === "lead_analysis" ? "rank_avg_90d AS sales_rank_avg_90d" : "sales_rank_avg_90d"; const { where, params } = parseResultFilters(processType, runId, filters); const allowedSort = new Set([ "asin", "product_name", "brand", "category", "current_price", "avg_price_90d", "sales_rank", "seller_count", "amazon_is_seller", "amazon_buybox_share_pct_90d", "monthly_sold", "verdict", "confidence", "fetched_at", ]); const orderBy = parseResultSort( filters.get("sort"), allowedSort, "CAST(COALESCE(monthly_sold, 0) AS INTEGER) DESC, asin ASC", ); const rows = db .query( `SELECT run_id, asin, ${productNameSelect}, brand, category, unit_cost, current_price, avg_price_90d, ${salesRankAvgSelect}, ${sellerCountSelect}, amazon_is_seller, amazon_buybox_share_pct_90d, monthly_sold, sellability_status, verdict, confidence, reasoning, fetched_at FROM ${tableName} ${where} ORDER BY ${orderBy}`, ) .all(...params) as Array>; const headers = [ "run_id", "asin", "product_name", "brand", "category", "unit_cost", "current_price", "avg_price_90d", "sales_rank_avg_90d", "seller_count", "amazon_is_seller", "amazon_buybox_share_pct_90d", "monthly_sold", "sellability_status", "verdict", "confidence", "reasoning", "fetched_at", ]; const lines = [headers.join(",")]; for (const row of rows) { lines.push(headers.map((h) => escapeCsvValue(row[h])).join(",")); } return lines.join("\n"); } type ReanalyzeSourceRow = { asin: string; product_name: string | null; brand: string | null; category: string | null; unit_cost: number | null; sales_rank: number | null; avg_price_90d_sheet: number | null; selling_price_sheet: number | null; fba_net_sheet: number | null; gross_profit_dollar: number | null; gross_profit_pct: number | null; net_profit_sheet: number | null; roi_sheet: number | null; moq: number | null; moq_cost: number | null; qty_available: number | null; supplier: string | null; source_url: string | null; asin_link: string | null; promo_coupon_code: string | null; notes: string | null; lead_date: string | null; }; function getReanalyzeSourceRow( processType: ProcessType, runId: number, asin: string, ): ReanalyzeSourceRow | null { if (processType === "lead_analysis") { return ( (db .query( `SELECT asin, product_name, brand, category, unit_cost, sales_rank, avg_price_90d_sheet, selling_price_sheet, fba_net_sheet, gross_profit_dollar, gross_profit_pct, net_profit_sheet, roi_sheet, moq, moq_cost, qty_available, supplier, source_url, asin_link, promo_coupon_code, notes, lead_date FROM results WHERE run_id = ? AND asin = ? LIMIT 1`, ) .get(runId, asin) as ReanalyzeSourceRow | null) ?? null ); } return ( (db .query( `SELECT asin, name AS product_name, brand, category, unit_cost, sales_rank, avg_price_90d_sheet, selling_price_sheet, NULL AS fba_net_sheet, NULL AS gross_profit_dollar, NULL AS gross_profit_pct, NULL AS net_profit_sheet, NULL AS roi_sheet, NULL AS moq, NULL AS moq_cost, NULL AS qty_available, NULL AS supplier, NULL AS source_url, NULL AS asin_link, NULL AS promo_coupon_code, NULL AS notes, NULL AS lead_date FROM product_analysis_results WHERE run_id = ? AND asin = ? LIMIT 1`, ) .get(runId, asin) as ReanalyzeSourceRow | null) ?? null ); } function toProductRecord(row: ReanalyzeSourceRow): ProductRecord { return { asin: row.asin, name: row.product_name ?? row.asin, unitCost: row.unit_cost ?? 0, brand: row.brand ?? undefined, category: row.category ?? undefined, amazonRank: row.sales_rank ?? undefined, avgPrice90FromSheet: row.avg_price_90d_sheet ?? undefined, sellingPriceFromSheet: row.selling_price_sheet ?? undefined, fbaNet: row.fba_net_sheet ?? undefined, grossProfit: row.gross_profit_dollar ?? undefined, grossProfitPct: row.gross_profit_pct ?? undefined, netProfitFromSheet: row.net_profit_sheet ?? undefined, roiFromSheet: row.roi_sheet ?? undefined, moq: row.moq ?? undefined, moqCost: row.moq_cost ?? undefined, totalQtyAvail: row.qty_available ?? undefined, supplier: row.supplier ?? undefined, sourceUrl: row.source_url ?? undefined, asinLink: row.asin_link ?? undefined, promoCouponCode: row.promo_coupon_code ?? undefined, notes: row.notes ?? undefined, leadDate: row.lead_date ?? undefined, }; } function unknownSpApiData(): SpApiData { return { fbaFee: 5.0, fbmFee: 1.5, referralFeePercent: 15, estimatedSalePrice: 0, canSell: null, sellabilityStatus: "unknown", sellabilityReason: "Sellability check returned no result", }; } function refreshRunCounts(processType: ProcessType, runId: number): void { if (processType === "lead_analysis") { const stats = db .query( `SELECT COUNT(*) AS total, SUM(CASE WHEN verdict = 'FBA' THEN 1 ELSE 0 END) AS fba, SUM(CASE WHEN verdict = 'FBM' THEN 1 ELSE 0 END) AS fbm, SUM(CASE WHEN verdict = 'SKIP' THEN 1 ELSE 0 END) AS skip FROM results WHERE run_id = ?`, ) .get(runId) as { total: number; fba: number | null; fbm: number | null; skip: number | null; }; db.query( `UPDATE runs SET total_products = ?, fba_count = ?, fbm_count = ?, skip_count = ? WHERE id = ?`, ).run( stats.total ?? 0, stats.fba ?? 0, stats.fbm ?? 0, stats.skip ?? 0, runId, ); return; } const stats = db .query( `SELECT COUNT(*) AS available, SUM(CASE WHEN verdict = 'FBA' THEN 1 ELSE 0 END) AS fba, SUM(CASE WHEN verdict = 'FBM' THEN 1 ELSE 0 END) AS fbm, SUM(CASE WHEN verdict = 'SKIP' THEN 1 ELSE 0 END) AS skip FROM product_analysis_results WHERE run_id = ?`, ) .get(runId) as { available: number; fba: number | null; fbm: number | null; skip: number | null; }; db.query( `UPDATE category_analysis_runs SET available_asins = ?, fba_count = ?, fbm_count = ?, skip_count = ? WHERE id = ?`, ).run( stats.available ?? 0, stats.fba ?? 0, stats.fbm ?? 0, stats.skip ?? 0, runId, ); } async function reanalyzeSingleAsin( processType: ProcessType, runId: number, asin: string, ): Promise<{ asin: string; runId: number; processType: ProcessType; fetchedAt: string; }> { const row = getReanalyzeSourceRow(processType, runId, asin); if (!row) { throw new Error("Result row not found"); } const productRecord = toProductRecord(row); let keepa = null; try { const keepaMap = await fetchKeepaDataBatch([asin]); keepa = keepaMap.get(asin) ?? null; } catch { keepa = null; } const sellabilityMap = await fetchSellabilityBatch([asin]); const sellability = sellabilityMap.get(asin) ?? { canSell: null, sellabilityStatus: "unknown" as const, sellabilityReason: "Sellability check returned no result", }; const spApi = await fetchSpApiPricingAndFees(asin, sellability); if (keepa?.currentPrice && spApi.estimatedSalePrice === 0) { spApi.estimatedSalePrice = keepa.currentPrice; } const enriched: EnrichedProduct = { record: productRecord, keepa, spApi: spApi ?? unknownSpApiData(), fetchedAt: new Date().toISOString(), }; const verdicts = await analyzeProducts([enriched]); const verdict = verdicts[0] ?? { asin, verdict: "SKIP" as const, confidence: 0, reasoning: "LLM analysis returned no verdict", }; const amazonIsSeller = keepa?.amazonIsSeller == null ? null : keepa.amazonIsSeller ? 1 : 0; const fetchedAt = enriched.fetchedAt; if (processType === "lead_analysis") { db.query( `UPDATE results SET current_price = ?, avg_price_90d = ?, sales_rank = ?, rank_avg_90d = ?, sellers = ?, amazon_is_seller = ?, amazon_buybox_share_pct_90d = ?, monthly_sold = ?, rank_drops_30d = ?, rank_drops_90d = ?, fba_fee = ?, fbm_fee = ?, referral_percent = ?, can_sell = ?, sellability_status = ?, sellability_reason = ?, verdict = ?, confidence = ?, reasoning = ?, fetched_at = ? WHERE run_id = ? AND asin = ?`, ).run( keepa?.currentPrice ?? null, keepa?.avgPrice90 ?? null, keepa?.salesRank ?? row.sales_rank ?? null, keepa?.salesRankAvg90 ?? null, keepa?.sellerCount ?? null, amazonIsSeller, keepa?.amazonBuyboxSharePct90d ?? null, keepa?.monthlySold ?? null, keepa?.salesRankDrops30 ?? null, keepa?.salesRankDrops90 ?? null, spApi.fbaFee, spApi.fbmFee, spApi.referralFeePercent, spApi.canSell == null ? null : spApi.canSell ? 1 : 0, spApi.sellabilityStatus, spApi.sellabilityReason ?? null, verdict.verdict, verdict.confidence, verdict.reasoning, fetchedAt, runId, asin, ); } else { db.query( `UPDATE product_analysis_results SET current_price = ?, avg_price_90d = ?, sales_rank = ?, sales_rank_avg_90d = ?, seller_count = ?, amazon_is_seller = ?, amazon_buybox_share_pct_90d = ?, monthly_sold = ?, rank_drops_30d = ?, rank_drops_90d = ?, fba_fee = ?, fbm_fee = ?, referral_percent = ?, can_sell = ?, sellability_status = ?, sellability_reason = ?, verdict = ?, confidence = ?, reasoning = ?, fetched_at = ? WHERE run_id = ? AND asin = ?`, ).run( keepa?.currentPrice ?? null, keepa?.avgPrice90 ?? null, keepa?.salesRank ?? row.sales_rank ?? null, keepa?.salesRankAvg90 ?? null, keepa?.sellerCount ?? null, amazonIsSeller, keepa?.amazonBuyboxSharePct90d ?? null, keepa?.monthlySold ?? null, keepa?.salesRankDrops30 ?? null, keepa?.salesRankDrops90 ?? null, spApi.fbaFee, spApi.fbmFee, spApi.referralFeePercent, spApi.canSell == null ? null : spApi.canSell ? 1 : 0, spApi.sellabilityStatus, spApi.sellabilityReason ?? null, verdict.verdict, verdict.confidence, verdict.reasoning, fetchedAt, runId, asin, ); } refreshRunCounts(processType, runId); return { asin, runId, processType, fetchedAt }; } const server = Bun.serve({ port: Number(process.env.PORT || "3000"), routes: { "/": index, "/products": index, "/runs/:processType/:runId": index, "/api/runs": (req) => { const url = new URL(req.url); return json(getRuns(url.searchParams)); }, "/api/products": (req) => { const url = new URL(req.url); return json(getProductList(url.searchParams)); }, "/api/runs/:processType/:runId": (req) => { const processType = req.params.processType as ProcessType; const runId = Number(req.params.runId); if ( !( processType === "lead_analysis" || processType === "category_analysis" ) || !Number.isInteger(runId) ) { return json({ error: "Invalid run identifier" }, 400); } if (req.method === "DELETE") { const deleted = deleteRun(processType, runId); if (!deleted.deletedRun) return json({ error: "Run not found" }, 404); return json(deleted); } const run = getRun(processType, runId); if (!run) return json({ error: "Run not found" }, 404); const summary = { totalProducts: (run as { totalProducts: number }).totalProducts, fbaCount: (run as { fbaCount: number }).fbaCount, fbmCount: (run as { fbmCount: number }).fbmCount, skipCount: (run as { skipCount: number }).skipCount, }; return json({ processType, ...run, summary }); }, "/api/runs/:processType/:runId/results": (req) => { const processType = req.params.processType as ProcessType; const runId = Number(req.params.runId); if ( !( processType === "lead_analysis" || processType === "category_analysis" ) || !Number.isInteger(runId) ) { return json({ error: "Invalid run identifier" }, 400); } const url = new URL(req.url); const payload = getRunResults(processType, runId, url.searchParams); return json(payload); }, "/api/runs/:processType/:runId/asins/:asin/reanalyze": async (req) => { const processType = req.params.processType as ProcessType; const runId = Number(req.params.runId); const asin = normalizeAsin(req.params.asin); if ( !( processType === "lead_analysis" || processType === "category_analysis" ) || !Number.isInteger(runId) ) { return json({ error: "Invalid run identifier" }, 400); } if (req.method !== "POST") { return json({ error: "Method not allowed" }, 405); } if (!isValidAsin(asin)) { return json({ error: "Invalid ASIN" }, 400); } try { const result = await reanalyzeSingleAsin(processType, runId, asin); return json(result); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message === "Result row not found") { return json({ error: message }, 404); } return json({ error: message }, 500); } }, "/api/runs/:processType/:runId/export.csv": (req) => { const processType = req.params.processType as ProcessType; const runId = Number(req.params.runId); if ( !( processType === "lead_analysis" || processType === "category_analysis" ) || !Number.isInteger(runId) ) { return json({ error: "Invalid run identifier" }, 400); } const url = new URL(req.url); const csvText = exportRunResultsCsv(processType, runId, url.searchParams); return csv(csvText, `run-${processType}-${runId}.csv`); }, }, fetch() { return json({ error: "Not found" }, 404); }, development: { hmr: true, console: true, }, }); console.log(`Results viewer running on http://localhost:${server.port}`);