diff --git a/packages/api/src/routes/finder.ts b/packages/api/src/routes/finder.ts index 4b103ea..10d2762 100644 --- a/packages/api/src/routes/finder.ts +++ b/packages/api/src/routes/finder.ts @@ -54,16 +54,23 @@ finderRouter.get("/", async (req, res) => { SELECT t.id, t.slug, t.form_factor, t.speed, t.speed_gbps, t.reach_label, t.reach_meters, t.fiber_type, t.wavelengths, t.connector, t.power_consumption_w, - t.image_url, t.image_r2_key, t.part_number, + t.image_url, t.image_r2_key, t.part_number, t.product_page_url, + -- Verification tags + t.price_verified, t.price_verified_eur, t.price_verified_url, t.price_verified_at, + t.image_verified, t.details_verified, t.fully_verified, t.fully_verified_at, tv.name AS transceiver_vendor, tv.type AS vendor_type, c.status AS compat_status, c.firmware_min, c.verified_by, c.notes AS compat_notes, - -- Latest price - (SELECT po.price FROM price_observations po WHERE po.transceiver_id = t.id ORDER BY po.time DESC LIMIT 1) AS latest_price, - (SELECT po.currency FROM price_observations po WHERE po.transceiver_id = t.id ORDER BY po.time DESC LIMIT 1) AS latest_currency, + -- Latest price (verified preferred) + COALESCE(t.price_verified_eur, + (SELECT po.price FROM price_observations po WHERE po.transceiver_id = t.id ORDER BY po.time DESC LIMIT 1) + ) AS latest_price, + CASE WHEN t.price_verified_eur IS NOT NULL THEN 'EUR' + ELSE (SELECT po.currency FROM price_observations po WHERE po.transceiver_id = t.id ORDER BY po.time DESC LIMIT 1) + END AS latest_currency, (SELECT po.stock_level FROM price_observations po WHERE po.transceiver_id = t.id ORDER BY po.time DESC LIMIT 1) AS stock_level, -- Flexoptix mapping fpm.flexoptix_sku, @@ -128,6 +135,7 @@ finderRouter.get("/", async (req, res) => { compatible_transceivers: compatResult.rows.map(r => ({ id: r.id, slug: r.slug, + part_number: r.part_number, form_factor: r.form_factor, speed: r.speed, speed_gbps: r.speed_gbps, @@ -137,12 +145,22 @@ finderRouter.get("/", async (req, res) => { vendor: r.transceiver_vendor, vendor_type: r.vendor_type, image_url: r.image_url, + product_page_url: r.product_page_url, compat_status: r.compat_status, firmware_min: r.firmware_min, // Pricing price: r.latest_price ? parseFloat(r.latest_price) : null, currency: r.latest_currency, stock: r.stock_level, + // Verification tags + price_verified: r.price_verified === true, + price_verified_eur: r.price_verified_eur ? parseFloat(r.price_verified_eur) : null, + price_verified_url: r.price_verified_url || null, + price_verified_at: r.price_verified_at || null, + image_verified: r.image_verified === true, + details_verified: r.details_verified === true, + fully_verified: r.fully_verified === true, + fully_verified_at: r.fully_verified_at || null, // Flexoptix flexoptix_sku: r.flexoptix_sku, flexoptix_url: r.flexoptix_url, diff --git a/packages/api/src/routes/health.ts b/packages/api/src/routes/health.ts index 679fd37..3a3fc98 100644 --- a/packages/api/src/routes/health.ts +++ b/packages/api/src/routes/health.ts @@ -11,6 +11,18 @@ healthRouter.get("/", async (_req: Request, res: Response) => { const stats = await getDbStats(); const latencyMs = Date.now() - start; + // Verification stats + const verStats = await pool.query(` + SELECT + COUNT(*) FILTER (WHERE price_verified) AS price_verified, + COUNT(*) FILTER (WHERE image_verified) AS image_verified, + COUNT(*) FILTER (WHERE details_verified) AS details_verified, + COUNT(*) FILTER (WHERE fully_verified) AS fully_verified, + COUNT(*) AS total + FROM transceivers + `).catch(() => ({ rows: [{}] })); + const v = verStats.rows[0] || {}; + res.json({ success: true, status: "healthy", @@ -21,6 +33,15 @@ healthRouter.get("/", async (_req: Request, res: Response) => { latency_ms: latencyMs, stats, }, + verification: { + price_verified: Number(v.price_verified || 0), + image_verified: Number(v.image_verified || 0), + details_verified: Number(v.details_verified || 0), + fully_verified: Number(v.fully_verified || 0), + total: Number(v.total || 0), + price_coverage_pct: v.total ? Math.round(Number(v.price_verified) / Number(v.total) * 100) : 0, + fully_verified_pct: v.total ? Math.round(Number(v.fully_verified) / Number(v.total) * 100) : 0, + }, }); } catch (err) { res.status(503).json({ diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index 6cf627f..0d5cb24 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -2426,16 +2426,34 @@ async function runFinder() { var cards = items.slice(0, 12).map(function(t) { var isFlexoptix = (t.vendor || '').toUpperCase() === 'FLEXOPTIX'; - var hasPrice = t.price != null; + var fullyVerified = t.fully_verified === true; + var priceVerified = t.price_verified === true; + + // Price: use verified EUR price if available + var displayPrice = t.price_verified_eur || t.price; + var displayCurrency = t.price_verified_eur ? 'EUR' : (t.currency || 'EUR'); + var hasPrice = displayPrice != null; var priceHtml = hasPrice - ? '' + (t.currency || 'EUR') + ' ' + parseFloat(t.price).toFixed(2) + '' - : 'Price on request'; + ? '' + displayCurrency + ' ' + parseFloat(displayPrice).toFixed(2) + '' + + (priceVerified ? ' ✓ Verified' : '') + : 'see flexoptix.net'; + var stockHtml = t.stock === 'in_stock' ? '● In Stock' : t.stock === 'limited' ? '● Limited' : ''; var partNum = t.part_number || t.slug || t.id; - return '
' + + // 100% Verified stamp + var verifiedStamp = fullyVerified + ? '
★ 100% VERIFIED

' + : ''; + + // Card border: 100% verified = green, Flexoptix = orange, else default + var cardBorder = fullyVerified ? 'border:1px solid #2d6a4f;box-shadow:0 0 0 1px #2d6a4f20' + : isFlexoptix ? 'border-left:3px solid var(--accent)' : ''; + + return '
' + + verifiedStamp + '
' + '
' + (isFlexoptix ? 'FLEXOPTIX' : '') + @@ -2450,7 +2468,10 @@ async function runFinder() { priceHtml + '
' + stockHtml + '
' + '
' + - (t.buy_url ? 'Buy at Flexoptix →' : '') + + '
' + + (t.buy_url ? 'Buy at Flexoptix →' : '') + + (t.price_verified_url ? 'price source ↗' : '') + + '
' + '
'; }).join(''); @@ -2462,10 +2483,15 @@ async function runFinder() { '
'; }).join(''); + // Count verified in results + var verifiedCount = transceivers.filter(function(t) { return t.fully_verified; }).length; + var priceVerCount = transceivers.filter(function(t) { return t.price_verified; }).length; + results.innerHTML = swHtml + - '
' + - 'Showing ' + Math.min(transceivers.length, total) + ' of ' + total + ' compatible transceivers · ' + - 'Orange border = Flexoptix product' + + '
' + + 'Showing ' + Math.min(transceivers.length, total) + ' of ' + total + ' compatible transceivers' + + (verifiedCount > 0 ? '★ ' + verifiedCount + ' × 100% Verified' : '') + + (priceVerCount > 0 ? '✓ ' + priceVerCount + ' with verified prices' : '') + '
' + tcvrHtml; diff --git a/sql/017-verification-tags.sql b/sql/017-verification-tags.sql new file mode 100644 index 0000000..4ba6997 --- /dev/null +++ b/sql/017-verification-tags.sql @@ -0,0 +1,180 @@ +-- Migration 017: Product Verification Tags +-- "Verified Price" and "100% Verified" stamp system +-- +-- VERIFIED PRICE: Price scraped from official vendor URL, observed within 30 days +-- 100% VERIFIED: Verified Price + product image from official product page + details from official source +-- ───────────────────────────────────────────────────────────────────────────── + +-- Add verification columns to transceivers +ALTER TABLE transceivers + ADD COLUMN IF NOT EXISTS price_verified BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS price_verified_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS price_verified_url TEXT, + ADD COLUMN IF NOT EXISTS price_verified_eur NUMERIC(10,2), + ADD COLUMN IF NOT EXISTS image_verified BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS image_verified_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS image_verified_url TEXT, + ADD COLUMN IF NOT EXISTS details_verified BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS details_verified_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS details_source_url TEXT, + ADD COLUMN IF NOT EXISTS fully_verified BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS fully_verified_at TIMESTAMPTZ; + +-- Add verification columns to price_observations +ALTER TABLE price_observations + ADD COLUMN IF NOT EXISTS is_verified BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS verified_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS scrape_method TEXT; -- 'html_scrape', 'api', 'manual' + +-- ─── Function: compute verification status for a single transceiver ─────────── +CREATE OR REPLACE FUNCTION compute_transceiver_verification(t_id UUID) +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + v_price_row RECORD; + v_price_ok BOOLEAN := false; + v_price_eur NUMERIC; + v_price_url TEXT; + v_price_at TIMESTAMPTZ; + v_image_ok BOOLEAN := false; + v_details_ok BOOLEAN := false; + v_fully_ok BOOLEAN := false; +BEGIN + -- ── PRICE VERIFICATION ──────────────────────────────────────────────────── + -- Criteria: at least one price_observation with: + -- • price > 0 + -- • url IS NOT NULL (scraped from real product page, not estimated) + -- • observed within last 30 days + SELECT po.price, po.currency, po.url, po.time + INTO v_price_row + FROM price_observations po + WHERE po.transceiver_id = t_id + AND po.price > 0 + AND po.url IS NOT NULL + AND po.url != '' + AND po.time > NOW() - INTERVAL '30 days' + ORDER BY po.time DESC + LIMIT 1; + + IF FOUND THEN + v_price_ok := true; + v_price_url := v_price_row.url; + v_price_at := v_price_row.time; + -- Normalize to EUR (approximate for non-EUR currencies — scraper stores actual currency) + v_price_eur := CASE v_price_row.currency + WHEN 'EUR' THEN v_price_row.price + WHEN 'USD' THEN ROUND(v_price_row.price * 0.92, 2) + WHEN 'GBP' THEN ROUND(v_price_row.price * 1.17, 2) + ELSE v_price_row.price + END; + END IF; + + -- ── IMAGE VERIFICATION ──────────────────────────────────────────────────── + -- Criteria: image_url or image_r2_key is set AND + -- • image_url points to a real vendor/manufacturer CDN (not a generic placeholder) + -- • OR image is stored in R2 (always real, downloaded by scraper) + SELECT + ( + -- R2 image always verified (downloaded by scraper from product page) + (transceivers.image_r2_key IS NOT NULL AND transceivers.image_r2_key != '') + OR + -- image_url from known product CDNs (flexoptix, fs.com, vendor sites) + (transceivers.image_url IS NOT NULL AND ( + transceivers.image_url ILIKE '%flexoptix.net%' + OR transceivers.image_url ILIKE '%resource.fs.com%' + OR transceivers.image_url ILIKE '%fs.com/mall%' + OR transceivers.image_url ILIKE '%innolight%' + OR transceivers.image_url ILIKE '%coherent.com%' + OR transceivers.image_url ILIKE '%lumentum%' + OR transceivers.image_url ILIKE '%ii-vi%' + OR transceivers.image_url ILIKE '%atgbics.com%' + OR transceivers.image_url ILIKE '%prolabs%' + OR transceivers.image_url ILIKE '%10gtek%' + OR transceivers.image_url ILIKE '%optcore%' + OR transceivers.image_url ILIKE '%fluxlight%' + OR transceivers.image_url ILIKE '%champion-one%' + OR transceivers.image_url ILIKE '%gbics.com%' + )) + ) INTO v_image_ok + FROM transceivers + WHERE id = t_id; + + -- ── DETAILS VERIFICATION ────────────────────────────────────────────────── + -- Criteria: product_page_url is set AND data_confidence is 'scraped_unverified' or better + -- AND core fields are populated (form_factor, speed_gbps, reach_label, fiber_type) + SELECT + ( + transceivers.product_page_url IS NOT NULL + AND transceivers.form_factor IS NOT NULL + AND transceivers.speed_gbps IS NOT NULL + AND transceivers.reach_label IS NOT NULL + AND (transceivers.part_number IS NOT NULL AND transceivers.part_number != transceivers.slug) + AND transceivers.data_confidence IN ('scraped_unverified', 'verified', 'official') + ) INTO v_details_ok + FROM transceivers + WHERE id = t_id; + + -- ── FULLY VERIFIED ──────────────────────────────────────────────────────── + v_fully_ok := v_price_ok AND v_image_ok AND v_details_ok; + + -- ── WRITE BACK ──────────────────────────────────────────────────────────── + UPDATE transceivers SET + price_verified = v_price_ok, + price_verified_at = CASE WHEN v_price_ok THEN v_price_at ELSE NULL END, + price_verified_url = CASE WHEN v_price_ok THEN v_price_url ELSE NULL END, + price_verified_eur = CASE WHEN v_price_ok THEN v_price_eur ELSE NULL END, + image_verified = v_image_ok, + image_verified_at = CASE WHEN v_image_ok THEN NOW() ELSE NULL END, + details_verified = v_details_ok, + details_verified_at = CASE WHEN v_details_ok THEN NOW() ELSE NULL END, + fully_verified = v_fully_ok, + fully_verified_at = CASE WHEN v_fully_ok THEN NOW() ELSE NULL END, + updated_at = NOW() + WHERE id = t_id; +END; +$$; + +-- ─── Function: recompute ALL transceivers (run after scraper, or on demand) ── +CREATE OR REPLACE FUNCTION recompute_all_verification() +RETURNS TABLE( + price_verified_count INT, + image_verified_count INT, + details_verified_count INT, + fully_verified_count INT, + total_count INT +) LANGUAGE plpgsql AS $$ +BEGIN + PERFORM compute_transceiver_verification(id) FROM transceivers; + + RETURN QUERY + SELECT + COUNT(*) FILTER (WHERE price_verified)::INT, + COUNT(*) FILTER (WHERE image_verified)::INT, + COUNT(*) FILTER (WHERE details_verified)::INT, + COUNT(*) FILTER (WHERE fully_verified)::INT, + COUNT(*)::INT + FROM transceivers; +END; +$$; + +-- ─── Initial run: compute verification for all existing products ────────────── +SELECT * FROM recompute_all_verification(); + +-- ─── Indexes for fast filtering ─────────────────────────────────────────────── +CREATE INDEX IF NOT EXISTS idx_transceivers_price_verified ON transceivers (price_verified) WHERE price_verified = true; +CREATE INDEX IF NOT EXISTS idx_transceivers_fully_verified ON transceivers (fully_verified) WHERE fully_verified = true; + +-- ─── Convenience view ───────────────────────────────────────────────────────── +CREATE OR REPLACE VIEW v_verified_products AS +SELECT + t.id, t.part_number, t.standard_name, t.form_factor, t.speed, t.speed_gbps, + t.reach_label, t.fiber_type, t.connector, t.image_url, t.image_r2_key, + t.product_page_url, t.data_confidence, + v.name AS vendor, v.type AS vendor_type, + t.price_verified, t.price_verified_eur, t.price_verified_url, t.price_verified_at, + t.image_verified, t.details_verified, t.fully_verified, t.fully_verified_at +FROM transceivers t +JOIN vendors v ON t.vendor_id = v.id +WHERE t.price_verified = true OR t.fully_verified = true; + +COMMENT ON VIEW v_verified_products IS + 'Products with at least a verified price. fully_verified = price + image + details all confirmed.';