New API routes: - GET /api/finder — Switch→Flexoptix transceiver finder with FlexBox coding - GET /api/competitor-alerts — Competitor intelligence (price changes, new products, stock) - GET /api/forecast/:technology — Sales forecast 3/9/12/18 months + buy/wait/hold signal - POST /api/transport/plan — Transport system planner (city→city BOM with fiber providers) New MCP tools: - find_flexoptix_for_switch — Customer switch → Flexoptix products - get_competitor_alerts — Competitor monitoring - plan_transport — Network transport planning - forecast_sales — Volume/revenue prediction - generate_blog — Enhanced blog generation New DB tables (migration 013): - competitor_alerts, price_changes, flexoptix_product_map - sales_forecasts, fiber_providers, fiber_routes, cities - generated_datasheets, blog_series - Views: v_price_coverage, v_image_coverage, v_switch_flexoptix_finder Seed data (migration 014): - 25 European cities with IX/DC locations + coordinates - 15 fiber providers (euNetworks, Telia, DTAG, Colt, Zayo, etc.) - 16 fiber routes with pricing (Germany focus) Infrastructure: - Scraper scheduler: 2h Flexoptix, 4h FS.com/Optcore (was 6-8h) - Change detector for competitor price/stock monitoring - Image downloader utility with coverage tracking
161 lines
5.3 KiB
TypeScript
161 lines
5.3 KiB
TypeScript
/**
|
|
* WS4: Competitor Intelligence — Alerts & Price Changes
|
|
*/
|
|
import { Router } from "express";
|
|
import { pool } from "../db/client";
|
|
|
|
export const competitorRouter = Router();
|
|
|
|
/**
|
|
* GET /api/competitor-alerts?vendor=&type=&severity=&days=&limit=&offset=
|
|
*/
|
|
competitorRouter.get("/", async (req, res) => {
|
|
try {
|
|
const {
|
|
vendor, type, severity, days = "7",
|
|
acknowledged, limit = "50", offset = "0"
|
|
} = req.query;
|
|
|
|
let sql = `
|
|
SELECT ca.*,
|
|
v.name AS vendor_name,
|
|
v.slug AS vendor_slug
|
|
FROM competitor_alerts ca
|
|
LEFT JOIN vendors v ON ca.vendor_id = v.id
|
|
WHERE ca.created_at > NOW() - INTERVAL '1 day' * $1
|
|
`;
|
|
const params: any[] = [parseInt(days as string)];
|
|
let idx = 2;
|
|
|
|
if (vendor) { sql += ` AND v.slug = $${idx}`; params.push(vendor); idx++; }
|
|
if (type) { sql += ` AND ca.alert_type = $${idx}`; params.push(type); idx++; }
|
|
if (severity) { sql += ` AND ca.severity = $${idx}`; params.push(severity); idx++; }
|
|
if (acknowledged === 'false') { sql += ` AND ca.acknowledged = false`; }
|
|
|
|
sql += ` ORDER BY ca.created_at DESC LIMIT $${idx} OFFSET $${idx + 1}`;
|
|
params.push(parseInt(limit as string), parseInt(offset as string));
|
|
|
|
const result = await pool.query(sql, params);
|
|
|
|
// Summary stats
|
|
const stats = await pool.query(`
|
|
SELECT
|
|
alert_type,
|
|
COUNT(*) AS count,
|
|
COUNT(*) FILTER (WHERE acknowledged = false) AS unread
|
|
FROM competitor_alerts
|
|
WHERE created_at > NOW() - INTERVAL '1 day' * $1
|
|
GROUP BY alert_type
|
|
ORDER BY count DESC
|
|
`, [parseInt(days as string)]);
|
|
|
|
res.json({
|
|
alerts: result.rows,
|
|
total: result.rowCount,
|
|
stats: stats.rows,
|
|
period_days: parseInt(days as string),
|
|
});
|
|
} catch (err) {
|
|
console.error("Competitor alerts error:", err);
|
|
res.status(500).json({ error: "Internal server error" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/competitor-alerts/price-changes?vendor=&speed=&days=
|
|
*/
|
|
competitorRouter.get("/price-changes", async (req, res) => {
|
|
try {
|
|
const { vendor, speed, days = "30", limit = "50" } = req.query;
|
|
|
|
let sql = `
|
|
SELECT pc.*,
|
|
v.name AS vendor_name,
|
|
t.slug, t.form_factor, t.speed_gbps, t.reach_label
|
|
FROM price_changes pc
|
|
JOIN vendors v ON pc.vendor_id = v.id
|
|
JOIN transceivers t ON pc.transceiver_id = t.id
|
|
WHERE pc.detected_at > NOW() - INTERVAL '1 day' * $1
|
|
`;
|
|
const params: any[] = [parseInt(days as string)];
|
|
let idx = 2;
|
|
|
|
if (vendor) { sql += ` AND v.slug = $${idx}`; params.push(vendor); idx++; }
|
|
if (speed) { sql += ` AND t.speed_gbps = $${idx}`; params.push(parseFloat(speed as string)); idx++; }
|
|
|
|
sql += ` ORDER BY ABS(pc.delta_pct) DESC LIMIT $${idx}`;
|
|
params.push(parseInt(limit as string));
|
|
|
|
const result = await pool.query(sql, params);
|
|
res.json({ price_changes: result.rows, total: result.rowCount });
|
|
} catch (err) {
|
|
console.error("Price changes error:", err);
|
|
res.status(500).json({ error: "Internal server error" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PUT /api/competitor-alerts/:id/acknowledge
|
|
*/
|
|
competitorRouter.put("/:id/acknowledge", async (req, res) => {
|
|
try {
|
|
const { notes } = req.body || {};
|
|
await pool.query(
|
|
`UPDATE competitor_alerts SET acknowledged = true, notes = COALESCE($2, notes) WHERE id = $1`,
|
|
[req.params.id, notes]
|
|
);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
res.status(500).json({ error: "Internal server error" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/competitor-alerts/summary
|
|
*
|
|
* High-level competitor intelligence overview
|
|
*/
|
|
competitorRouter.get("/summary", async (req, res) => {
|
|
try {
|
|
const [alertsByVendor, recentDrops, newProducts, coverage] = await Promise.all([
|
|
pool.query(`
|
|
SELECT v.name, v.slug, COUNT(*) AS alert_count,
|
|
COUNT(*) FILTER (WHERE ca.alert_type = 'price_drop') AS drops,
|
|
COUNT(*) FILTER (WHERE ca.alert_type = 'price_increase') AS increases,
|
|
COUNT(*) FILTER (WHERE ca.alert_type = 'new_product') AS new_products
|
|
FROM competitor_alerts ca
|
|
JOIN vendors v ON ca.vendor_id = v.id
|
|
WHERE ca.created_at > NOW() - INTERVAL '7 days'
|
|
GROUP BY v.name, v.slug ORDER BY alert_count DESC LIMIT 20
|
|
`),
|
|
pool.query(`
|
|
SELECT pc.*, v.name AS vendor_name, t.form_factor, t.speed_gbps, t.reach_label
|
|
FROM price_changes pc
|
|
JOIN vendors v ON pc.vendor_id = v.id
|
|
JOIN transceivers t ON pc.transceiver_id = t.id
|
|
WHERE pc.delta_pct < -5 AND pc.detected_at > NOW() - INTERVAL '7 days'
|
|
ORDER BY pc.delta_pct ASC LIMIT 10
|
|
`),
|
|
pool.query(`
|
|
SELECT ca.*, v.name AS vendor_name
|
|
FROM competitor_alerts ca
|
|
JOIN vendors v ON ca.vendor_id = v.id
|
|
WHERE ca.alert_type = 'new_product' AND ca.created_at > NOW() - INTERVAL '30 days'
|
|
ORDER BY ca.created_at DESC LIMIT 20
|
|
`),
|
|
pool.query(`SELECT * FROM v_price_coverage WHERE has_recent_price = false LIMIT 20`),
|
|
]);
|
|
|
|
res.json({
|
|
period: "7 days",
|
|
by_vendor: alertsByVendor.rows,
|
|
biggest_price_drops: recentDrops.rows,
|
|
new_competitor_products: newProducts.rows,
|
|
products_missing_prices: coverage.rows,
|
|
});
|
|
} catch (err) {
|
|
console.error("Summary error:", err);
|
|
res.status(500).json({ error: "Internal server error" });
|
|
}
|
|
});
|