/** * Price Alert Subscriptions — /api/price-alerts * * Users subscribe to price thresholds for specific SKUs or form factor/speed combos. * A background checker (called by the scraper scheduler) evaluates active subscriptions * against the latest price_observations and queues email delivery. * * Routes: * POST /api/price-alerts — Create subscription * GET /api/price-alerts?email= — List subscriptions for an email * DELETE /api/price-alerts/:id — Cancel subscription * POST /api/price-alerts/check — Internal: evaluate + queue alerts (scheduler) * GET /api/price-alerts/triggered — Recent triggered alerts */ import { Router, Request, Response } from "express"; import { pool } from "../db/client"; export const priceAlertsRouter = Router(); // ── POST /api/price-alerts — Create a price alert subscription ─────────────── priceAlertsRouter.post("/", async (req: Request, res: Response) => { const { email, transceiver_id, form_factor, speed_gbps, threshold_price, currency = "USD", direction = "below", vendor_id, } = req.body as Record; if (!email || typeof email !== "string" || !email.includes("@")) { return res.status(400).json({ success: false, error: "Valid email required" }); } if (!threshold_price || isNaN(parseFloat(threshold_price))) { return res.status(400).json({ success: false, error: "threshold_price required" }); } if (!transceiver_id && !form_factor && !speed_gbps) { return res.status(400).json({ success: false, error: "At least one of: transceiver_id, form_factor, speed_gbps" }); } try { const result = await pool.query( `INSERT INTO price_alert_subscriptions (email, transceiver_id, form_factor, speed_gbps, threshold_price, currency, direction, vendor_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, threshold_price, currency, direction, created_at`, [ email.toLowerCase().trim(), transceiver_id || null, form_factor || null, speed_gbps ? parseFloat(speed_gbps) : null, parseFloat(threshold_price), currency.toUpperCase(), direction, vendor_id || null, ] ); return res.status(201).json({ success: true, subscription: result.rows[0] }); } catch (err) { return res.status(500).json({ success: false, error: String(err) }); } }); // ── GET /api/price-alerts?email= — List subscriptions ──────────────────────── priceAlertsRouter.get("/", async (req: Request, res: Response) => { const email = String(Array.isArray(req.query.email) ? req.query.email[0] ?? "" : req.query.email ?? "").trim().toLowerCase(); if (!email) return res.status(400).json({ success: false, error: "email required" }); try { const result = await pool.query( `SELECT pas.*, t.standard_name, t.form_factor AS tx_form_factor, t.speed_gbps AS tx_speed, v.name AS vendor_name FROM price_alert_subscriptions pas LEFT JOIN transceivers t ON t.id = pas.transceiver_id LEFT JOIN vendors v ON v.id = pas.vendor_id WHERE pas.email = $1 ORDER BY pas.created_at DESC`, [email] ); return res.json({ success: true, subscriptions: result.rows }); } catch (err) { return res.status(500).json({ success: false, error: String(err) }); } }); // ── DELETE /api/price-alerts/:id — Cancel subscription ─────────────────────── priceAlertsRouter.delete("/:id", async (req: Request, res: Response) => { const id = String(req.params.id); const email = String(Array.isArray(req.query.email) ? req.query.email[0] ?? "" : req.query.email ?? "").trim().toLowerCase(); try { const result = await pool.query( `UPDATE price_alert_subscriptions SET active = false WHERE id = $1 AND ($2 = '' OR email = $2) RETURNING id`, [parseInt(id), email] ); if (result.rowCount === 0) { return res.status(404).json({ success: false, error: "Subscription not found" }); } return res.json({ success: true, cancelled: parseInt(id) }); } catch (err) { return res.status(500).json({ success: false, error: String(err) }); } }); // ── GET /api/price-alerts/triggered — Recent triggered alerts ───────────────── priceAlertsRouter.get("/triggered", async (_req: Request, res: Response) => { try { const result = await pool.query(` SELECT pal.*, t.standard_name, t.form_factor, v.name AS vendor_name FROM price_alert_log pal LEFT JOIN transceivers t ON t.id = pal.transceiver_id LEFT JOIN vendors v ON v.id = pal.vendor_id ORDER BY pal.created_at DESC LIMIT 100 `); return res.json({ success: true, alerts: result.rows }); } catch (err) { return res.status(500).json({ success: false, error: String(err) }); } }); // ── POST /api/price-alerts/check — Evaluate all active subscriptions ────────── // Called by the scraper scheduler periodically. Finds triggered conditions, // inserts into price_alert_log, and marks last_triggered on subscription. priceAlertsRouter.post("/check", async (_req: Request, res: Response) => { try { // Find subscriptions where latest price crosses the threshold const triggered = await pool.query(` WITH latest_prices AS ( SELECT DISTINCT ON (po.transceiver_id, po.source_vendor_id) po.transceiver_id, po.source_vendor_id AS vendor_id, po.price, po.currency, po.time FROM price_observations po WHERE po.price > 0 AND COALESCE(po.is_anomalous, false) = false ORDER BY po.transceiver_id, po.source_vendor_id, po.time DESC ), matched AS ( SELECT pas.id AS subscription_id, pas.email, pas.threshold_price, pas.currency, pas.direction, lp.transceiver_id, lp.vendor_id, lp.price AS triggered_price FROM price_alert_subscriptions pas JOIN latest_prices lp ON ( (pas.transceiver_id IS NULL OR lp.transceiver_id = pas.transceiver_id) AND lp.currency = pas.currency AND (pas.vendor_id IS NULL OR lp.vendor_id = pas.vendor_id) ) JOIN transceivers t ON t.id = lp.transceiver_id WHERE pas.active = true AND (pas.form_factor IS NULL OR t.form_factor = pas.form_factor) AND (pas.speed_gbps IS NULL OR t.speed_gbps = pas.speed_gbps) AND ( (pas.direction = 'below' AND lp.price < pas.threshold_price) OR (pas.direction = 'above' AND lp.price > pas.threshold_price) ) -- Don't re-trigger more than once per 24h per subscription AND (pas.last_triggered IS NULL OR pas.last_triggered < NOW() - INTERVAL '24 hours') ) SELECT * FROM matched LIMIT 200 `); let queued = 0; for (const row of triggered.rows) { await pool.query( `INSERT INTO price_alert_log (subscription_id, transceiver_id, vendor_id, triggered_price, threshold_price, currency, email, delivery_status) VALUES ($1,$2,$3,$4,$5,$6,$7,'pending')`, [row.subscription_id, row.transceiver_id, row.vendor_id, row.triggered_price, row.threshold_price, row.currency, row.email] ); await pool.query( `UPDATE price_alert_subscriptions SET last_triggered = NOW(), trigger_count = trigger_count + 1 WHERE id = $1`, [row.subscription_id] ); queued++; } return res.json({ success: true, checked: triggered.rowCount, queued }); } catch (err) { return res.status(500).json({ success: false, error: String(err) }); } });