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
348 lines
13 KiB
SQL
348 lines
13 KiB
SQL
-- Migration 013: v0.2.0 Sales Intelligence Engine
|
|
-- Adds: competitor_alerts, price_changes, image tracking, finder views, blog_posts_v2, forecast tables
|
|
|
|
-- ============================================================
|
|
-- IMAGE TRACKING (WS0)
|
|
-- ============================================================
|
|
|
|
-- Add image columns if not exist
|
|
DO $$ BEGIN
|
|
ALTER TABLE transceivers ADD COLUMN IF NOT EXISTS image_url TEXT;
|
|
ALTER TABLE transceivers ADD COLUMN IF NOT EXISTS image_r2_key TEXT;
|
|
ALTER TABLE transceivers ADD COLUMN IF NOT EXISTS image_thumb_r2_key TEXT;
|
|
ALTER TABLE transceivers ADD COLUMN IF NOT EXISTS image_scraped_at TIMESTAMPTZ;
|
|
ALTER TABLE transceivers ADD COLUMN IF NOT EXISTS has_image BOOLEAN DEFAULT FALSE;
|
|
EXCEPTION WHEN duplicate_column THEN NULL;
|
|
END $$;
|
|
|
|
DO $$ BEGIN
|
|
ALTER TABLE switches ADD COLUMN IF NOT EXISTS image_thumb_r2_key TEXT;
|
|
ALTER TABLE switches ADD COLUMN IF NOT EXISTS has_image BOOLEAN DEFAULT FALSE;
|
|
EXCEPTION WHEN duplicate_column THEN NULL;
|
|
END $$;
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_transceivers_has_image ON transceivers(has_image) WHERE has_image = false;
|
|
|
|
-- ============================================================
|
|
-- PRICE COVERAGE (WS0b)
|
|
-- ============================================================
|
|
|
|
-- View: products missing recent prices
|
|
CREATE OR REPLACE VIEW v_price_coverage AS
|
|
SELECT
|
|
t.id,
|
|
t.slug,
|
|
t.form_factor,
|
|
t.speed_gbps,
|
|
t.reach_label,
|
|
v.name AS vendor_name,
|
|
(SELECT MAX(po.time) FROM price_observations po WHERE po.transceiver_id = t.id) AS last_price_at,
|
|
(SELECT COUNT(*) FROM price_observations po WHERE po.transceiver_id = t.id AND po.time > NOW() - INTERVAL '7 days') AS recent_price_count,
|
|
CASE
|
|
WHEN (SELECT COUNT(*) FROM price_observations po WHERE po.transceiver_id = t.id AND po.time > NOW() - INTERVAL '7 days') > 0 THEN TRUE
|
|
ELSE FALSE
|
|
END AS has_recent_price
|
|
FROM transceivers t
|
|
LEFT JOIN vendors v ON t.vendor_id = v.id
|
|
ORDER BY has_recent_price ASC, t.speed_gbps DESC;
|
|
|
|
-- View: image coverage
|
|
CREATE OR REPLACE VIEW v_image_coverage AS
|
|
SELECT
|
|
t.id,
|
|
t.slug,
|
|
t.form_factor,
|
|
t.speed_gbps,
|
|
t.image_url,
|
|
t.image_r2_key,
|
|
t.has_image,
|
|
v.name AS vendor_name
|
|
FROM transceivers t
|
|
LEFT JOIN vendors v ON t.vendor_id = v.id
|
|
ORDER BY t.has_image ASC, t.speed_gbps DESC;
|
|
|
|
-- ============================================================
|
|
-- COMPETITOR INTELLIGENCE (WS4)
|
|
-- ============================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS competitor_alerts (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
vendor_id UUID REFERENCES vendors(id),
|
|
transceiver_id UUID REFERENCES transceivers(id),
|
|
|
|
alert_type TEXT NOT NULL CHECK (alert_type IN (
|
|
'new_product', 'price_drop', 'price_increase', 'out_of_stock',
|
|
'back_in_stock', 'discontinued', 'new_vendor'
|
|
)),
|
|
severity TEXT DEFAULT 'info' CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info')),
|
|
|
|
-- Price change details
|
|
old_price NUMERIC,
|
|
new_price NUMERIC,
|
|
price_delta NUMERIC, -- absolute change
|
|
price_pct NUMERIC, -- percentage change
|
|
currency TEXT DEFAULT 'USD',
|
|
|
|
-- Product details
|
|
part_number TEXT,
|
|
product_name TEXT,
|
|
form_factor TEXT,
|
|
speed_gbps NUMERIC,
|
|
source_url TEXT,
|
|
|
|
-- Status
|
|
acknowledged BOOLEAN DEFAULT FALSE,
|
|
notes TEXT,
|
|
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_competitor_alerts_type ON competitor_alerts(alert_type);
|
|
CREATE INDEX IF NOT EXISTS idx_competitor_alerts_vendor ON competitor_alerts(vendor_id);
|
|
CREATE INDEX IF NOT EXISTS idx_competitor_alerts_created ON competitor_alerts(created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_competitor_alerts_unack ON competitor_alerts(acknowledged) WHERE acknowledged = FALSE;
|
|
CREATE INDEX IF NOT EXISTS idx_competitor_alerts_severity ON competitor_alerts(severity);
|
|
|
|
-- Price change history (deduplicated, one row per actual change)
|
|
CREATE TABLE IF NOT EXISTS price_changes (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
transceiver_id UUID REFERENCES transceivers(id),
|
|
vendor_id UUID REFERENCES vendors(id),
|
|
|
|
old_price NUMERIC NOT NULL,
|
|
new_price NUMERIC NOT NULL,
|
|
delta NUMERIC NOT NULL, -- new - old
|
|
delta_pct NUMERIC NOT NULL, -- ((new-old)/old) * 100
|
|
currency TEXT DEFAULT 'USD',
|
|
|
|
detected_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_price_changes_transceiver ON price_changes(transceiver_id, detected_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_price_changes_vendor ON price_changes(vendor_id, detected_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_price_changes_detected ON price_changes(detected_at DESC);
|
|
|
|
-- ============================================================
|
|
-- FINDER: FLEXOPTIX PRODUCT MAPPING (WS1)
|
|
-- ============================================================
|
|
|
|
-- Map OEM part numbers to Flexoptix products
|
|
CREATE TABLE IF NOT EXISTS flexoptix_product_map (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
oem_part_number TEXT NOT NULL,
|
|
oem_vendor TEXT NOT NULL,
|
|
flexoptix_sku TEXT,
|
|
flexoptix_url TEXT,
|
|
flexoptix_price_eur NUMERIC,
|
|
form_factor TEXT,
|
|
speed_gbps NUMERIC,
|
|
reach_label TEXT,
|
|
fiber_type TEXT,
|
|
match_type TEXT DEFAULT 'exact' CHECK (match_type IN ('exact', 'equivalent', 'compatible', 'suggested')),
|
|
last_verified TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
UNIQUE(oem_part_number, oem_vendor)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_flexoptix_map_oem ON flexoptix_product_map(oem_part_number);
|
|
CREATE INDEX IF NOT EXISTS idx_flexoptix_map_vendor ON flexoptix_product_map(oem_vendor);
|
|
CREATE INDEX IF NOT EXISTS idx_flexoptix_map_ff ON flexoptix_product_map(form_factor, speed_gbps);
|
|
|
|
-- Finder view: switch → compatible Flexoptix products
|
|
CREATE OR REPLACE VIEW v_switch_flexoptix_finder AS
|
|
SELECT
|
|
sw.id AS switch_id,
|
|
sw.model AS switch_model,
|
|
sw.series AS switch_series,
|
|
sv.name AS switch_vendor,
|
|
c.status AS compat_status,
|
|
c.firmware_min,
|
|
c.notes AS compat_notes,
|
|
t.id AS transceiver_id,
|
|
t.slug AS transceiver_slug,
|
|
t.form_factor,
|
|
t.speed_gbps,
|
|
t.reach_label,
|
|
t.fiber_type,
|
|
t.wavelengths,
|
|
t.connector,
|
|
t.image_url AS transceiver_image,
|
|
fpm.flexoptix_sku,
|
|
fpm.flexoptix_url,
|
|
fpm.flexoptix_price_eur,
|
|
fpm.match_type,
|
|
(SELECT po.price FROM price_observations po WHERE po.transceiver_id = t.id ORDER BY po.time DESC LIMIT 1) AS latest_price,
|
|
(SELECT po.currency FROM price_observations po WHERE po.transceiver_id = t.id ORDER BY po.time DESC LIMIT 1) AS latest_currency
|
|
FROM switches sw
|
|
JOIN vendors sv ON sw.vendor_id = sv.id
|
|
JOIN compatibility c ON c.switch_id = sw.id AND c.status = 'compatible'
|
|
JOIN transceivers t ON c.transceiver_id = t.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
|
|
);
|
|
|
|
-- ============================================================
|
|
-- BLOG ENGINE v2 (WS8)
|
|
-- ============================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS blog_series (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
title TEXT NOT NULL,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
description TEXT,
|
|
total_parts INTEGER DEFAULT 1,
|
|
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed', 'paused')),
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Add v2 columns to existing blog_drafts if they exist
|
|
DO $$ BEGIN
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS series_id UUID REFERENCES blog_series(id);
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS series_part INTEGER;
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS seo_title TEXT;
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS seo_description TEXT;
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS seo_slug TEXT;
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS seo_focus_keyword TEXT;
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS seo_score INTEGER;
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS readability_score NUMERIC;
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS hero_image_url TEXT;
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS hero_image_r2_key TEXT;
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS related_products UUID[];
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS related_switches UUID[];
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS competitor_data JSONB;
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS pricing_data JSONB;
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS export_markdown TEXT;
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS export_html TEXT;
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS published_at TIMESTAMPTZ;
|
|
ALTER TABLE blog_drafts ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ;
|
|
EXCEPTION WHEN duplicate_column THEN NULL;
|
|
END $$;
|
|
|
|
-- ============================================================
|
|
-- SALES FORECAST (WS5)
|
|
-- ============================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS sales_forecasts (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
technology TEXT NOT NULL, -- "400G QSFP-DD", "100G QSFP28", etc.
|
|
speed_gbps NUMERIC,
|
|
form_factor TEXT,
|
|
|
|
-- Forecast periods
|
|
forecast_3m_units INTEGER,
|
|
forecast_3m_revenue NUMERIC,
|
|
forecast_9m_units INTEGER,
|
|
forecast_9m_revenue NUMERIC,
|
|
forecast_12m_units INTEGER,
|
|
forecast_12m_revenue NUMERIC,
|
|
forecast_18m_units INTEGER,
|
|
forecast_18m_revenue NUMERIC,
|
|
|
|
-- Price trajectory
|
|
current_asp NUMERIC,
|
|
asp_3m NUMERIC,
|
|
asp_12m NUMERIC,
|
|
price_floor NUMERIC,
|
|
months_to_floor INTEGER,
|
|
|
|
-- Confidence
|
|
confidence_3m NUMERIC,
|
|
confidence_9m NUMERIC,
|
|
confidence_12m NUMERIC,
|
|
confidence_18m NUMERIC,
|
|
|
|
-- Buy signal
|
|
buy_signal TEXT CHECK (buy_signal IN ('BUY_NOW', 'WAIT', 'HOLD')),
|
|
signal_reason TEXT,
|
|
|
|
-- Model info
|
|
model_version TEXT DEFAULT 'norton-bass-v1',
|
|
data_points INTEGER, -- how many price observations used
|
|
|
|
computed_at TIMESTAMPTZ DEFAULT NOW(),
|
|
valid_until TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days'
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_forecasts_tech ON sales_forecasts(technology);
|
|
CREATE INDEX IF NOT EXISTS idx_forecasts_computed ON sales_forecasts(computed_at DESC);
|
|
|
|
-- ============================================================
|
|
-- TRANSPORT PLANNER (WS3)
|
|
-- ============================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS fiber_providers (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name TEXT NOT NULL UNIQUE,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
website TEXT,
|
|
type TEXT CHECK (type IN ('tier1', 'tier2', 'regional', 'municipal', 'hyperscaler')),
|
|
headquarters TEXT,
|
|
coverage_countries TEXT[],
|
|
products TEXT[], -- 'dark_fiber', 'wavelength', 'ip_transit', 'ethernet'
|
|
peering_ixs TEXT[], -- IX names where they peer
|
|
notes TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS fiber_routes (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
provider_id UUID REFERENCES fiber_providers(id),
|
|
city_a TEXT NOT NULL,
|
|
city_b TEXT NOT NULL,
|
|
country TEXT DEFAULT 'DE',
|
|
distance_km NUMERIC,
|
|
fiber_distance_km NUMERIC, -- actual fiber route (usually 1.3-1.5x straight line)
|
|
product_type TEXT, -- 'dark_fiber', 'wavelength_100g', 'wavelength_400g', etc.
|
|
monthly_price_eur NUMERIC,
|
|
setup_fee_eur NUMERIC,
|
|
min_contract_months INTEGER,
|
|
latency_ms NUMERIC,
|
|
available BOOLEAN DEFAULT TRUE,
|
|
notes TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
UNIQUE(provider_id, city_a, city_b, product_type)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS cities (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name TEXT NOT NULL,
|
|
country TEXT NOT NULL DEFAULT 'DE',
|
|
lat NUMERIC,
|
|
lon NUMERIC,
|
|
has_ix BOOLEAN DEFAULT FALSE,
|
|
ix_names TEXT[],
|
|
has_datacenter BOOLEAN DEFAULT FALSE,
|
|
population INTEGER,
|
|
UNIQUE(name, country)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_fiber_routes_cities ON fiber_routes(city_a, city_b);
|
|
CREATE INDEX IF NOT EXISTS idx_cities_country ON cities(country);
|
|
|
|
-- ============================================================
|
|
-- GENERATED DATASHEETS (WS2)
|
|
-- ============================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS generated_datasheets (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
entity_type TEXT NOT NULL CHECK (entity_type IN ('transceiver', 'switch', 'comparison', 'compatibility_matrix')),
|
|
entity_id UUID,
|
|
entity_ids UUID[], -- for comparison datasheets
|
|
branding TEXT DEFAULT 'flexoptix',
|
|
format TEXT DEFAULT 'pdf',
|
|
|
|
r2_key TEXT,
|
|
r2_url TEXT,
|
|
file_size_bytes BIGINT,
|
|
|
|
generated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '30 days'
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_datasheets_entity ON generated_datasheets(entity_type, entity_id);
|