2026-05-09 23:06:21 +02:00

496 lines
18 KiB
TypeScript

/**
* 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
* POST /api/review/run-research — trigger equivalence research job immediately
*/
import { Router, Request, Response } from "express";
import { createHash } from "crypto";
import { pool } from "../db/client";
/** Promote to fully_verified if all 4 flags are set — shared logic */
async function checkAndSetFullyVerified(transceiverId: string): Promise<boolean> {
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;
}
async function queueMaintenanceJob(name: string): Promise<void> {
await pool.query(
`
INSERT INTO pgboss.job (name, data, priority)
VALUES ($1, '{}', 0)
ON CONFLICT DO NOTHING
`,
[name]
);
}
function hashEvidence(value: Record<string, unknown>): string {
return createHash("sha256").update(JSON.stringify(value, Object.keys(value).sort())).digest("hex").slice(0, 16);
}
async function recordVerificationEvidence(params: {
transceiverId: string;
verificationType: "competitor_match" | "competitor_no_match";
evidenceValue: Record<string, unknown>;
robotName: string;
confidence?: number;
}): Promise<void> {
const evidenceHash = hashEvidence(params.evidenceValue);
await pool.query(`
INSERT INTO transceiver_verification_evidence (
transceiver_id, verification_type, evidence_value, evidence_hash, robot_name, confidence
)
VALUES ($1, $2, $3::jsonb, $4, $5, $6)
ON CONFLICT DO NOTHING
`, [
params.transceiverId,
params.verificationType,
JSON.stringify(params.evidenceValue),
evidenceHash,
params.robotName,
params.confidence ?? null,
]);
}
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 ('pending','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 ('pending','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 productStatus = await pool.query(`
SELECT
SUM(CASE WHEN competitor_status = 'matched' THEN 1 ELSE 0 END) AS matched,
SUM(CASE WHEN competitor_status = 'no_valid_match' THEN 1 ELSE 0 END) AS no_valid_match,
SUM(CASE WHEN competitor_status = 'ambiguous' THEN 1 ELSE 0 END) AS ambiguous,
SUM(CASE WHEN competitor_status = 'needs_research' THEN 1 ELSE 0 END) AS needs_research_products,
SUM(CASE WHEN competitor_status = 'unknown' THEN 1 ELSE 0 END) AS unknown
FROM transceivers
`);
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,
product_status: {
matched: parseInt(productStatus.rows[0].matched, 10) || 0,
no_valid_match: parseInt(productStatus.rows[0].no_valid_match, 10) || 0,
ambiguous: parseInt(productStatus.rows[0].ambiguous, 10) || 0,
needs_research_products: parseInt(productStatus.rows[0].needs_research_products, 10) || 0,
unknown: parseInt(productStatus.rows[0].unknown, 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(),
competitor_status = 'matched',
competitor_status_updated_at = NOW()
WHERE id = $1
`, [flexoptix_id]);
await recordVerificationEvidence({
transceiverId: flexoptix_id,
verificationType: "competitor_match",
evidenceValue: { equivalence_id: id, reviewer },
robotName: "review:approve",
confidence: Number(eq.rows[0].confidence ?? 1),
});
// 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 });
});
// ── POST /api/review/transceivers/:id/no-valid-match ─────────────────────────
// Mark a product as competitor-resolved because research found no valid 1:1 match.
// This is not a fake competitor match; the status and evidence explain the truth.
reviewRouter.post("/transceivers/:id/no-valid-match", async (req: Request, res: Response) => {
const id = String(req.params.id);
const { reason, reviewer } = req.body as { reason?: string; reviewer?: string };
if (!reason || reason.trim().length < 10) {
res.status(400).json({ success: false, error: "reason with at least 10 characters required" });
return;
}
const result = await pool.query(`
UPDATE transceivers
SET competitor_verified = true,
competitor_verified_at = NOW(),
competitor_status = 'no_valid_match',
competitor_status_updated_at = NOW(),
no_match_verified_at = NOW(),
no_match_reason = $2,
updated_at = NOW()
WHERE id = $1
RETURNING id
`, [id, reason.trim()]);
if (!result.rowCount) {
res.status(404).json({ success: false, error: "Not found" });
return;
}
await recordVerificationEvidence({
transceiverId: id,
verificationType: "competitor_no_match",
evidenceValue: {
reason: reason.trim(),
reviewer: reviewer || "manual",
},
robotName: "review:no-valid-match",
confidence: 1,
});
const fullyVerifiedEarned = await checkAndSetFullyVerified(id);
res.json({ success: true, fully_verified_earned: fullyVerifiedEarned });
});
// ── 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 only high-confidence pending equivalences. Weak candidates are queued
// for automated research instead of being marked as approved.
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;
if (needsReSearch) {
await pool.query(`
UPDATE transceiver_equivalences
SET status = 'pending',
reviewed_by = $2,
reviewed_at = NULL,
re_research_due_at = NOW(),
re_researched_at = NULL,
match_notes = CONCAT(COALESCE(match_notes, ''), E'\n[Automated research queued ' || NOW()::date || ': confidence below approval threshold]')
WHERE id = $1
`, [row.id, reviewer]);
scheduledReSearch++;
continue;
}
await pool.query(`
UPDATE transceiver_equivalences
SET status = 'approved',
reviewed_by = $2,
reviewed_at = NOW(),
re_research_due_at = NULL,
re_researched_at = NULL
WHERE id = $1
`, [row.id, reviewer]);
await pool.query(`
UPDATE transceivers
SET competitor_verified = true,
competitor_verified_at = NOW(),
competitor_status = 'matched',
competitor_status_updated_at = NOW()
WHERE id = $1 AND competitor_verified = false
`, [row.flexoptix_id]);
const earned = await checkAndSetFullyVerified(row.flexoptix_id);
if (earned) fullyVerified++;
approved++;
}
if (scheduledReSearch > 0) {
await queueMaintenanceJob("maintenance:re-research-equivalences");
}
res.json({
success: true,
approved,
fully_verified_earned: fullyVerified,
scheduled_re_research: scheduledReSearch,
left_pending: 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(),
re_research_due_at = NULL,
re_researched_at = NULL
WHERE id = $1
`, [row.id, reviewer]);
await pool.query(`
UPDATE transceivers
SET competitor_verified = true,
competitor_verified_at = NOW(),
competitor_status = 'matched',
competitor_status_updated_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) => {
await queueMaintenanceJob("maintenance:find-equivalences");
res.json({ success: true, message: "Equivalence matcher queued" });
});
// ── POST /api/review/run-research ────────────────────────────────────────────
// Trigger the automated equivalence research worker immediately.
reviewRouter.post("/run-research", async (_req: Request, res: Response) => {
await queueMaintenanceJob("maintenance:re-research-equivalences");
res.json({ success: true, message: "Equivalence research queued" });
});