CREATE TABLE IF NOT EXISTS raw_mqtt_events ( id BIGSERIAL PRIMARY KEY, received_at TIMESTAMPTZ NOT NULL DEFAULT now(), source_system TEXT NOT NULL, network_name TEXT, modem_preset TEXT, broker_host TEXT, broker_port INTEGER, topic TEXT NOT NULL, qos INTEGER, retain BOOLEAN, payload_raw BYTEA, payload_text TEXT, payload_json JSONB, parse_status TEXT NOT NULL DEFAULT 'raw', parser_error TEXT ); CREATE INDEX IF NOT EXISTS idx_raw_mqtt_events_received_at ON raw_mqtt_events(received_at DESC); CREATE INDEX IF NOT EXISTS idx_raw_mqtt_events_source_received ON raw_mqtt_events(source_system, received_at DESC); CREATE INDEX IF NOT EXISTS idx_raw_mqtt_events_topic ON raw_mqtt_events(topic); CREATE TABLE IF NOT EXISTS normalized_events ( id BIGSERIAL PRIMARY KEY, raw_event_id BIGINT REFERENCES raw_mqtt_events(id) ON DELETE CASCADE, event_time TIMESTAMPTZ NOT NULL DEFAULT now(), source_system TEXT NOT NULL, network_name TEXT, modem_preset TEXT, topic TEXT NOT NULL, from_node_id TEXT, to_node_id TEXT, message_type TEXT, text_body TEXT, rssi NUMERIC, snr NUMERIC, payload JSONB ); CREATE INDEX IF NOT EXISTS idx_normalized_events_event_time ON normalized_events(event_time DESC); CREATE INDEX IF NOT EXISTS idx_normalized_events_source_time ON normalized_events(source_system, event_time DESC); CREATE INDEX IF NOT EXISTS idx_normalized_events_from_node ON normalized_events(source_system, from_node_id); CREATE INDEX IF NOT EXISTS idx_normalized_events_message_type ON normalized_events(message_type); CREATE TABLE IF NOT EXISTS nodes_seen ( id BIGSERIAL PRIMARY KEY, source_system TEXT NOT NULL, node_id TEXT NOT NULL, first_seen TIMESTAMPTZ NOT NULL DEFAULT now(), last_seen TIMESTAMPTZ NOT NULL DEFAULT now(), metadata JSONB, UNIQUE(source_system, node_id) ); CREATE INDEX IF NOT EXISTS idx_nodes_seen_last_seen ON nodes_seen(last_seen DESC); CREATE OR REPLACE VIEW v_packets_per_hour AS SELECT date_trunc('hour', event_time) AS hour, source_system, network_name, modem_preset, COUNT(*) AS packets FROM normalized_events GROUP BY 1, 2, 3, 4 ORDER BY 1 DESC; CREATE OR REPLACE VIEW v_text_messages_per_day AS SELECT date_trunc('day', event_time) AS day, source_system, network_name, modem_preset, COUNT(*) AS text_messages FROM normalized_events WHERE message_type = 'text' GROUP BY 1, 2, 3, 4 ORDER BY 1 DESC; CREATE OR REPLACE VIEW v_unique_nodes_per_day AS SELECT date_trunc('day', event_time) AS day, source_system, network_name, modem_preset, COUNT(DISTINCT from_node_id) AS unique_nodes FROM normalized_events WHERE from_node_id IS NOT NULL GROUP BY 1, 2, 3, 4 ORDER BY 1 DESC; CREATE OR REPLACE VIEW v_utilization_hourly AS SELECT date_trunc('hour', event_time) AS hour, CASE WHEN source_system = 'meshtastic' AND modem_preset IS NOT NULL AND modem_preset <> '' THEN 'Meshtastic ' || modem_preset WHEN source_system = 'meshtastic' THEN 'Meshtastic' WHEN source_system = 'meshcore' THEN 'MeshCore' ELSE source_system END AS network, COUNT(*) AS packets, COUNT(*) FILTER (WHERE message_type = 'text') AS text_messages, COUNT(DISTINCT from_node_id) AS unique_nodes FROM normalized_events GROUP BY 1, 2 ORDER BY 1 DESC; CREATE OR REPLACE VIEW v_top_talkers_24h AS SELECT source_system, network_name, modem_preset, from_node_id, COUNT(*) AS packets, COUNT(*) FILTER (WHERE message_type = 'text') AS text_messages, MAX(event_time) AS last_seen FROM normalized_events WHERE event_time >= now() - interval '24 hours' AND from_node_id IS NOT NULL GROUP BY 1, 2, 3, 4 ORDER BY packets DESC;