import index from "./web/index.html"; import path from "node:path"; import * as XLSX from "xlsx"; import { getDb, initDb } from "./database.ts"; import { fetchKeepaDataBatch, lookupKeepaUpcs, mapUpcsToAsins, } from "./keepa.ts"; import { runUpcFileAnalysis } from "./upc-file-analysis.ts"; import { fetchSellabilityBatch, fetchSpApiPricingAndFees } from "./sp-api.ts"; import { analyzeProducts } from "./llm.ts"; import type { EnrichedProduct, KeepaUpcLookupDetail, 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; }; type StalkerResultRecord = { runId: number; started_at: string; status: string; input_file: string; seller_id: string; seller_name: string | null; rating: number | null; rating_count: number | null; storefront_asin_total: number | null; persisted_inventory_sample_count: number | null; discovered_from_count: number; first_seen_at: string; last_seen_at: string; persisted_inventory_asin_count: number; inventory_sample_asins: string | null; }; type StalkerProductRecord = { runId: number; started_at: string; seller_id: string; seller_name: string | null; rating: number | null; rating_count: number | null; asin: string; can_sell: number; sellability_status: string; sellability_reason: string | null; product_title: string | null; brand: string | null; category_tree: string | null; current_price: number | null; avg_price_90d: number | null; sales_rank: number | null; monthly_sold: number | null; seller_count: number | null; amazon_is_seller: number | null; verdict: string | null; confidence: number | null; reasoning: string | null; last_seen_at: string; }; const DB_PATH = process.env.RESULTS_DB_PATH || path.join("db", "results.db"); const DEFAULT_PAGE_SIZE = 25; const MAX_PAGE_SIZE = 200; const ASIN_PATTERN = /^[A-Z0-9]{10}$/; const MAX_UPCS_PER_REQUEST = 1000; const USE_CLAUDE = process.argv.includes("--claude"); 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 xlsx(buffer: ArrayBuffer, filename: string): Response { return new Response(buffer, { status: 200, headers: { "content-type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "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 splitRawUpcValues(input: string): string[] { return input .split(/[\s,;|]+/) .map((chunk) => chunk.trim()) .filter(Boolean); } function collectUpcsFromUnknown(value: unknown, target: string[]): void { if (typeof value === "string") { target.push(...splitRawUpcValues(value)); return; } if (typeof value === "number" && Number.isFinite(value)) { target.push(String(Math.trunc(value))); return; } if (Array.isArray(value)) { for (const item of value) { collectUpcsFromUnknown(item, target); } } } function normalizeAndDedupeUpcs(values: string[]): string[] { const seen = new Set(); const normalized: string[] = []; for (const value of values) { const upc = value.trim(); if (!upc || seen.has(upc)) continue; seen.add(upc); normalized.push(upc); } return normalized; } function parseUpcsFromSearchParams(params: URLSearchParams): string[] { const parsed: string[] = []; for (const value of params.getAll("upc")) { collectUpcsFromUnknown(value, parsed); } const upcsValue = params.get("upcs"); if (upcsValue) { collectUpcsFromUnknown(upcsValue, parsed); } return normalizeAndDedupeUpcs(parsed); } async function parseUpcsFromRequest(req: Request): Promise { if (req.method === "GET") { const url = new URL(req.url); return parseUpcsFromSearchParams(url.searchParams); } if (req.method !== "POST") { throw new Error("Method not allowed"); } let body: unknown; try { body = await req.json(); } catch { throw new Error("Invalid JSON body"); } const parsed: string[] = []; if (body && typeof body === "object" && "upcs" in body) { collectUpcsFromUnknown((body as { upcs?: unknown }).upcs, parsed); } else { collectUpcsFromUnknown(body, parsed); } return normalizeAndDedupeUpcs(parsed); } function validateUpcRequest(upcs: string[]): string | null { if (upcs.length === 0) { return "Provide at least one UPC via query (?upc=...) or JSON body { upcs: [...] }"; } if (upcs.length > MAX_UPCS_PER_REQUEST) { return `Too many UPCs. Maximum allowed per request is ${MAX_UPCS_PER_REQUEST}.`; } return null; } function summarizeLookupStatuses( details: KeepaUpcLookupDetail[], ): Record { const counts: Record = {}; for (const detail of details) { counts[detail.status] = (counts[detail.status] ?? 0) + 1; } return counts; } function parsePositiveIntField( value: unknown, fieldName: string, ): number | undefined { if (value == null) return undefined; if (typeof value === "number") { if (!Number.isInteger(value) || value < 1) { throw new Error(`${fieldName} must be a positive integer`); } return value; } if (typeof value === "string" && value.trim().length > 0) { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed < 1) { throw new Error(`${fieldName} must be a positive integer`); } return parsed; } throw new Error(`${fieldName} must be a positive integer`); } type UpcFileProcessRequest = { inputFile: string; outputFile?: string; inputBatchSize?: number; upcLookupBatchSize?: number; maxRows?: number; }; async function parseUpcFileProcessRequest( req: Request, ): Promise { if (req.method !== "POST") { throw new Error("Method not allowed"); } let body: unknown; try { body = await req.json(); } catch { throw new Error("Invalid JSON body"); } if (!body || typeof body !== "object") { throw new Error("Request body must be an object"); } const parsedBody = body as Record; const inputFileValue = parsedBody.inputFile; if ( typeof inputFileValue !== "string" || inputFileValue.trim().length === 0 ) { throw new Error("inputFile is required and must be a non-empty string"); } const outputFileValue = parsedBody.outputFile; if ( outputFileValue != null && (typeof outputFileValue !== "string" || outputFileValue.trim().length === 0) ) { throw new Error("outputFile must be a non-empty string when provided"); } return { inputFile: inputFileValue.trim(), outputFile: typeof outputFileValue === "string" ? outputFileValue.trim() : undefined, inputBatchSize: parsePositiveIntField( parsedBody.inputBatchSize, "inputBatchSize", ), upcLookupBatchSize: parsePositiveIntField( parsedBody.upcLookupBatchSize, "upcLookupBatchSize", ), maxRows: parsePositiveIntField(parsedBody.maxRows, "maxRows"), }; } 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 amazonIsSeller = filters.get("amazonIsSeller")?.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 (amazonIsSeller === "yes") { conditions.push("amazon_is_seller = 1"); } else if (amazonIsSeller === "no") { conditions.push("amazon_is_seller = 0"); } 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 amazonIsSeller = filters.get("amazonIsSeller")?.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 (amazonIsSeller === "yes") { conditions.push("amazon_is_seller = 1"); } else if (amazonIsSeller === "no") { conditions.push("amazon_is_seller = 0"); } 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 parseStalkerFilters(filters: URLSearchParams) { const q = filters.get("q")?.trim() || ""; const sellerId = filters.get("sellerId")?.trim().toUpperCase() || ""; const runIdRaw = filters.get("runId")?.trim() || ""; const minRatingCountRaw = filters.get("minRatingCount")?.trim() || ""; const maxRatingCountRaw = filters.get("maxRatingCount")?.trim() || ""; const conditions: string[] = []; const params: Array = []; if (runIdRaw) { const runId = Number(runIdRaw); if (Number.isInteger(runId) && runId > 0) { conditions.push("r.id = ?"); params.push(runId); } } if (sellerId) { conditions.push("s.seller_id = ?"); params.push(sellerId); } if (minRatingCountRaw) { conditions.push("s.rating_count >= ?"); params.push(Number(minRatingCountRaw)); } if (maxRatingCountRaw) { conditions.push("s.rating_count <= ?"); params.push(Number(maxRatingCountRaw)); } if (q) { const wildcard = `%${q}%`; conditions.push( `(s.seller_id LIKE ? OR s.seller_name LIKE ? OR EXISTS ( SELECT 1 FROM stalker_seller_inventory inv_q WHERE inv_q.run_id = r.id AND inv_q.seller_id = s.seller_id AND inv_q.asin LIKE ? ))`, ); params.push(wildcard, wildcard, wildcard); } return { where: conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "", params, }; } function parseStalkerSort(sortParam: string | null): string { const allowedSort = new Set([ "runId", "started_at", "seller_id", "seller_name", "rating", "rating_count", "discovered_from_count", "persisted_inventory_asin_count", "storefront_asin_total", "last_seen_at", ]); const parsed = parseSort( sortParam, allowedSort, "persisted_inventory_asin_count DESC, last_seen_at DESC, seller_id ASC", ); return parsed .replaceAll("runId", "runId") .replaceAll("rating_count", "rating_count") .replaceAll( "persisted_inventory_asin_count", "persisted_inventory_asin_count", ) .replaceAll("storefront_asin_total", "storefront_asin_total"); } function getStalkerResults(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 { where, params } = parseStalkerFilters(filters); const orderBy = parseStalkerSort(filters.get("sort")); const baseSelect = ` SELECT r.id AS runId, r.started_at, r.status, r.input_file, s.seller_id, s.seller_name, s.rating, s.rating_count, s.storefront_asin_total, s.persisted_inventory_sample_count, COUNT(DISTINCT sc.source_asin) AS discovered_from_count, MIN(sc.fetched_at) AS first_seen_at, MAX(sc.fetched_at) AS last_seen_at, COUNT(DISTINCT inv.asin) AS persisted_inventory_asin_count, GROUP_CONCAT(DISTINCT inv.asin) AS inventory_sample_asins FROM stalker_asin_sellers sas JOIN stalker_asin_scans sc ON sc.id = sas.scan_id JOIN stalker_runs r ON r.id = sc.run_id JOIN stalker_sellers s ON s.seller_id = sas.seller_id LEFT JOIN stalker_seller_inventory inv ON inv.run_id = r.id AND inv.seller_id = s.seller_id ${where} GROUP BY r.id, s.seller_id `; const totalRow = db .query(`SELECT COUNT(*) AS total FROM (${baseSelect}) stalker_rows`) .get(...params) as { total: number }; const summary = db .query( `SELECT COUNT(DISTINCT runId) AS runs, COUNT(DISTINCT seller_id) AS sellers, COALESCE(SUM(persisted_inventory_asin_count), 0) AS persistedInventoryAsins FROM (${baseSelect}) stalker_rows`, ) .get(...params) as { runs: number; sellers: number; persistedInventoryAsins: number; }; const items = db .query( `SELECT * FROM (${baseSelect}) stalker_rows ORDER BY ${orderBy} LIMIT ? OFFSET ?`, ) .all(...params, pageSize, offset) as StalkerResultRecord[]; return { items, summary, page, pageSize, total: totalRow.total, totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)), }; } function parseStalkerProductFilters(filters: URLSearchParams) { const q = filters.get("q")?.trim() || ""; const sellerId = filters.get("sellerId")?.trim().toUpperCase() || ""; const runIdRaw = filters.get("runId")?.trim() || ""; const verdict = filters.get("verdict")?.trim().toUpperCase() || ""; const amazonIsSeller = filters.get("amazonIsSeller")?.trim() || ""; const minPriceRaw = filters.get("minPrice")?.trim() || ""; const maxPriceRaw = filters.get("maxPrice")?.trim() || ""; const minMonthlySoldRaw = filters.get("minMonthlySold")?.trim() || ""; const maxMonthlySoldRaw = filters.get("maxMonthlySold")?.trim() || ""; const minSalesRankRaw = filters.get("minSalesRank")?.trim() || ""; const maxSalesRankRaw = filters.get("maxSalesRank")?.trim() || ""; const minSellerCountRaw = filters.get("minSellerCount")?.trim() || ""; const maxSellerCountRaw = filters.get("maxSellerCount")?.trim() || ""; const minRatingCountRaw = filters.get("minRatingCount")?.trim() || ""; const maxRatingCountRaw = filters.get("maxRatingCount")?.trim() || ""; const minConfidenceRaw = filters.get("minConfidence")?.trim() || ""; const maxConfidenceRaw = filters.get("maxConfidence")?.trim() || ""; const conditions = [ "inv.can_sell = 1", "inv.sellability_status = 'available'", ]; const params: Array = []; if (runIdRaw) { const runId = Number(runIdRaw); if (Number.isInteger(runId) && runId > 0) { conditions.push("r.id = ?"); params.push(runId); } } if (sellerId) { conditions.push("s.seller_id = ?"); params.push(sellerId); } if (verdict === "FBA" || verdict === "FBM" || verdict === "SKIP") { conditions.push("analysis.verdict = ?"); params.push(verdict); } else if (verdict === "UNANALYZED") { conditions.push("analysis.verdict IS NULL"); } if (amazonIsSeller === "yes") { conditions.push("inv.amazon_is_seller = 1"); } else if (amazonIsSeller === "no") { conditions.push("inv.amazon_is_seller = 0"); } else if (amazonIsSeller === "unknown") { conditions.push("inv.amazon_is_seller IS NULL"); } const numericFilters: Array<[string, string, string]> = [ [minPriceRaw, "inv.current_price >= ?", "minPrice"], [maxPriceRaw, "inv.current_price <= ?", "maxPrice"], [minMonthlySoldRaw, "inv.monthly_sold >= ?", "minMonthlySold"], [maxMonthlySoldRaw, "inv.monthly_sold <= ?", "maxMonthlySold"], [minSalesRankRaw, "inv.sales_rank >= ?", "minSalesRank"], [maxSalesRankRaw, "inv.sales_rank <= ?", "maxSalesRank"], [minSellerCountRaw, "inv.seller_count >= ?", "minSellerCount"], [maxSellerCountRaw, "inv.seller_count <= ?", "maxSellerCount"], [minRatingCountRaw, "s.rating_count >= ?", "minRatingCount"], [maxRatingCountRaw, "s.rating_count <= ?", "maxRatingCount"], [minConfidenceRaw, "analysis.confidence >= ?", "minConfidence"], [maxConfidenceRaw, "analysis.confidence <= ?", "maxConfidence"], ]; for (const [raw, condition] of numericFilters) { if (!raw) continue; const value = Number(raw); if (Number.isFinite(value)) { conditions.push(condition); params.push(value); } } if (q) { const wildcard = `%${q}%`; conditions.push( `( inv.asin LIKE ? OR inv.product_title LIKE ? OR inv.brand LIKE ? OR inv.category_tree LIKE ? OR s.seller_id LIKE ? OR s.seller_name LIKE ? )`, ); params.push(wildcard, wildcard, wildcard, wildcard, wildcard, wildcard); } return { where: `WHERE ${conditions.join(" AND ")}`, params, }; } function parseStalkerProductSort(sortParam: string | null): string { const allowedSort = new Set([ "runId", "started_at", "seller_id", "seller_name", "rating", "rating_count", "asin", "product_title", "brand", "current_price", "avg_price_90d", "sales_rank", "monthly_sold", "seller_count", "amazon_is_seller", "verdict", "confidence", "last_seen_at", ]); return parseSort( sortParam, allowedSort, "monthly_sold DESC, last_seen_at DESC, asin ASC", ); } function getStalkerProducts(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 { where, params } = parseStalkerProductFilters(filters); const orderBy = parseStalkerProductSort(filters.get("sort")); const baseSelect = ` SELECT r.id AS runId, r.started_at, s.seller_id, s.seller_name, s.rating, s.rating_count, inv.asin, inv.can_sell, inv.sellability_status, inv.sellability_reason, inv.product_title, inv.brand, inv.category_tree, inv.current_price, inv.avg_price_90d, inv.sales_rank, inv.monthly_sold, inv.seller_count, inv.amazon_is_seller, analysis.verdict, analysis.confidence, analysis.reasoning, inv.last_seen_at FROM stalker_seller_inventory inv JOIN stalker_runs r ON r.id = inv.run_id JOIN stalker_sellers s ON s.seller_id = inv.seller_id LEFT JOIN product_analysis_results analysis ON analysis.asin = inv.asin ${where} `; const totalRow = db .query(`SELECT COUNT(*) AS total FROM (${baseSelect}) stalker_products`) .get(...params) as { total: number }; const summary = db .query( `SELECT COUNT(DISTINCT runId) AS runs, COUNT(DISTINCT seller_id) AS sellers, COUNT(DISTINCT asin) AS products FROM (${baseSelect}) stalker_products`, ) .get(...params) as { runs: number; sellers: number; products: number; }; const items = db .query( `SELECT * FROM (${baseSelect}) stalker_products ORDER BY ${orderBy} LIMIT ? OFFSET ?`, ) .all(...params, pageSize, offset) as StalkerProductRecord[]; return { items, summary, page, pageSize, total: totalRow.total, totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)), }; } function getStalkerProductsForExport( filters: URLSearchParams, ): StalkerProductRecord[] { const { where, params } = parseStalkerProductFilters(filters); const orderBy = parseStalkerProductSort(filters.get("sort")); return db .query( `SELECT * FROM ( SELECT r.id AS runId, r.started_at, s.seller_id, s.seller_name, s.rating, s.rating_count, inv.asin, inv.can_sell, inv.sellability_status, inv.sellability_reason, inv.product_title, inv.brand, inv.category_tree, inv.current_price, inv.avg_price_90d, inv.sales_rank, inv.monthly_sold, inv.seller_count, inv.amazon_is_seller, analysis.verdict, analysis.confidence, analysis.reasoning, inv.last_seen_at FROM stalker_seller_inventory inv JOIN stalker_runs r ON r.id = inv.run_id JOIN stalker_sellers s ON s.seller_id = inv.seller_id LEFT JOIN product_analysis_results analysis ON analysis.asin = inv.asin ${where} ) stalker_products ORDER BY ${orderBy}`, ) .all(...params) as StalkerProductRecord[]; } function parseCategoryTreeForExport(value: string | null): string { if (!value) return ""; try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string").join(" > ") : ""; } catch { return ""; } } function exportStalkerProductsXlsx(filters: URLSearchParams): Response { const rows = getStalkerProductsForExport(filters); const data = rows.map((row) => ({ ASIN: row.asin, "Amazon URL": `https://amazon.com/dp/${row.asin}`, Product: row.product_title ?? "", Brand: row.brand ?? "", Category: parseCategoryTreeForExport(row.category_tree), "Monthly Sold": row.monthly_sold ?? null, Sellers: row.seller_count ?? null, "Amazon Seller": row.amazon_is_seller == null ? "" : row.amazon_is_seller === 1 ? "Yes" : "No", "Sales Rank": row.sales_rank ?? null, "Current Price": row.current_price ?? null, "Avg 90d": row.avg_price_90d ?? null, Verdict: row.verdict ?? "", Confidence: row.confidence ?? null, Reasoning: row.reasoning ?? "", "Seller ID": row.seller_id, Seller: row.seller_name ?? "", "Seller Rating": row.rating ?? null, "Seller Rating Count": row.rating_count ?? null, "Sellability Status": row.sellability_status, "Sellability Reason": row.sellability_reason ?? "", "Run ID": row.runId, "Last Seen": row.last_seen_at, })); const workbook = XLSX.utils.book_new(); const worksheet = XLSX.utils.json_to_sheet(data); worksheet["!cols"] = [ { wch: 12 }, { wch: 32 }, { wch: 48 }, { wch: 20 }, { wch: 34 }, { wch: 14 }, { wch: 10 }, { wch: 14 }, { wch: 12 }, { wch: 12 }, { wch: 12 }, { wch: 10 }, { wch: 12 }, { wch: 60 }, { wch: 18 }, { wch: 24 }, { wch: 12 }, { wch: 20 }, { wch: 18 }, { wch: 40 }, { wch: 10 }, { wch: 24 }, ]; XLSX.utils.book_append_sheet(workbook, worksheet, "Sellable Products"); const buffer = XLSX.write(workbook, { type: "array", bookType: "xlsx", }) as ArrayBuffer; return xlsx(buffer, "stalker-sellable-products.xlsx"); } function purgeStalkerData() { const counts = { inventory: ( db .query("SELECT COUNT(*) AS count FROM stalker_seller_inventory") .get() as { count: number } ).count, asinSellers: ( db.query("SELECT COUNT(*) AS count FROM stalker_asin_sellers").get() as { count: number; } ).count, sellers: ( db.query("SELECT COUNT(*) AS count FROM stalker_sellers").get() as { count: number; } ).count, scans: ( db.query("SELECT COUNT(*) AS count FROM stalker_asin_scans").get() as { count: number; } ).count, runs: ( db.query("SELECT COUNT(*) AS count FROM stalker_runs").get() as { count: number; } ).count, }; db.transaction(() => { db.run("DELETE FROM stalker_seller_inventory"); db.run("DELETE FROM stalker_asin_sellers"); db.run("DELETE FROM stalker_sellers"); db.run("DELETE FROM stalker_asin_scans"); db.run("DELETE FROM stalker_runs"); })(); return { ok: true, deleted: counts }; } 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], { useClaude: USE_CLAUDE, }); 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, "/stalker": index, "/stalker/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/stalker/results": (req) => { const url = new URL(req.url); return json(getStalkerResults(url.searchParams)); }, "/api/stalker/products": (req) => { const url = new URL(req.url); return json(getStalkerProducts(url.searchParams)); }, "/api/stalker/products/export.xlsx": (req) => { const url = new URL(req.url); return exportStalkerProductsXlsx(url.searchParams); }, "/api/stalker/purge": (req) => { if (req.method !== "DELETE" && req.method !== "POST") { return json({ error: "Method not allowed" }, 405); } return json(purgeStalkerData()); }, "/api/upc/map": async (req) => { let upcs: string[]; try { upcs = await parseUpcsFromRequest(req); } catch (err) { const message = err instanceof Error ? err.message : String(err); const status = message === "Method not allowed" ? 405 : 400; return json({ error: message }, status); } const validationError = validateUpcRequest(upcs); if (validationError) { return json({ error: validationError }, 400); } try { const mapping = await mapUpcsToAsins(upcs); const items = Array.from(mapping.entries()).map(([upc, asin]) => ({ upc, asin, })); return json({ requested: upcs.length, matched: items.length, items, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); return json({ error: message }, 500); } }, "/api/upc/lookup": async (req) => { let upcs: string[]; try { upcs = await parseUpcsFromRequest(req); } catch (err) { const message = err instanceof Error ? err.message : String(err); const status = message === "Method not allowed" ? 405 : 400; return json({ error: message }, status); } const validationError = validateUpcRequest(upcs); if (validationError) { return json({ error: validationError }, 400); } try { const detailMap = await lookupKeepaUpcs(upcs); const items = Array.from(detailMap.values()); return json({ requested: upcs.length, statusCounts: summarizeLookupStatuses(items), items, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); return json({ error: message }, 500); } }, "/api/process/upc-file": async (req) => { let parsed: UpcFileProcessRequest; try { parsed = await parseUpcFileProcessRequest(req); } catch (err) { const message = err instanceof Error ? err.message : String(err); const status = message === "Method not allowed" ? 405 : message === "Invalid JSON body" ? 400 : 400; return json({ error: message }, status); } try { const summary = await runUpcFileAnalysis({ inputFile: parsed.inputFile, outputFile: parsed.outputFile, inputBatchSize: parsed.inputBatchSize, upcLookupBatchSize: parsed.upcLookupBatchSize, maxRows: parsed.maxRows, dbPath: DB_PATH, manageResources: false, }); return json(summary); } catch (err) { const message = err instanceof Error ? err.message : String(err); return json({ error: message }, 500); } }, "/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}`);