import { Router, Request, Response, NextFunction } from "express"; import { pool } from "../db/client"; import { listVendors } from "../db/queries"; export const vendorRouter = Router(); // GET /api/vendors — List all vendors vendorRouter.get("/", async (req: Request, res: Response) => { try { const vendors = await listVendors(req.query.type ? String(req.query.type) : undefined); res.json({ success: true, data: vendors, total: vendors.length }); } catch (err) { console.error("List vendors error:", err); res.status(500).json({ success: false, error: "Internal server error" }); } }); // POST /api/vendors — Create a new vendor + queue auto-crawl vendorRouter.post("/", async (req: Request, res: Response) => { try { const { name, type, website, shop_url, headquarters, country, founded_year, revenue_usd, employee_count, market_position, specialties, is_competitor, } = req.body as Record; if (!name || typeof name !== "string" || !name.trim()) { return res.status(400).json({ success: false, error: "name is required" }); } const validTypes = ["manufacturer", "distributor", "oem", "reseller", "compatible"]; const resolvedType = validTypes.includes(type) ? type : "compatible"; // Generate slug from name const slug = name .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); // Build specialties array const specialtiesArr: string[] = Array.isArray(specialties) ? specialties : typeof specialties === "string" && specialties.trim() ? specialties.split(",").map((s: string) => s.trim()).filter(Boolean) : []; // Insert vendor const insertResult = await pool.query( `INSERT INTO vendors (name, slug, type, website, shop_url, headquarters, country, founded_year, revenue_usd, employee_count, market_position, specialties, is_competitor, scrape_config) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`, [ name.trim(), slug, resolvedType, website || null, shop_url || website || null, headquarters || null, country || null, founded_year ? Number(founded_year) : null, revenue_usd ? Number(revenue_usd) : null, employee_count ? Number(employee_count) : null, market_position || null, specialtiesArr, is_competitor === true || is_competitor === "true", website ? JSON.stringify({ url: website, enabled: true, auto_queued: true }) : "{}", ] ); const vendor = insertResult.rows[0]; // Queue crawl job: insert into crawler_llm_log as a pending task signal // The scraper fleet polls for new vendors with scrape_config.enabled=true if (website) { await pool .query( `INSERT INTO crawler_llm_log (vendor_id, url, action, model, tokens_used, created_at) VALUES ($1, $2, 'vendor_created_auto_crawl', 'system', 0, NOW())`, [vendor.id, website] ) .catch(() => null); // Non-fatal — vendor is created regardless } return res.status(201).json({ success: true, vendor, crawl_queued: !!website, message: website ? `Vendor "${name}" created. Auto-crawl queued for ${website}.` : `Vendor "${name}" created. Add a website URL to enable auto-crawl.`, }); } catch (err: any) { if (err.code === "23505") { // Unique constraint violation (name or slug) return res.status(409).json({ success: false, error: "A vendor with this name already exists." }); } console.error("Create vendor error:", err); return res.status(500).json({ success: false, error: "Internal server error" }); } }); // GET /api/vendors/:id — Get single vendor with full stats // GET /api/vendors/reliability — per-vendor data-reliability scores (freshness/frequency/coverage) vendorRouter.get("/reliability", async (_req: Request, res: Response) => { try { const result = await pool.query(` SELECT v.id::text AS vendor_id, v.name AS vendor_name, COUNT(DISTINCT t.id)::int AS sku_count, COUNT(po.transceiver_id)::int AS obs_count, MAX(po.time) AS last_obs FROM vendors v JOIN transceivers t ON t.vendor_id = v.id LEFT JOIN price_observations po ON po.transceiver_id = t.id GROUP BY v.id, v.name HAVING COUNT(DISTINCT t.id) > 0`); const rows = result.rows as Array<{ vendor_id: string; vendor_name: string; sku_count: number; obs_count: number; last_obs: string | null }>; const maxSku = Math.max(1, ...rows.map((r) => r.sku_count)); const maxObs = Math.max(1, ...rows.map((r) => r.obs_count)); const now = Date.now(); const vendors = rows.map((r) => { const daysStale = r.last_obs ? Math.max(0, (now - new Date(r.last_obs).getTime()) / 86400000) : 999; const freshness_score = Math.max(0, Math.min(100, Math.round(100 - daysStale * 3))); const frequency_score = Math.round(100 * Math.min(1, Math.log10(r.obs_count + 1) / Math.log10(maxObs + 1))); const coverage_score = Math.round(100 * Math.min(1, r.sku_count / maxSku)); const reliability_score = Math.round(0.4 * freshness_score + 0.3 * frequency_score + 0.3 * coverage_score); return { vendor_id: r.vendor_id, vendor_name: r.vendor_name, sku_count: r.sku_count, obs_count: r.obs_count, last_obs: r.last_obs, freshness_score, frequency_score, coverage_score, reliability_score }; }); vendors.sort((a, b) => b.reliability_score - a.reliability_score); res.json({ success: true, count: vendors.length, vendors }); } catch (err) { console.error("vendor reliability error:", err); res.status(500).json({ success: false, error: "Failed to compute vendor reliability" }); } }); vendorRouter.get("/:id", async (req: Request, res: Response, next: NextFunction) => { if (req.params.id === "market-share" || req.params.id === "intelligence") return next(); try { const { id } = req.params; const result = await pool.query( `SELECT v.*, (SELECT COUNT(*) FROM transceivers t WHERE t.vendor_id = v.id)::int AS transceiver_count, (SELECT COUNT(*) FROM switches s WHERE s.vendor_id = v.id)::int AS switch_count, (SELECT COUNT(*) FROM price_observations po JOIN transceivers t ON po.transceiver_id = t.id WHERE t.vendor_id = v.id)::int AS price_obs_count FROM vendors v WHERE v.id::text = $1 OR v.slug = $1`, [id] ); const vendor = result.rows[0]; if (!vendor) return res.status(404).json({ success: false, error: "Vendor not found" }); return res.json({ success: true, vendor }); } catch (err) { console.error("Get vendor error:", err); return res.status(500).json({ success: false, error: "Internal server error" }); } }); // ───────────────────────────────────────────────────────────────────────────── // GET /api/vendors/market-share — Weekly SKU-coverage share per vendor over time // Shows which vendors are gaining/losing market presence // Query params: speed_gbps, form_factor, days (default 90) // ───────────────────────────────────────────────────────────────────────────── vendorRouter.get("/market-share", async (req: Request, res: Response) => { const days = Math.min(parseInt(req.query.days as string) || 90, 365); const spd = req.query.speed_gbps as string | undefined; const ff = req.query.form_factor as string | undefined; try { const [weekly, current, momentum] = await Promise.all([ // Weekly SKU count per vendor — shows growth/shrink trends pool.query(` SELECT DATE_TRUNC('week', po.time)::date AS week, v.id::text AS vendor_id, v.name AS vendor_name, COUNT(DISTINCT po.transceiver_id)::int AS sku_count FROM price_observations po JOIN vendors v ON v.id = po.source_vendor_id JOIN transceivers t ON t.id = po.transceiver_id WHERE po.time >= NOW() - INTERVAL '${days} days' AND po.price > 0 AND COALESCE(po.is_anomalous, false) = false ${spd ? `AND t.speed_gbps = ${parseFloat(spd)}` : ""} ${ff ? `AND t.form_factor = '${ff.replace(/'/g,"''")}'` : ""} GROUP BY DATE_TRUNC('week', po.time), v.id, v.name ORDER BY week ASC, sku_count DESC `), // Current snapshot: SKU share % per vendor (last 30d) pool.query(` WITH totals AS ( SELECT COUNT(DISTINCT transceiver_id)::float AS total FROM price_observations WHERE time >= NOW() - INTERVAL '30 days' AND price > 0 AND COALESCE(is_anomalous, false) = false ) SELECT v.id::text AS vendor_id, v.name AS vendor_name, v.type, COUNT(DISTINCT po.transceiver_id)::int AS sku_count, ROUND((COUNT(DISTINCT po.transceiver_id)::numeric / NULLIF(t.total,0)::numeric) * 100, 1) AS market_share_pct, COUNT(*)::int AS total_obs, MAX(po.time) AS last_seen FROM price_observations po JOIN vendors v ON v.id = po.source_vendor_id JOIN transceivers tx ON tx.id = po.transceiver_id CROSS JOIN totals t WHERE po.time >= NOW() - INTERVAL '30 days' AND po.price > 0 AND COALESCE(po.is_anomalous, false) = false ${spd ? `AND tx.speed_gbps = ${parseFloat(spd)}` : ""} ${ff ? `AND tx.form_factor = '${ff.replace(/'/g,"''")}'` : ""} GROUP BY v.id, v.name, v.type, t.total ORDER BY sku_count DESC LIMIT 30 `), // Momentum: compare last 30d vs prior 30d SKU count per vendor pool.query(` WITH cur AS ( SELECT source_vendor_id, COUNT(DISTINCT transceiver_id)::int AS sku_count FROM price_observations po JOIN transceivers t ON t.id = po.transceiver_id WHERE po.time >= NOW() - INTERVAL '30 days' AND po.price > 0 AND COALESCE(po.is_anomalous, false) = false ${spd ? `AND t.speed_gbps = ${parseFloat(spd)}` : ""} ${ff ? `AND t.form_factor = '${ff.replace(/'/g,"''")}'` : ""} GROUP BY source_vendor_id ), prior AS ( SELECT source_vendor_id, COUNT(DISTINCT transceiver_id)::int AS sku_count FROM price_observations po JOIN transceivers t ON t.id = po.transceiver_id WHERE po.time >= NOW() - INTERVAL '60 days' AND po.time < NOW() - INTERVAL '30 days' AND po.price > 0 AND COALESCE(po.is_anomalous, false) = false ${spd ? `AND t.speed_gbps = ${parseFloat(spd)}` : ""} ${ff ? `AND t.form_factor = '${ff.replace(/'/g,"''")}'` : ""} GROUP BY source_vendor_id ) SELECT v.name AS vendor_name, v.id::text AS vendor_id, c.sku_count AS current_skus, COALESCE(p.sku_count, 0) AS prior_skus, (c.sku_count - COALESCE(p.sku_count, 0)) AS delta_skus, CASE WHEN COALESCE(p.sku_count, 0) = 0 THEN NULL ELSE ROUND(((c.sku_count - p.sku_count)::numeric / p.sku_count::numeric) * 100, 1) END AS delta_pct FROM cur c JOIN vendors v ON v.id = c.source_vendor_id LEFT JOIN prior p ON p.source_vendor_id = c.source_vendor_id ORDER BY delta_skus DESC LIMIT 20 `), ]); // Compute share % per week for chart (normalize across vendors per week) const weekTotals = new Map(); for (const row of weekly.rows) { const k = row.week; weekTotals.set(k, (weekTotals.get(k) || 0) + row.sku_count); } const weeklyWithShare = weekly.rows.map(r => ({ ...r, share_pct: weekTotals.get(r.week) ? Math.round((r.sku_count / weekTotals.get(r.week)!) * 1000) / 10 : 0, })); res.json({ success: true, filters: { days, speed_gbps: spd || null, form_factor: ff || null }, weekly_trend: weeklyWithShare, current_share: current.rows, momentum: momentum.rows, }); } catch (err) { res.status(500).json({ success: false, error: String(err) }); } }); // GET /api/vendors/intelligence — per-vendor price + SKU market stats (last 30d) vendorRouter.get("/intelligence", async (_req: Request, res: Response) => { try { const result = await pool.query(` SELECT v.id, v.name, v.type, v.website, COUNT(DISTINCT po.transceiver_id)::int AS sku_count, COUNT(*)::int AS price_obs, ROUND(AVG(po.price)::numeric, 2) AS avg_price, ROUND(MIN(po.price)::numeric, 2) AS min_price, ROUND(MAX(po.price)::numeric, 2) AS max_price, MAX(po.time) AS last_seen, (SELECT currency FROM price_observations WHERE source_vendor_id = v.id ORDER BY time DESC LIMIT 1) AS currency FROM vendors v LEFT JOIN price_observations po ON po.source_vendor_id = v.id AND po.time > NOW() - INTERVAL '30 days' AND po.price > 0 AND COALESCE(po.is_anomalous, false) = false GROUP BY v.id, v.name, v.type, v.website HAVING COUNT(DISTINCT po.transceiver_id) > 0 ORDER BY COUNT(DISTINCT po.transceiver_id) DESC LIMIT 60 `); res.json({ success: true, data: result.rows }); } catch (err) { res.status(500).json({ success: false, error: String(err) }); } });