- api() helper now parses JSON body on non-2xx responses so error.suggestion is available in catch blocks - runFinder() catch shows 'Switch not found' + suggestion instead of 'Error: HTTP 404' - finder.ts: normalized search (removes hyphens/spaces) + token-based fallback so 'sg350-28' → 'SG350-28', 'N9K-C93180' → Nexus 93180, etc.
278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
/**
|
|
* WS1: Switch → Flexoptix Transceiver Finder
|
|
*
|
|
* "Customer has a Cisco Nexus 93180YC-FX3 — which Flexoptix transceivers fit?"
|
|
*/
|
|
import { Router } from "express";
|
|
import { pool } from "../db/client";
|
|
|
|
export const finderRouter = Router();
|
|
|
|
/**
|
|
* GET /api/finder?switch=<model>&speed=&form_factor=
|
|
*
|
|
* Finds Flexoptix-compatible transceivers for a given switch model.
|
|
* If no direct Flexoptix match, shows generic compatible transceivers
|
|
* with a note about Flexoptix FlexBox coding capability.
|
|
*/
|
|
finderRouter.get("/", async (req, res) => {
|
|
try {
|
|
const { switch: switchQuery, speed, form_factor, limit = "20" } = req.query;
|
|
|
|
if (!switchQuery) {
|
|
return res.status(400).json({ error: "Parameter 'switch' is required" });
|
|
}
|
|
|
|
// Step 1: Find the switch — try multiple search strategies
|
|
const q = String(switchQuery);
|
|
// Normalized form: remove hyphens/spaces for fuzzy match (sg350-28 → sg35028)
|
|
const qNorm = q.replace(/[\s\-_]/g, "");
|
|
|
|
const switchResult = await pool.query(
|
|
`SELECT sw.id, sw.model, sw.series, sw.ports_config, sw.max_speed_gbps,
|
|
v.name AS vendor_name, sw.image_url, sw.datasheet_r2_key
|
|
FROM switches sw
|
|
JOIN vendors v ON sw.vendor_id = v.id
|
|
WHERE sw.model ILIKE $1
|
|
OR sw.model ILIKE '%' || $1 || '%'
|
|
OR REPLACE(REPLACE(sw.model, '-', ''), ' ', '') ILIKE '%' || $2 || '%'
|
|
OR sw.search_vector @@ plainto_tsquery('english', $3)
|
|
ORDER BY
|
|
CASE WHEN sw.model ILIKE $1 THEN 0
|
|
WHEN sw.model ILIKE $1 || '%' THEN 1
|
|
WHEN REPLACE(REPLACE(sw.model, '-', ''), ' ', '') ILIKE $2 || '%' THEN 2
|
|
ELSE 3 END
|
|
LIMIT 5`,
|
|
[q, qNorm, q.replace(/[^\w\s]/g, " ")]
|
|
);
|
|
|
|
if (switchResult.rows.length === 0) {
|
|
// Try one more time with individual tokens
|
|
const tokens = q.split(/[\s\-_]+/).filter((t) => t.length >= 3);
|
|
let fallbackResult = { rows: [] as any[] };
|
|
if (tokens.length > 0) {
|
|
fallbackResult = await pool.query(
|
|
`SELECT sw.id, sw.model, sw.series, sw.ports_config, sw.max_speed_gbps,
|
|
v.name AS vendor_name, sw.image_url, sw.datasheet_r2_key
|
|
FROM switches sw JOIN vendors v ON sw.vendor_id = v.id
|
|
WHERE ${tokens.map((_, i) => `sw.model ILIKE '%' || $${i + 1} || '%'`).join(" AND ")}
|
|
LIMIT 5`,
|
|
tokens
|
|
);
|
|
}
|
|
if (fallbackResult.rows.length === 0) {
|
|
return res.status(404).json({
|
|
error: `Switch "${q}" not found in the database`,
|
|
suggestion: `Try a partial model name, e.g. "${tokens[0] || q.substring(0, 6)}" — or check spelling. Available: Cisco Nexus, Arista, Juniper QFX, Edgecore, Mellanox.`
|
|
});
|
|
}
|
|
switchResult.rows.push(...fallbackResult.rows);
|
|
}
|
|
|
|
const sw = switchResult.rows[0];
|
|
|
|
// Step 2: Find compatible transceivers via compatibility table
|
|
let compatSql = `
|
|
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.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 (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,
|
|
fpm.flexoptix_url,
|
|
fpm.flexoptix_price_eur,
|
|
fpm.match_type AS flexoptix_match
|
|
FROM compatibility c
|
|
JOIN transceivers t ON c.transceiver_id = t.id
|
|
JOIN vendors tv ON t.vendor_id = tv.id
|
|
LEFT JOIN flexoptix_product_map fpm ON (
|
|
fpm.form_factor = t.form_factor
|
|
AND fpm.speed_gbps = t.speed_gbps
|
|
AND (fpm.reach_label = t.reach_label OR fpm.reach_label IS NULL)
|
|
)
|
|
WHERE c.switch_id = $1 AND c.status = 'compatible'
|
|
`;
|
|
const params: any[] = [sw.id];
|
|
let idx = 2;
|
|
|
|
if (speed) {
|
|
compatSql += ` AND t.speed_gbps = $${idx}`;
|
|
params.push(parseFloat(speed as string));
|
|
idx++;
|
|
}
|
|
if (form_factor) {
|
|
compatSql += ` AND t.form_factor = $${idx}`;
|
|
params.push(form_factor);
|
|
idx++;
|
|
}
|
|
|
|
compatSql += ` ORDER BY t.speed_gbps DESC, t.reach_meters ASC LIMIT $${idx}`;
|
|
params.push(parseInt(limit as string));
|
|
|
|
const compatResult = await pool.query(compatSql, params);
|
|
|
|
// Step 3: Group results by speed class
|
|
const bySpeed: Record<string, any[]> = {};
|
|
for (const row of compatResult.rows) {
|
|
const key = `${row.speed_gbps}G ${row.form_factor}`;
|
|
if (!bySpeed[key]) bySpeed[key] = [];
|
|
bySpeed[key].push({
|
|
...row,
|
|
flexoptix_available: !!row.flexoptix_sku,
|
|
flexbox_codable: true, // All Flexoptix modules are FlexBox-codable
|
|
buy_url: row.flexoptix_url || `https://www.flexoptix.net/en/catalogsearch/result/?q=${encodeURIComponent(row.form_factor + ' ' + row.speed_gbps + 'G ' + row.reach_label)}`,
|
|
});
|
|
}
|
|
|
|
// Step 4: Extract port types from switch for "what can this switch accept?"
|
|
const portTypes = sw.ports_config || {};
|
|
|
|
res.json({
|
|
switch: {
|
|
id: sw.id,
|
|
model: sw.model,
|
|
series: sw.series,
|
|
vendor: sw.vendor_name,
|
|
max_speed_gbps: sw.max_speed_gbps,
|
|
ports: portTypes,
|
|
image_url: sw.image_url,
|
|
},
|
|
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,
|
|
reach: r.reach_label,
|
|
fiber_type: r.fiber_type,
|
|
connector: r.connector,
|
|
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,
|
|
flexoptix_price_eur: r.flexoptix_price_eur ? parseFloat(r.flexoptix_price_eur) : null,
|
|
flexoptix_match: r.flexoptix_match,
|
|
flexbox_codable: true,
|
|
buy_url: r.flexoptix_url || `https://www.flexoptix.net/en/catalogsearch/result/?q=${encodeURIComponent(r.form_factor + ' ' + r.speed_gbps + 'G ' + r.reach_label)}`,
|
|
})),
|
|
by_speed_class: bySpeed,
|
|
total: compatResult.rowCount,
|
|
flexoptix_note: "All Flexoptix transceivers support FlexBox coding for OEM compatibility.",
|
|
});
|
|
} catch (err) {
|
|
console.error("Finder error:", err);
|
|
res.status(500).json({ error: "Internal server error" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/finder/suggest?q=<free text>
|
|
*
|
|
* Free-text query: "100G LR4 for Cisco Nexus" → suggests switch + transceiver combos
|
|
*/
|
|
finderRouter.get("/suggest", async (req, res) => {
|
|
try {
|
|
const { q } = req.query;
|
|
if (!q) return res.status(400).json({ error: "Parameter 'q' is required" });
|
|
|
|
// Extract speed, form factor, vendor hints from query
|
|
const queryStr = (q as string).toLowerCase();
|
|
let speed: number | null = null;
|
|
let vendor: string | null = null;
|
|
let reach: string | null = null;
|
|
|
|
// Speed detection
|
|
const speedMatch = queryStr.match(/(\d+)\s*g\b/i);
|
|
if (speedMatch) speed = parseInt(speedMatch[1]!);
|
|
|
|
// Reach detection
|
|
if (queryStr.includes('sr')) reach = 'SR';
|
|
else if (queryStr.includes('lr')) reach = 'LR';
|
|
else if (queryStr.includes('er')) reach = 'ER';
|
|
else if (queryStr.includes('zr')) reach = 'ZR';
|
|
else if (queryStr.includes('dr')) reach = 'DR';
|
|
|
|
// Vendor detection
|
|
const vendorPatterns: [RegExp, string][] = [
|
|
[/cisco|nexus|catalyst/i, 'Cisco'],
|
|
[/juniper|qfx|ex\d{4}/i, 'Juniper'],
|
|
[/arista|dcs-/i, 'Arista'],
|
|
[/dell|powerswitch/i, 'Dell'],
|
|
[/hpe|aruba/i, 'HPE'],
|
|
];
|
|
for (const [pattern, name] of vendorPatterns) {
|
|
if (pattern.test(queryStr)) { vendor = name; break; }
|
|
}
|
|
|
|
// Search switches matching the query
|
|
const switches = await pool.query(
|
|
`SELECT sw.id, sw.model, sw.series, sw.max_speed_gbps, v.name AS vendor_name
|
|
FROM switches sw JOIN vendors v ON sw.vendor_id = v.id
|
|
WHERE sw.search_vector @@ plainto_tsquery('english', $1)
|
|
${vendor ? `AND v.name ILIKE '%' || $2 || '%'` : ''}
|
|
ORDER BY sw.max_speed_gbps DESC LIMIT 10`,
|
|
vendor ? [q, vendor] : [q]
|
|
);
|
|
|
|
// Search transceivers matching speed/reach
|
|
let tcvrSql = `SELECT t.id, t.slug, t.form_factor, t.speed_gbps, t.reach_label, t.fiber_type,
|
|
tv.name AS vendor, t.image_url
|
|
FROM transceivers t JOIN vendors v ON t.vendor_id = v.id JOIN vendors tv ON t.vendor_id = tv.id
|
|
WHERE 1=1`;
|
|
const tcvrParams: any[] = [];
|
|
let tidx = 1;
|
|
if (speed) { tcvrSql += ` AND t.speed_gbps = $${tidx}`; tcvrParams.push(speed); tidx++; }
|
|
if (reach) { tcvrSql += ` AND t.reach_label ILIKE $${tidx}`; tcvrParams.push(reach + '%'); tidx++; }
|
|
tcvrSql += ` ORDER BY t.speed_gbps DESC LIMIT 10`;
|
|
|
|
const transceivers = await pool.query(tcvrSql, tcvrParams);
|
|
|
|
res.json({
|
|
query: q,
|
|
parsed: { speed, vendor, reach },
|
|
switches: switches.rows,
|
|
transceivers: transceivers.rows,
|
|
tip: "Use GET /api/finder?switch=<model> for detailed compatibility results",
|
|
});
|
|
} catch (err) {
|
|
console.error("Suggest error:", err);
|
|
res.status(500).json({ error: "Internal server error" });
|
|
}
|
|
});
|