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