/** * Manual Review API — Transceiver Equivalence Review Queue * * GET /api/review/equivalences — list (filter by status) * GET /api/review/equivalences/stats — pending/approved/rejected counts * POST /api/review/equivalences/:id/approve — approve + set competitor_verified * POST /api/review/equivalences/:id/reject — reject with optional reason * PATCH /api/review/equivalences/:id — edit match_notes * POST /api/review/run-matcher — trigger equivalence job immediately */ import { Router, Request, Response } from "express"; import { pool } from "../db/client"; /** Promote to fully_verified if all 4 flags are set — shared logic */ async function checkAndSetFullyVerified(transceiverId: string): Promise { const result = await pool.query( `UPDATE transceivers SET fully_verified = true, fully_verified_at = COALESCE(fully_verified_at, NOW()) WHERE id = $1 AND price_verified = true AND image_verified = true AND details_verified = true AND competitor_verified = true AND (fully_verified IS NULL OR fully_verified = false) RETURNING id`, [transceiverId] ); return (result.rowCount ?? 0) > 0; } export const reviewRouter = Router(); // ── GET /api/review/equivalences ────────────────────────────────────────────── reviewRouter.get("/equivalences", async (req: Request, res: Response) => { const status = (req.query.status as string) || "pending"; const page = Math.max(1, parseInt(req.query.page as string, 10) || 1); const limit = Math.min(100, parseInt(req.query.limit as string, 10) || 50); const offset = (page - 1) * limit; const validStatuses = ["pending", "approved", "rejected", "auto_approved", "all", "needs_research"]; if (!validStatuses.includes(status)) { res.status(400).json({ success: false, error: "Invalid status filter" }); return; } let where: string; let params: unknown[]; let limitIdx: number; let offsetIdx: number; if (status === "all") { where = ""; params = [limit, offset]; limitIdx = 1; offsetIdx = 2; } else if (status === "needs_research") { where = `WHERE eq.status IN ('approved','auto_approved') AND eq.re_research_due_at IS NOT NULL AND eq.re_research_due_at <= NOW()`; params = [limit, offset]; limitIdx = 1; offsetIdx = 2; } else { where = `WHERE eq.status = $1`; params = [status, limit, offset]; limitIdx = 2; offsetIdx = 3; } const rows = await pool.query(` SELECT eq.id, eq.confidence, eq.match_basis, eq.match_notes, eq.status, eq.reviewed_by, eq.reviewed_at, eq.reject_reason, eq.re_research_due_at, eq.re_researched_at, eq.created_at, eq.updated_at, -- Flexoptix transceiver fx.id AS fx_id, fx.part_number AS fx_part_number, fx.standard_name AS fx_standard_name, fx.form_factor AS fx_form_factor, fx.speed AS fx_speed, fx.speed_gbps AS fx_speed_gbps, fx.fiber_type AS fx_fiber_type, fx.reach_meters AS fx_reach_meters, fx.reach_label AS fx_reach_label, fx.wavelengths AS fx_wavelengths, fx.connector AS fx_connector, fx.product_page_url AS fx_url, fxv.name AS fx_vendor, -- Competitor transceiver cp.id AS cp_id, cp.part_number AS cp_part_number, cp.standard_name AS cp_standard_name, cp.form_factor AS cp_form_factor, cp.speed AS cp_speed, cp.speed_gbps AS cp_speed_gbps, cp.fiber_type AS cp_fiber_type, cp.reach_meters AS cp_reach_meters, cp.reach_label AS cp_reach_label, cp.wavelengths AS cp_wavelengths, cp.connector AS cp_connector, cp.product_page_url AS cp_url, cpv.name AS cp_vendor, -- Latest competitor price (SELECT po.price FROM price_observations po WHERE po.transceiver_id = cp.id AND po.time > NOW() - INTERVAL '30 days' ORDER BY po.time DESC LIMIT 1) AS cp_latest_price, (SELECT po.currency FROM price_observations po WHERE po.transceiver_id = cp.id AND po.time > NOW() - INTERVAL '30 days' ORDER BY po.time DESC LIMIT 1) AS cp_latest_currency FROM transceiver_equivalences eq JOIN transceivers fx ON fx.id = eq.flexoptix_id JOIN vendors fxv ON fxv.id = fx.vendor_id JOIN transceivers cp ON cp.id = eq.competitor_id JOIN vendors cpv ON cpv.id = cp.vendor_id ${where} ORDER BY eq.confidence DESC, eq.created_at DESC LIMIT $${limitIdx} OFFSET $${offsetIdx} `, params); const countResult = await pool.query( `SELECT COUNT(*) FROM transceiver_equivalences eq ${where}`, (status === "all" || status === "needs_research") ? [] : [status] ); res.json({ success: true, data: rows.rows, total: parseInt(countResult.rows[0].count, 10), page, limit, }); }); // ── GET /api/review/equivalences/stats ──────────────────────────────────────── reviewRouter.get("/equivalences/stats", async (_req: Request, res: Response) => { const result = await pool.query(` SELECT SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending, SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) AS approved, SUM(CASE WHEN status = 'auto_approved' THEN 1 ELSE 0 END) AS auto_approved, SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) AS rejected, SUM(CASE WHEN status IN ('approved','auto_approved') AND re_research_due_at IS NOT NULL AND re_research_due_at <= NOW() THEN 1 ELSE 0 END) AS needs_research, COUNT(*) AS total FROM transceiver_equivalences `); const row = result.rows[0]; res.json({ success: true, stats: { pending: parseInt(row.pending, 10) || 0, approved: parseInt(row.approved, 10) || 0, auto_approved: parseInt(row.auto_approved, 10) || 0, rejected: parseInt(row.rejected, 10) || 0, needs_research: parseInt(row.needs_research, 10) || 0, total: parseInt(row.total, 10) || 0, }, }); }); // ── POST /api/review/equivalences/:id/approve ───────────────────────────────── reviewRouter.post("/equivalences/:id/approve", async (req: Request, res: Response) => { const { id } = req.params; const reviewer = (req.body as { reviewer?: string }).reviewer || "manual"; // Fetch the equivalence to get flexoptix_id const eq = await pool.query( `SELECT * FROM transceiver_equivalences WHERE id = $1`, [id] ); if (!eq.rows[0]) { res.status(404).json({ success: false, error: "Not found" }); return; } const { flexoptix_id } = eq.rows[0] as { flexoptix_id: string }; // Mark approved await pool.query(` UPDATE transceiver_equivalences SET status = 'approved', reviewed_by = $2, reviewed_at = NOW() WHERE id = $1 `, [id, reviewer]); // Set competitor_verified on the Flexoptix transceiver await pool.query(` UPDATE transceivers SET competitor_verified = true, competitor_verified_at = NOW() WHERE id = $1 `, [flexoptix_id]); // Promote to fully_verified if all 4 flags are now set const fullyVerifiedEarned = await checkAndSetFullyVerified(flexoptix_id); res.json({ success: true, fully_verified_earned: fullyVerifiedEarned, }); }); // ── POST /api/review/equivalences/:id/reject ────────────────────────────────── reviewRouter.post("/equivalences/:id/reject", async (req: Request, res: Response) => { const { id } = req.params; const { reason, reviewer } = req.body as { reason?: string; reviewer?: string }; const result = await pool.query(` UPDATE transceiver_equivalences SET status = 'rejected', reject_reason = $2, reviewed_by = $3, reviewed_at = NOW() WHERE id = $1 RETURNING id `, [id, reason || null, reviewer || "manual"]); if (!result.rowCount) { res.status(404).json({ success: false, error: "Not found" }); return; } res.json({ success: true }); }); // ── PATCH /api/review/equivalences/:id ──────────────────────────────────────── reviewRouter.patch("/equivalences/:id", async (req: Request, res: Response) => { const { id } = req.params; const { match_notes } = req.body as { match_notes?: string }; if (match_notes === undefined) { res.status(400).json({ success: false, error: "match_notes required" }); return; } const result = await pool.query(` UPDATE transceiver_equivalences SET match_notes = $2, updated_at = NOW() WHERE id = $1 RETURNING id `, [id, match_notes]); if (!result.rowCount) { res.status(404).json({ success: false, error: "Not found" }); return; } res.json({ success: true }); }); // ── POST /api/review/equivalences/approve-all ───────────────────────────────── // Approve ALL pending equivalences regardless of confidence. // Low-confidence ones (< 0.73) get re_research_due_at = NOW() so the nightly // re-research job will re-verify them one by one. reviewRouter.post("/equivalences/approve-all", async (req: Request, res: Response) => { const reviewer = (req.body as { reviewer?: string }).reviewer || "approve-all"; const RE_RESEARCH_THRESHOLD = 0.73; const candidates = await pool.query(` SELECT id, flexoptix_id, confidence FROM transceiver_equivalences WHERE status = 'pending' `); let approved = 0; let fullyVerified = 0; let scheduledReSearch = 0; for (const row of candidates.rows) { const needsReSearch = parseFloat(row.confidence) < RE_RESEARCH_THRESHOLD; await pool.query(` UPDATE transceiver_equivalences SET status = 'approved', reviewed_by = $2, reviewed_at = NOW(), re_research_due_at = $3, re_researched_at = NULL WHERE id = $1 `, [row.id, reviewer, needsReSearch ? new Date() : null]); await pool.query(` UPDATE transceivers SET competitor_verified = true, competitor_verified_at = NOW() WHERE id = $1 AND competitor_verified = false `, [row.flexoptix_id]); const earned = await checkAndSetFullyVerified(row.flexoptix_id); if (earned) fullyVerified++; if (needsReSearch) scheduledReSearch++; approved++; } res.json({ success: true, approved, fully_verified_earned: fullyVerified, scheduled_re_research: scheduledReSearch }); }); // ── POST /api/review/equivalences/bulk-approve ──────────────────────────────── // Bulk-approve all pending equivalences with confidence >= threshold (default 0.73) reviewRouter.post("/equivalences/bulk-approve", async (req: Request, res: Response) => { const threshold = Math.max(0, Math.min(1, parseFloat((req.body as { threshold?: string }).threshold as string) || 0.73)); const reviewer = (req.body as { reviewer?: string }).reviewer || "bulk-dashboard"; // Fetch all pending records above threshold const candidates = await pool.query(` SELECT id, flexoptix_id FROM transceiver_equivalences WHERE status = 'pending' AND confidence >= $1 `, [threshold]); let approved = 0; let fullyVerified = 0; for (const row of candidates.rows) { await pool.query(` UPDATE transceiver_equivalences SET status = 'approved', reviewed_by = $2, reviewed_at = NOW() WHERE id = $1 `, [row.id, reviewer]); await pool.query(` UPDATE transceivers SET competitor_verified = true, competitor_verified_at = NOW() WHERE id = $1 AND competitor_verified = false `, [row.flexoptix_id]); const earned = await checkAndSetFullyVerified(row.flexoptix_id); if (earned) fullyVerified++; approved++; } res.json({ success: true, approved, fully_verified_earned: fullyVerified, threshold }); }); // ── POST /api/review/run-matcher ────────────────────────────────────────────── // Trigger the equivalence matcher immediately (admin action) reviewRouter.post("/run-matcher", async (_req: Request, res: Response) => { // Queue the job via pg-boss — import from scraper's db util won't work here, // so we fire directly via DB insert into pg-boss queue await pool.query(` INSERT INTO pgboss.job (name, data, priority) VALUES ('maintenance:find-equivalences', '{}', 0) ON CONFLICT DO NOTHING `); res.json({ success: true, message: "Equivalence matcher queued" }); });