2026-05-10 00:03:42 +02:00

827 lines
28 KiB
TypeScript

import { Pool } from "pg";
import { config } from "dotenv";
import { join } from "path";
import { contentHash } from "./hash";
config({ path: join(__dirname, "..", "..", "..", "..", ".env") });
export const pool = new Pool({
host: process.env.POSTGRES_HOST || "localhost",
port: parseInt(process.env.POSTGRES_PORT || "5433"),
database: process.env.POSTGRES_DB || "transceiver_db",
user: process.env.POSTGRES_USER || "tip",
password: process.env.POSTGRES_PASSWORD || "tip_dev_2026",
max: 5,
idleTimeoutMillis: 10000,
connectionTimeoutMillis: 5000,
});
// Alias — some scrapers import { db } instead of { pool }
export const db = pool;
export async function recordVerificationEvidence(params: {
transceiverId: string;
verificationType: "price" | "image" | "details" | "competitor_match" | "competitor_no_match" | "competitor_ambiguous" | "artifact_quarantine";
sourceUrl?: string;
sourceVendorId?: string;
evidenceValue?: Record<string, unknown>;
robotName: string;
confidence?: number;
}): Promise<void> {
const evidenceValue = params.evidenceValue || {};
const evidenceHash = contentHash({
type: params.verificationType,
sourceUrl: params.sourceUrl || "",
sourceVendorId: params.sourceVendorId || "",
evidenceValue,
});
await pool.query(
`INSERT INTO transceiver_verification_evidence (
transceiver_id, verification_type, source_url, source_vendor_id,
evidence_value, evidence_hash, robot_name, confidence
)
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8)
ON CONFLICT DO NOTHING`,
[
params.transceiverId,
params.verificationType,
params.sourceUrl || null,
params.sourceVendorId || null,
JSON.stringify(evidenceValue),
evidenceHash,
params.robotName,
params.confidence ?? null,
]
);
}
/**
* After any verified flag is set, check if all 4 criteria are met and promote
* the transceiver to fully_verified. Call this wherever price/image/details/
* competitor_verified are written so the counter stays consistent.
*/
export 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;
}
export async function markImageVerified(
transceiverId: string,
imageUrl: string
): Promise<boolean> {
const result = await pool.query(
`UPDATE transceivers
SET image_url = CASE
WHEN image_url IS NULL
OR image_url = ''
OR image_url ~* '(placeholder|no-image|no_image|missing|default)'
THEN $2::text
ELSE image_url
END,
has_image = true,
image_verified = true,
image_verified_at = NOW(),
image_verified_url = $2::text,
image_scraped_at = NOW(),
updated_at = NOW()
WHERE id = $1
AND $2::text IS NOT NULL
AND $2::text != ''
RETURNING id`,
[transceiverId, imageUrl]
);
await checkAndSetFullyVerified(transceiverId);
await recordVerificationEvidence({
transceiverId,
verificationType: "image",
sourceUrl: imageUrl,
evidenceValue: { imageUrl },
robotName: "markImageVerified",
confidence: 1,
});
return (result.rowCount ?? 0) > 0;
}
export async function markDetailsVerified(params: {
transceiverId: string;
sourceUrl?: string;
}): Promise<boolean> {
const result = await pool.query(
`UPDATE transceivers
SET product_page_url = COALESCE(NULLIF(product_page_url, ''), NULLIF($2::text, '')),
details_verified = true,
details_verified_at = COALESCE(details_verified_at, NOW()),
details_source_url = COALESCE(NULLIF(details_source_url, ''), NULLIF($2::text, ''), product_page_url),
data_confidence = CASE
WHEN data_confidence IS NULL OR data_confidence IN ('unknown', 'enriched_estimated')
THEN 'scraped_unverified'
ELSE data_confidence
END,
updated_at = NOW()
WHERE id = $1
AND form_factor IS NOT NULL
AND speed_gbps IS NOT NULL
AND part_number IS NOT NULL
AND part_number != ''
AND reach_label IS NOT NULL
AND reach_label != ''
AND fiber_type IS NOT NULL
AND fiber_type != ''
AND COALESCE(data_confidence, 'unknown') != 'garbage'
RETURNING id`,
[params.transceiverId, params.sourceUrl || null]
);
await checkAndSetFullyVerified(params.transceiverId);
if ((result.rowCount ?? 0) > 0) {
await recordVerificationEvidence({
transceiverId: params.transceiverId,
verificationType: "details",
sourceUrl: params.sourceUrl,
evidenceValue: { sourceUrl: params.sourceUrl || null },
robotName: "markDetailsVerified",
confidence: 1,
});
}
return (result.rowCount ?? 0) > 0;
}
// Per-form-factor price bounds [min, max] in USD equivalent
const PRICE_BOUNDS: Record<string, [number, number]> = {
"SFP": [2, 3000],
"SFP+": [4, 5000],
"SFP28": [10, 8000],
"SFP56": [20, 10000],
"SFP-DD": [30, 12000],
"QSFP+": [15, 6000],
"QSFP28": [20, 10000],
"QSFP56": [50, 15000],
"QSFP-DD": [60, 20000],
"QSFP112": [80, 25000],
"OSFP": [100, 35000],
"OSFP112": [150, 40000],
"OSFP224": [200, 60000],
"CFP": [100, 30000],
"CFP2": [100, 30000],
"XFP": [10, 5000],
"GBIC": [2, 2000],
};
async function isPriceAnomalous(transceiverId: string, priceUsd: number): Promise<boolean> {
const row = await pool.query(
`SELECT form_factor FROM transceivers WHERE id = $1`,
[transceiverId]
);
const formFactor = row.rows[0]?.form_factor as string | undefined;
if (!formFactor) return false;
const bounds = PRICE_BOUNDS[formFactor];
if (!bounds) return false;
return priceUsd < bounds[0] || priceUsd > bounds[1];
}
export async function upsertPriceObservation(params: {
transceiverId: string;
sourceVendorId: string;
price: number;
currency: string;
stockLevel: string;
quantityAvailable?: number;
leadTimeDays?: number;
url?: string;
contentHash: string;
}): Promise<boolean> {
// Normalize price to USD for sanity check (rough conversion)
const priceUsd = params.currency === "EUR" ? params.price * 1.09
: params.currency === "GBP" ? params.price * 1.27
: params.price;
// Hard floor: no transceiver of any type can cost less than $1.50 — catches accessories/cables
// misidentified as transceivers (e.g. FS-XXXXX DAC cables scraped as OSFP/QSFP28)
if (priceUsd < 1.5) {
return false;
}
const anomalous = await isPriceAnomalous(params.transceiverId, priceUsd);
if (anomalous) {
return false; // Reject price outside form-factor bounds
}
// Check if price changed via content hash — also check observation age
const existing = await pool.query(
`SELECT content_hash, time FROM price_observations
WHERE transceiver_id = $1 AND source_vendor_id = $2
ORDER BY time DESC LIMIT 1`,
[params.transceiverId, params.sourceVendorId]
);
// Check if vendor is a competitor (non-Flexoptix) for competitor_verified flag
const vendorRow = await pool.query(
`SELECT is_competitor FROM vendors WHERE id = $1`,
[params.sourceVendorId]
);
const isCompetitor = vendorRow.rows[0]?.is_competitor === true;
// Price unchanged AND observation is fresh (< 7 days old) → skip insertion
const REFRESH_DAYS = 7;
const isStale = !existing.rows.length ||
(Date.now() - new Date(existing.rows[0].time).getTime()) > REFRESH_DAYS * 24 * 60 * 60 * 1000;
if (existing.rows.length > 0 && existing.rows[0].content_hash === params.contentHash && !isStale) {
// Price unchanged and recent — still ensure verified flags are current
await pool.query(
`UPDATE price_observations
SET is_verified = true,
verified_at = NOW()
WHERE transceiver_id = $1
AND source_vendor_id = $2
AND content_hash = $3
AND time > NOW() - INTERVAL '${REFRESH_DAYS} days'`,
[params.transceiverId, params.sourceVendorId, params.contentHash]
);
await pool.query(
`UPDATE transceivers SET
price_verified = true,
price_verified_at = NOW()
${isCompetitor ? ", competitor_verified = true, competitor_verified_at = NOW(), competitor_status = 'matched', competitor_status_updated_at = NOW()" : ""}
WHERE id = $1`,
[params.transceiverId]
);
await checkAndSetFullyVerified(params.transceiverId);
await recordVerificationEvidence({
transceiverId: params.transceiverId,
verificationType: "price",
sourceUrl: params.url,
sourceVendorId: params.sourceVendorId,
evidenceValue: { price: params.price, currency: params.currency, stockLevel: params.stockLevel },
robotName: "upsertPriceObservation",
confidence: 1,
});
return false; // No change
}
await pool.query(
`INSERT INTO price_observations (
time, transceiver_id, source_vendor_id, price, currency, stock_level,
quantity_available, lead_time_days, url, content_hash, is_verified, verified_at
)
VALUES (NOW(), $1, $2, $3, $4, $5, $6, $7, $8, $9, true, NOW())`,
[
params.transceiverId,
params.sourceVendorId,
params.price,
params.currency,
params.stockLevel,
params.quantityAvailable || null,
params.leadTimeDays || null,
params.url || null,
params.contentHash,
]
);
// Mark price_verified always; competitor_verified only for non-Flexoptix vendors
if (isCompetitor) {
await pool.query(
`UPDATE transceivers SET
price_verified = true,
price_verified_at = NOW(),
competitor_verified = true,
competitor_verified_at = NOW(),
competitor_status = 'matched',
competitor_status_updated_at = NOW()
WHERE id = $1`,
[params.transceiverId]
);
} else {
await pool.query(
`UPDATE transceivers
SET price_verified = true,
price_verified_at = NOW()
WHERE id = $1`,
[params.transceiverId]
);
}
await checkAndSetFullyVerified(params.transceiverId);
await recordVerificationEvidence({
transceiverId: params.transceiverId,
verificationType: "price",
sourceUrl: params.url,
sourceVendorId: params.sourceVendorId,
evidenceValue: { price: params.price, currency: params.currency, stockLevel: params.stockLevel },
robotName: "upsertPriceObservation",
confidence: 1,
});
return true; // New observation written
}
/**
* Upsert a stock observation with full warehouse breakdown (FS.com v2+).
* Writes to stock_observations including DE-Lager, Global-Lager, Nachlieferung,
* units_sold, compatible_brands, price_net, product_url, plus quality metadata:
* stock_confidence, price_currency, price_includes_tax, stock_vendor_ts.
*
* stock_confidence:
* 1 = boolean only (in_stock true/false, no unit count)
* 2 = aggregated global count (single number, e.g. QSFPTEK "5507 in real-time stock")
* 3 = per-warehouse breakdown (e.g. FS.com DE-Lager + Global-Lager split)
*
* Returns true only when the data has changed since the last observation.
*/
export async function upsertStockObservation(params: {
transceiverId: string;
sourceVendorId: string;
stockLevel: string;
quantityAvailable?: number;
warehouseDeQty?: number;
warehouseDeDeliveryDate?: string | null;
warehouseGlobalQty?: number;
warehouseGlobalDeliveryDate?: string | null;
backorderQty?: number;
backorderEstimatedDate?: string | null;
unitsSold?: number;
compatibleBrands?: string[];
priceNet?: number;
productUrl?: string;
// Quality metadata (migration 038)
stockConfidence?: 1 | 2 | 3;
priceCurrency?: string;
priceIncludesTax?: boolean;
stockVendorTs?: Date | null;
}): Promise<boolean> {
// Skip if there is genuinely no warehouse data at all
// (includes backorderQty so products available only on backorder are recorded)
if (
params.warehouseDeQty === undefined &&
params.warehouseGlobalQty === undefined &&
params.quantityAvailable === undefined &&
params.backorderQty === undefined
) {
return false;
}
// Compare against the last observation to avoid duplicate writes
const lastObs = await pool.query(
`SELECT warehouse_de_qty, warehouse_global_qty, backorder_qty, units_sold, quantity_available
FROM stock_observations
WHERE transceiver_id = $1 AND source_vendor_id = $2
ORDER BY time DESC LIMIT 1`,
[params.transceiverId, params.sourceVendorId]
);
if (lastObs.rows.length > 0) {
const r = lastObs.rows[0];
const unchanged =
(r.warehouse_de_qty ?? null) === (params.warehouseDeQty ?? null) &&
(r.warehouse_global_qty ?? null) === (params.warehouseGlobalQty ?? null) &&
(r.backorder_qty ?? null) === (params.backorderQty ?? null) &&
(r.units_sold ?? null) === (params.unitsSold ?? null) &&
(r.quantity_available ?? null) === (params.quantityAvailable ?? null);
if (unchanged) return false;
}
const inStock =
((params.warehouseDeQty ?? 0) + (params.warehouseGlobalQty ?? 0) + (params.quantityAvailable ?? 0)) > 0;
await pool.query(
`INSERT INTO stock_observations (
time, transceiver_id, source_vendor_id,
in_stock, quantity_available,
warehouse_de_qty, warehouse_de_delivery_date,
warehouse_global_qty, warehouse_global_delivery_date,
backorder_qty, backorder_estimated_date,
units_sold, compatible_brands, price_net, product_url,
stock_confidence, price_currency, price_includes_tax, stock_vendor_ts
) VALUES (
NOW(), $1, $2,
$3, $4,
$5, $6::date,
$7, $8::date,
$9, $10::date,
$11, $12, $13, $14,
$15, $16, $17, $18
)`,
[
params.transceiverId,
params.sourceVendorId,
inStock,
params.quantityAvailable ?? null,
params.warehouseDeQty ?? null,
params.warehouseDeDeliveryDate ?? null,
params.warehouseGlobalQty ?? null,
params.warehouseGlobalDeliveryDate ?? null,
params.backorderQty ?? null,
params.backorderEstimatedDate ?? null,
params.unitsSold ?? null,
params.compatibleBrands?.length ? params.compatibleBrands : null,
params.priceNet ?? null,
params.productUrl ?? null,
params.stockConfidence ?? 1,
params.priceCurrency ?? null,
params.priceIncludesTax ?? null,
params.stockVendorTs ?? null,
]
);
return true;
}
export async function findOrCreateScrapedTransceiver(params: {
partNumber: string;
vendorId: string;
productUrl?: string;
formFactor?: string;
speedGbps?: number;
speed?: string;
reachMeters?: number;
reachLabel?: string;
fiberType?: string;
wavelengths?: string;
category?: string;
imageUrl?: string;
}): Promise<string> {
// Try to match existing transceiver by part number + vendor
const existing = await pool.query(
`SELECT id, image_url FROM transceivers WHERE part_number = $1 AND vendor_id = $2`,
[params.partNumber, params.vendorId]
);
if (existing.rows.length > 0) {
await pool.query(
`UPDATE transceivers
SET product_page_url = COALESCE(NULLIF(product_page_url, ''), NULLIF($2, '')),
form_factor = COALESCE(NULLIF(form_factor, ''), $3),
speed_gbps = CASE WHEN speed_gbps IS NULL OR speed_gbps = 0 THEN COALESCE($4, speed_gbps) ELSE speed_gbps END,
speed = COALESCE(NULLIF(speed, ''), $5),
reach_meters = CASE WHEN reach_meters IS NULL OR reach_meters = 0 THEN COALESCE($6, reach_meters) ELSE reach_meters END,
reach_label = COALESCE(NULLIF(reach_label, ''), $7),
fiber_type = COALESCE(NULLIF(fiber_type, ''), $8),
wavelengths = COALESCE(NULLIF(wavelengths, ''), $9),
category = COALESCE(NULLIF(category, ''), $10),
updated_at = NOW()
WHERE id = $1`,
[
existing.rows[0].id,
params.productUrl || null,
params.formFactor || null,
params.speedGbps || null,
params.speed || null,
params.reachMeters || null,
params.reachLabel || null,
params.fiberType || null,
params.wavelengths || null,
params.category || null,
]
);
// Re-validate image metadata whenever the scraper sees a current product image.
if (params.imageUrl) {
await markImageVerified(existing.rows[0].id, params.imageUrl);
}
if (params.productUrl) {
await markDetailsVerified({
transceiverId: existing.rows[0].id,
sourceUrl: params.productUrl,
});
}
return existing.rows[0].id;
}
// Create new transceiver entry
const slug = `scraped-${params.partNumber.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
const result = await pool.query(
`INSERT INTO transceivers (
slug, part_number, vendor_id, product_page_url, form_factor, speed_gbps,
speed, reach_meters, reach_label, fiber_type, wavelengths, category,
market_status, data_confidence, image_url, has_image, image_verified,
image_verified_at, image_verified_url, details_verified, details_verified_at,
details_source_url
)
VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11, $12,
'Mainstream', 'scraped_unverified', $13, $14, $14,
CASE WHEN $14 THEN NOW() ELSE NULL END, $13, $15,
CASE WHEN $15 THEN NOW() ELSE NULL END, $4
)
ON CONFLICT (slug) DO UPDATE SET
product_page_url = COALESCE(transceivers.product_page_url, EXCLUDED.product_page_url),
image_url = COALESCE(transceivers.image_url, EXCLUDED.image_url),
has_image = COALESCE(transceivers.has_image, false) OR COALESCE(EXCLUDED.has_image, false),
image_verified = COALESCE(transceivers.image_verified, false) OR COALESCE(EXCLUDED.image_verified, false),
image_verified_at = COALESCE(transceivers.image_verified_at, EXCLUDED.image_verified_at),
image_verified_url = COALESCE(transceivers.image_verified_url, EXCLUDED.image_verified_url),
details_verified = COALESCE(transceivers.details_verified, false) OR COALESCE(EXCLUDED.details_verified, false),
details_verified_at = COALESCE(transceivers.details_verified_at, EXCLUDED.details_verified_at),
details_source_url = COALESCE(transceivers.details_source_url, EXCLUDED.details_source_url),
data_confidence = CASE
WHEN transceivers.data_confidence IS NULL OR transceivers.data_confidence IN ('unknown', 'enriched_estimated')
THEN EXCLUDED.data_confidence
ELSE transceivers.data_confidence
END,
updated_at = NOW()
RETURNING id`,
[
slug,
params.partNumber,
params.vendorId,
params.productUrl || null,
params.formFactor || "SFP",
params.speedGbps || 0,
params.speed || "Unknown",
params.reachMeters || 0,
params.reachLabel || "",
params.fiberType || "",
params.wavelengths || "",
params.category || "DataCenter",
params.imageUrl || null,
Boolean(params.imageUrl),
Boolean(params.productUrl && params.reachLabel && params.fiberType),
]
);
const id = result.rows[0].id;
await checkAndSetFullyVerified(id);
return id;
}
export interface SwitchParams {
model: string;
vendorId: string;
series?: string;
category?: string;
layer?: string;
portsConfig?: Record<string, number>;
totalPorts?: number;
uplinkSpeedGbps?: number;
maxSpeedGbps?: number;
switchingCapacityTbps?: number;
forwardingRateMpps?: number;
asicVendor?: string;
asicModel?: string;
asicSeries?: string;
asicGeneration?: string;
rackUnits?: number;
maxPowerW?: number;
typicalPowerW?: number;
poeSupport?: string;
stackingSupport?: boolean;
vxlanSupport?: boolean;
evpnSupport?: boolean;
bgpSupport?: boolean;
mplsSupport?: boolean;
openconfigSupport?: boolean;
sonicCompatible?: boolean;
macsecSupport?: boolean;
lifecycleStatus?: string;
releaseDate?: string;
eolDate?: string;
msrpUsd?: number;
tags?: string[];
// Whitebox-specific fields
isWhitebox?: boolean;
isOcpAccepted?: boolean;
ocpStatus?: string;
supportedNos?: string[];
onlCompatible?: boolean;
dentCompatible?: boolean;
cumulusCompatible?: boolean;
fbossCompatible?: boolean;
cpu?: string;
cpuCores?: number;
ramGb?: number;
storageGb?: number;
storageType?: string;
transceiverFormFactors?: string[];
catalogUrl?: string;
sonicHwsku?: string;
onieSupport?: boolean;
scrapeSource?: string;
}
export async function findOrCreateSwitch(params: SwitchParams): Promise<string> {
const existing = await pool.query(
`SELECT id FROM switches WHERE model = $1 AND vendor_id = $2`,
[params.model, params.vendorId]
);
if (existing.rows.length > 0) {
await pool.query(
`UPDATE switches SET
series = COALESCE($2, series),
category = COALESCE($3, category),
ports_config = COALESCE($4, ports_config),
total_ports = COALESCE($5, total_ports),
max_speed_gbps = COALESCE($6, max_speed_gbps),
switching_capacity_tbps = COALESCE($7, switching_capacity_tbps),
is_whitebox = COALESCE($8, is_whitebox),
supported_nos = COALESCE($9, supported_nos),
sonic_compatible = COALESCE($10, sonic_compatible),
sonic_hwsku = COALESCE($11, sonic_hwsku),
cpu = COALESCE($12, cpu),
ram_gb = COALESCE($13, ram_gb),
storage_gb = COALESCE($14, storage_gb),
transceiver_form_factors = COALESCE($15, transceiver_form_factors),
catalog_url = COALESCE($16, catalog_url),
is_ocp_accepted = COALESCE($17, is_ocp_accepted),
ocp_status = COALESCE($18, ocp_status),
onie_support = COALESCE($19, onie_support),
asic_series = COALESCE($20, asic_series),
last_scraped = CASE WHEN $21::text IS NOT NULL THEN NOW() ELSE last_scraped END,
scrape_source = COALESCE($21, scrape_source),
updated_at = NOW()
WHERE id = $1`,
[
existing.rows[0].id,
params.series || null,
params.category || null,
params.portsConfig ? JSON.stringify(params.portsConfig) : null,
params.totalPorts || null,
params.maxSpeedGbps || null,
params.switchingCapacityTbps || null,
params.isWhitebox ?? null,
params.supportedNos?.length ? params.supportedNos : null,
params.sonicCompatible ?? null,
params.sonicHwsku || null,
params.cpu || null,
params.ramGb || null,
params.storageGb || null,
params.transceiverFormFactors?.length ? params.transceiverFormFactors : null,
params.catalogUrl || null,
params.isOcpAccepted ?? null,
params.ocpStatus || null,
params.onieSupport ?? null,
params.asicSeries || null,
params.scrapeSource || null,
]
);
return existing.rows[0].id;
}
const result = await pool.query(
`INSERT INTO switches (
model, vendor_id, series, category, layer,
ports_config, total_ports, uplink_speed_gbps, max_speed_gbps,
switching_capacity_tbps, forwarding_rate_mpps,
asic_vendor, asic_model, asic_series, asic_generation,
rack_units, max_power_w, typical_power_w,
poe_support, stacking_support, vxlan_support, evpn_support,
bgp_support, mpls_support, openconfig_support, sonic_compatible, macsec_support,
lifecycle_status, release_date, eol_date, msrp_usd, tags,
is_whitebox, is_ocp_accepted, ocp_status, supported_nos,
onl_compatible, dent_compatible, cumulus_compatible, fboss_compatible,
cpu, cpu_cores, ram_gb, storage_gb, storage_type,
transceiver_form_factors, catalog_url, sonic_hwsku, onie_support,
last_scraped, scrape_source
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9,
$10, $11,
$12, $13, $14, $15,
$16, $17, $18,
$19, $20, $21, $22,
$23, $24, $25, $26, $27,
$28, $29, $30, $31, $32,
$33, $34, $35, $36,
$37, $38, $39, $40,
$41, $42, $43, $44, $45,
$46, $47, $48, $49,
$50, $51
)
ON CONFLICT (vendor_id, model) DO UPDATE SET updated_at = NOW()
RETURNING id`,
[
params.model,
params.vendorId,
params.series || null,
params.category || 'DataCenter',
params.layer || 'L3',
JSON.stringify(params.portsConfig || {}),
params.totalPorts || null,
params.uplinkSpeedGbps || null,
params.maxSpeedGbps || null,
params.switchingCapacityTbps || null,
params.forwardingRateMpps || null,
params.asicVendor || null,
params.asicModel || null,
params.asicSeries || null,
params.asicGeneration || null,
params.rackUnits || null,
params.maxPowerW || null,
params.typicalPowerW || null,
params.poeSupport || 'None',
params.stackingSupport || false,
params.vxlanSupport || false,
params.evpnSupport || false,
params.bgpSupport || false,
params.mplsSupport || false,
params.openconfigSupport || false,
params.sonicCompatible || false,
params.macsecSupport || false,
params.lifecycleStatus || 'Active',
params.releaseDate || null,
params.eolDate || null,
params.msrpUsd || null,
params.tags || [],
params.isWhitebox || false,
params.isOcpAccepted || false,
params.ocpStatus || 'None',
params.supportedNos || [],
params.onlCompatible || false,
params.dentCompatible || false,
params.cumulusCompatible || false,
params.fbossCompatible || false,
params.cpu || null,
params.cpuCores || null,
params.ramGb || null,
params.storageGb || null,
params.storageType || null,
params.transceiverFormFactors || [],
params.catalogUrl || null,
params.sonicHwsku || null,
params.onieSupport || false,
params.scrapeSource ? new Date() : null,
params.scrapeSource || null,
]
);
return result.rows[0].id;
}
export async function ensureWhiteboxVendor(
name: string,
website?: string,
options?: { isOdm?: boolean; ocpMember?: boolean; sonicContributor?: boolean }
): Promise<string> {
const existing = await pool.query(`SELECT id FROM vendors WHERE name ILIKE $1`, [name]);
if (existing.rows.length > 0) {
if (options) {
await pool.query(
`UPDATE vendors SET
is_whitebox_vendor = TRUE,
is_odm = COALESCE($2, is_odm),
ocp_member = COALESCE($3, ocp_member),
sonic_contributor = COALESCE($4, sonic_contributor),
updated_at = NOW()
WHERE id = $1`,
[existing.rows[0].id, options.isOdm ?? null, options.ocpMember ?? null, options.sonicContributor ?? null]
);
}
return existing.rows[0].id;
}
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
const result = await pool.query(
`INSERT INTO vendors (name, slug, type, website, is_whitebox_vendor, is_odm, ocp_member, sonic_contributor)
VALUES ($1, $2, 'manufacturer', $3, TRUE, $4, $5, $6)
RETURNING id`,
[
name, slug, website || null,
options?.isOdm ?? true,
options?.ocpMember ?? false,
options?.sonicContributor ?? false,
]
);
return result.rows[0].id;
}
export async function getVendorId(name: string): Promise<string | null> {
const result = await pool.query(`SELECT id FROM vendors WHERE name = $1`, [name]);
return result.rows[0]?.id || null;
}
export async function ensureVendor(
name: string,
type: string,
website?: string,
shopUrl?: string
): Promise<string> {
// Try to find existing vendor first
const existing = await pool.query(`SELECT id FROM vendors WHERE name ILIKE $1`, [name]);
if (existing.rows.length > 0) return existing.rows[0].id;
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
try {
const result = await pool.query(
`INSERT INTO vendors (name, slug, type, website, shop_url, is_competitor)
VALUES ($1, $2, $3, $4, $5, true)
RETURNING id`,
[name, slug, type, website || null, shopUrl || null]
);
return result.rows[0].id;
} catch (err: unknown) {
// Handle race condition — re-query if insert fails on unique constraint
const existing2 = await pool.query(`SELECT id FROM vendors WHERE name ILIKE $1 OR slug = $2`, [name, slug]);
if (existing2.rows.length > 0) return existing2.rows[0].id;
throw err;
}
}