827 lines
28 KiB
TypeScript
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;
|
|
}
|
|
}
|