Rene Fichtmueller 99778d8639 fix: Finder 404 shows helpful message + fuzzy switch name matching
- 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.
2026-04-01 22:17:07 +02:00

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" });
}
});