diff --git a/migrations/13_inc_search_fn.sql b/migrations/13_inc_search_fn.sql new file mode 100644 index 0000000..1949b4a --- /dev/null +++ b/migrations/13_inc_search_fn.sql @@ -0,0 +1,89 @@ +-- 13_inc_search_fn.sql — fleettickets · INC ticket explorer (search) function +-- ───────────────────────────────────────────────────────────────────────────── +-- reporting.fn_inc_search — ad-hoc ticket lookup by id / engineer / cluster / +-- status / state / time, for the FleetOps "Ticket explorer" card. Returns +-- { count, truncated, limit, state, rows }. Consumed by dashboard_api +-- GET /webhook/inc-search. +-- +-- RECOVERED INTO VERSION CONTROL 2026-06-26: this migration was applied to the live +-- DB on 2026-06-19 but the file was never committed. Recovered verbatim from the live +-- definition (pg_get_functiondef) so a fresh DB rebuilds faithfully; the live ledger +-- already lists it, so run_migrations skips it there. The crq mirror is in 16. +-- Idempotent (CREATE OR REPLACE). +-- ───────────────────────────────────────────────────────────────────────────── + +SET search_path = tickets, public; + +CREATE OR REPLACE FUNCTION reporting.fn_inc_search( + p_ticket_id text DEFAULT NULL, + p_owner text DEFAULT NULL, + p_cluster text DEFAULT NULL, + p_status text DEFAULT NULL, + p_state text DEFAULT 'closed', + p_from timestamptz DEFAULT NULL, + p_to timestamptz DEFAULT NULL, + p_limit integer DEFAULT 500 +) + RETURNS jsonb LANGUAGE plpgsql STABLE AS $function$ +DECLARE + v_state text := lower(COALESCE(NULLIF(p_state, ''), 'closed')); + v_limit integer := LEAST(GREATEST(COALESCE(p_limit, 500), 1), 5000); + v_result jsonb; +BEGIN + p_ticket_id := NULLIF(trim(p_ticket_id), ''); + p_owner := NULLIF(trim(p_owner), ''); + p_cluster := NULLIF(p_cluster, ''); + p_status := NULLIF(p_status, ''); + + WITH hits AS ( + SELECT ticket_id, normalized_status, cluster, region, location_name, + initcap(lower(NULLIF(owner, ''))) AS owner, assigned_team, + sla_status, mttr, closed_at, created_at_service, is_actionable, + CASE WHEN geom IS NOT NULL THEN ST_Y(geom) END AS lat, + CASE WHEN geom IS NOT NULL THEN ST_X(geom) END AS lng + FROM tickets.inc + WHERE (p_ticket_id IS NULL OR ticket_id ILIKE '%' || p_ticket_id || '%') + AND (p_owner IS NULL OR lower(owner) LIKE '%' || lower(p_owner) || '%') + AND (p_cluster IS NULL OR cluster = p_cluster) + AND (p_status IS NULL OR normalized_status = p_status) + AND CASE v_state + WHEN 'open' THEN COALESCE(is_actionable, false) + WHEN 'all' THEN COALESCE(is_actionable, false) + OR (closed_at IS NOT NULL + AND (p_from IS NULL OR closed_at >= p_from) + AND (p_to IS NULL OR closed_at < p_to)) + ELSE NOT COALESCE(is_actionable, false) -- 'closed' + AND closed_at IS NOT NULL + AND (p_from IS NULL OR closed_at >= p_from) + AND (p_to IS NULL OR closed_at < p_to) + END + ), + total AS (SELECT count(*) AS n FROM hits), + page AS ( + SELECT * FROM hits + ORDER BY closed_at DESC NULLS LAST, created_at_service DESC NULLS LAST + LIMIT v_limit + ) + SELECT jsonb_build_object( + 'count', (SELECT n FROM total), + 'truncated', (SELECT n FROM total) > v_limit, + 'limit', v_limit, + 'state', v_state, + 'rows', COALESCE((SELECT jsonb_agg(to_jsonb(page) + ORDER BY page.closed_at DESC NULLS LAST, + page.created_at_service DESC NULLS LAST) + FROM page), '[]'::jsonb) + ) INTO v_result; + + RETURN v_result; +END $function$; + +DO $grants$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dashboard_ro') THEN + GRANT EXECUTE ON FUNCTION reporting.fn_inc_search(text, text, text, text, text, timestamptz, timestamptz, integer) TO dashboard_ro; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN + GRANT EXECUTE ON FUNCTION reporting.fn_inc_search(text, text, text, text, text, timestamptz, timestamptz, integer) TO grafana_ro; + END IF; +END $grants$; diff --git a/migrations/14_inc_filter_options.sql b/migrations/14_inc_filter_options.sql new file mode 100644 index 0000000..336b105 --- /dev/null +++ b/migrations/14_inc_filter_options.sql @@ -0,0 +1,37 @@ +-- 14_inc_filter_options.sql — fleettickets · INC explorer dropdown options +-- ───────────────────────────────────────────────────────────────────────────── +-- reporting.fn_inc_filter_options — distinct engineers (owner), clusters, and the +-- ids of currently-open tickets, for the FleetOps "Ticket explorer" dropdowns. +-- Consumed by dashboard_api GET /webhook/inc-filter-options. +-- +-- RECOVERED INTO VERSION CONTROL 2026-06-26: applied to the live DB 2026-06-19 but +-- never committed. Recovered verbatim from the live definition so a fresh DB rebuilds +-- faithfully; the live ledger already lists it (run_migrations skips it there). The crq +-- mirror is in 16. Idempotent (CREATE OR REPLACE). +-- ───────────────────────────────────────────────────────────────────────────── + +SET search_path = tickets, public; + +CREATE OR REPLACE FUNCTION reporting.fn_inc_filter_options() + RETURNS jsonb LANGUAGE sql STABLE AS $function$ + SELECT jsonb_build_object( + 'owners', (SELECT COALESCE(jsonb_agg(o ORDER BY o), '[]'::jsonb) + FROM (SELECT DISTINCT initcap(lower(NULLIF(owner, ''))) AS o + FROM tickets.inc WHERE NULLIF(owner, '') IS NOT NULL) s), + 'clusters', (SELECT COALESCE(jsonb_agg(c ORDER BY c), '[]'::jsonb) + FROM (SELECT DISTINCT cluster AS c + FROM tickets.inc WHERE NULLIF(cluster, '') IS NOT NULL) s), + 'open_ticket_ids', (SELECT COALESCE(jsonb_agg(ticket_id ORDER BY ticket_id), '[]'::jsonb) + FROM tickets.inc WHERE COALESCE(is_actionable, false)) + ); +$function$; + +DO $grants$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dashboard_ro') THEN + GRANT EXECUTE ON FUNCTION reporting.fn_inc_filter_options() TO dashboard_ro; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN + GRANT EXECUTE ON FUNCTION reporting.fn_inc_filter_options() TO grafana_ro; + END IF; +END $grants$; diff --git a/migrations/16_crq_dashboard.sql b/migrations/16_crq_dashboard.sql new file mode 100644 index 0000000..ba760e7 --- /dev/null +++ b/migrations/16_crq_dashboard.sql @@ -0,0 +1,297 @@ +-- 16_crq_dashboard.sql — fleettickets · CRQ dashboard parity (view + read functions) +-- ───────────────────────────────────────────────────────────────────────────── +-- Brings CRQ to FleetOps-dashboard parity with INC, so the Tickets tab's CRQ +-- sub-tab works "just like INC". Mirrors, against tickets.crq: +-- tickets.crq_open_sla ← mirror of tickets.inc_open_sla (08) +-- reporting.fn_crq_dashboard ← mirror of reporting.fn_inc_dashboard (09/12) +-- reporting.fn_crq_search ← mirror of reporting.fn_inc_search (13) +-- reporting.fn_crq_filter_options ← mirror of reporting.fn_inc_filter_options (14) +-- consumed by dashboard_api GET /webhook/crq-dashboard | crq-search | crq-filter-options. +-- +-- Differences from the INC view: tickets.crq has no `geog` column (mig 05 is INC-only) +-- and its latitude/longitude come from `raw` (empty in the feed), so crq_open_sla omits +-- geog and derives latitude/longitude from `geom`. The 48h SLA rule is reused verbatim +-- for layout parity — installation-lifecycle SLA semantics may be refined later. +-- +-- Idempotent (CREATE OR REPLACE / VIEW). Requires migration 15 (tickets.crq + typed cols). +-- ───────────────────────────────────────────────────────────────────────────── + +SET search_path = tickets, public; + +-- ── crq_open_sla — open CRQ tickets with derived SLA (mirror of inc_open_sla) ─ +CREATE OR REPLACE VIEW tickets.crq_open_sla AS +SELECT + ticket_id, + normalized_status, + bucket, + cluster, + region, + location_name, + assigned_team, + owner, + sla_status AS source_sla_status, + mttr, -- minutes (null until closed) + COALESCE(created_at_service, first_seen_at) AS sla_clock, + CASE WHEN created_at_service IS NOT NULL THEN 'service' ELSE 'first_seen' END AS sla_clock_source, + round((EXTRACT(EPOCH FROM now() - COALESCE(created_at_service, first_seen_at)) / 3600)::numeric, 1) AS hours_open, + CASE + WHEN COALESCE(created_at_service, first_seen_at) IS NULL THEN 'unknown' + WHEN now() - COALESCE(created_at_service, first_seen_at) >= interval '48h' THEN 'breached' + WHEN now() - COALESCE(created_at_service, first_seen_at) >= interval '36h' THEN 'at_risk' + ELSE 'ok' + END AS sla_state, + created_at_service, + first_seen_at, + scheduled_at, + CASE WHEN geom IS NOT NULL THEN ST_Y(geom) END AS latitude, + CASE WHEN geom IS NOT NULL THEN ST_X(geom) END AS longitude, + geo_source, + geom +FROM tickets.crq +WHERE is_actionable; + +COMMENT ON VIEW tickets.crq_open_sla IS + 'Open (is_actionable) CRQ tickets with derived SLA (48h rule; clock = created_at_service ' + 'or first_seen_at fallback). Mirror of inc_open_sla; no geog. fleettickets 16.'; + +-- ── fn_crq_dashboard — mirror of fn_inc_dashboard over tickets.crq ─────────── +CREATE OR REPLACE FUNCTION reporting.fn_crq_dashboard( + p_cluster text DEFAULT NULL, + p_status text DEFAULT NULL, + p_window text DEFAULT 'today', + p_from timestamptz DEFAULT NULL, + p_to timestamptz DEFAULT NULL +) + RETURNS jsonb LANGUAGE plpgsql STABLE AS $function$ +DECLARE + v_now_eat timestamp; + v_from timestamptz; + v_to timestamptz; + v_preset text; + v_days numeric; + v_result jsonb; +BEGIN + p_cluster := NULLIF(p_cluster, ''); + p_status := NULLIF(p_status, ''); + v_now_eat := now() AT TIME ZONE 'Africa/Nairobi'; + + -- ── resolve the window ────────────────────────────────────────────────────── + IF p_from IS NOT NULL OR p_to IS NOT NULL THEN + v_preset := 'custom'; + v_from := COALESCE(p_from, '-infinity'::timestamptz); + v_to := COALESCE(p_to, 'infinity'::timestamptz); + ELSE + v_preset := lower(COALESCE(NULLIF(p_window, ''), 'today')); + IF v_preset = 'week' THEN + v_from := date_trunc('week', v_now_eat) AT TIME ZONE 'Africa/Nairobi'; + v_to := (date_trunc('week', v_now_eat) + interval '1 week') AT TIME ZONE 'Africa/Nairobi'; + ELSIF v_preset = 'month' THEN + v_from := date_trunc('month', v_now_eat) AT TIME ZONE 'Africa/Nairobi'; + v_to := (date_trunc('month', v_now_eat) + interval '1 month') AT TIME ZONE 'Africa/Nairobi'; + ELSE + v_preset := 'today'; + v_from := date_trunc('day', v_now_eat) AT TIME ZONE 'Africa/Nairobi'; + v_to := (date_trunc('day', v_now_eat) + interval '1 day') AT TIME ZONE 'Africa/Nairobi'; + END IF; + END IF; + + IF v_from > '-infinity'::timestamptz AND v_to < 'infinity'::timestamptz THEN + v_days := GREATEST(EXTRACT(EPOCH FROM (v_to - v_from)) / 86400.0, 1); + ELSE + v_days := NULL; -- open-ended custom window → per-day average not meaningful + END IF; + + -- ── build payload ─────────────────────────────────────────────────────────── + WITH open_t AS ( + SELECT * FROM tickets.crq_open_sla + WHERE (p_cluster IS NULL OR cluster = p_cluster) + AND (p_status IS NULL OR normalized_status = p_status) + ), + closed_t AS ( + SELECT ticket_id, normalized_status, cluster, region, location_name, + assigned_team, owner, closed_at, mttr, sla_status, geo_source, geom + FROM tickets.crq + WHERE NOT COALESCE(is_actionable, false) + AND closed_at IS NOT NULL + AND closed_at >= v_from AND closed_at < v_to + AND (p_cluster IS NULL OR cluster = p_cluster) + AND (p_status IS NULL OR normalized_status = p_status) + ) + SELECT jsonb_build_object( + 'window', jsonb_build_object('from', v_from, 'to', v_to, 'preset', v_preset), + + 'open', jsonb_build_object( + 'type', 'FeatureCollection', + 'features', COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'type', 'Feature', + 'properties', jsonb_build_object( + 'ticket_id', ticket_id, 'normalized_status', normalized_status, + 'cluster', cluster, 'region', region, 'location_name', location_name, + 'assigned_team', assigned_team, 'owner', initcap(lower(NULLIF(owner, ''))), + 'geo_source', geo_source, + 'sla_state', sla_state, 'hours_open', hours_open), + 'geometry', ST_AsGeoJSON(geom)::jsonb)) + FROM open_t WHERE geom IS NOT NULL), '[]'::jsonb) + ), + + 'closed', jsonb_build_object( + 'type', 'FeatureCollection', + 'features', COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'type', 'Feature', + 'properties', jsonb_build_object( + 'ticket_id', ticket_id, 'normalized_status', normalized_status, + 'cluster', cluster, 'region', region, 'location_name', location_name, + 'assigned_team', assigned_team, 'owner', initcap(lower(NULLIF(owner, ''))), + 'geo_source', geo_source, + 'closed_at', closed_at, 'mttr', mttr, 'sla_status', sla_status), + 'geometry', ST_AsGeoJSON(geom)::jsonb)) + FROM closed_t WHERE geom IS NOT NULL), '[]'::jsonb) + ), + + 'metrics', jsonb_build_object( + 'open_now', (SELECT count(*) FROM open_t), + 'closed_in_window', (SELECT count(*) FROM closed_t), + 'sla', jsonb_build_object( + 'open', (SELECT jsonb_build_object( + 'breached', count(*) FILTER (WHERE sla_state = 'breached'), + 'at_risk', count(*) FILTER (WHERE sla_state = 'at_risk'), + 'ok', count(*) FILTER (WHERE sla_state = 'ok'), + 'unknown', count(*) FILTER (WHERE sla_state = 'unknown')) FROM open_t), + 'closed', (SELECT jsonb_build_object( + 'compliant', count(*) FILTER (WHERE sla_status = 'Compliant'), + 'breached', count(*) FILTER (WHERE sla_status = 'Breached')) FROM closed_t) + ), + 'by_status', COALESCE((SELECT jsonb_object_agg(s, c) FROM ( + SELECT COALESCE(normalized_status, '(none)') AS s, count(*) AS c FROM ( + SELECT normalized_status FROM open_t + UNION ALL SELECT normalized_status FROM closed_t) u GROUP BY 1) z), '{}'::jsonb), + 'by_cluster', COALESCE((SELECT jsonb_object_agg(cl, c) FROM ( + SELECT COALESCE(cluster, '(none)') AS cl, count(*) AS c FROM ( + SELECT cluster FROM open_t + UNION ALL SELECT cluster FROM closed_t) u GROUP BY 1) z), '{}'::jsonb), + -- closures by engineer (CASE-NORMALIZED owner) — leaderboard for "who closed". + 'by_owner', COALESCE((SELECT jsonb_agg(jsonb_build_object( + 'owner', o, 'closed', c, 'breached', b, 'avg_mttr_min', a) ORDER BY c DESC, o) + FROM ( + SELECT COALESCE(initcap(lower(NULLIF(owner, ''))), '(unattributed)') AS o, + count(*) AS c, + count(*) FILTER (WHERE sla_status = 'Breached') AS b, + round(avg(mttr) FILTER (WHERE mttr IS NOT NULL), 1) AS a + FROM closed_t GROUP BY 1) z), '[]'::jsonb), + 'closure_rate', jsonb_build_object( + 'per_day_avg', CASE WHEN v_days IS NULL THEN NULL + ELSE round((SELECT count(*) FROM closed_t)::numeric / v_days, 2) END, + 'series', COALESCE((SELECT jsonb_agg(jsonb_build_object('day', d, 'count', c) ORDER BY d) FROM ( + SELECT (closed_at AT TIME ZONE 'Africa/Nairobi')::date AS d, count(*) AS c + FROM closed_t GROUP BY 1) z), '[]'::jsonb) + ), + 'avg_mttr_min', (SELECT round(avg(mttr), 1) FROM closed_t WHERE mttr IS NOT NULL) + ), + + 'freshness', (SELECT jsonb_object_agg(dataset, jsonb_build_object( + 'export_type', export_type, 'exported_at', exported_at, + 'records_ingested', records_ingested, 'ingested_at', ingested_at)) + FROM tickets.import_meta) + ) INTO v_result; + + RETURN v_result; +END $function$; + +-- ── fn_crq_search — mirror of fn_inc_search over tickets.crq ────────────────── +CREATE OR REPLACE FUNCTION reporting.fn_crq_search( + p_ticket_id text DEFAULT NULL, + p_owner text DEFAULT NULL, + p_cluster text DEFAULT NULL, + p_status text DEFAULT NULL, + p_state text DEFAULT 'closed', + p_from timestamptz DEFAULT NULL, + p_to timestamptz DEFAULT NULL, + p_limit integer DEFAULT 500 +) + RETURNS jsonb LANGUAGE plpgsql STABLE AS $function$ +DECLARE + v_state text := lower(COALESCE(NULLIF(p_state, ''), 'closed')); + v_limit integer := LEAST(GREATEST(COALESCE(p_limit, 500), 1), 5000); + v_result jsonb; +BEGIN + p_ticket_id := NULLIF(trim(p_ticket_id), ''); + p_owner := NULLIF(trim(p_owner), ''); + p_cluster := NULLIF(p_cluster, ''); + p_status := NULLIF(p_status, ''); + + WITH hits AS ( + SELECT ticket_id, normalized_status, cluster, region, location_name, + initcap(lower(NULLIF(owner, ''))) AS owner, assigned_team, + sla_status, mttr, closed_at, created_at_service, is_actionable, + CASE WHEN geom IS NOT NULL THEN ST_Y(geom) END AS lat, + CASE WHEN geom IS NOT NULL THEN ST_X(geom) END AS lng + FROM tickets.crq + WHERE (p_ticket_id IS NULL OR ticket_id ILIKE '%' || p_ticket_id || '%') + AND (p_owner IS NULL OR lower(owner) LIKE '%' || lower(p_owner) || '%') + AND (p_cluster IS NULL OR cluster = p_cluster) + AND (p_status IS NULL OR normalized_status = p_status) + AND CASE v_state + WHEN 'open' THEN COALESCE(is_actionable, false) + WHEN 'all' THEN COALESCE(is_actionable, false) + OR (closed_at IS NOT NULL + AND (p_from IS NULL OR closed_at >= p_from) + AND (p_to IS NULL OR closed_at < p_to)) + ELSE NOT COALESCE(is_actionable, false) -- 'closed' + AND closed_at IS NOT NULL + AND (p_from IS NULL OR closed_at >= p_from) + AND (p_to IS NULL OR closed_at < p_to) + END + ), + total AS (SELECT count(*) AS n FROM hits), + page AS ( + SELECT * FROM hits + ORDER BY closed_at DESC NULLS LAST, created_at_service DESC NULLS LAST + LIMIT v_limit + ) + SELECT jsonb_build_object( + 'count', (SELECT n FROM total), + 'truncated', (SELECT n FROM total) > v_limit, + 'limit', v_limit, + 'state', v_state, + 'rows', COALESCE((SELECT jsonb_agg(to_jsonb(page) + ORDER BY page.closed_at DESC NULLS LAST, + page.created_at_service DESC NULLS LAST) + FROM page), '[]'::jsonb) + ) INTO v_result; + + RETURN v_result; +END $function$; + +-- ── fn_crq_filter_options — mirror of fn_inc_filter_options over tickets.crq ── +CREATE OR REPLACE FUNCTION reporting.fn_crq_filter_options() + RETURNS jsonb LANGUAGE sql STABLE AS $function$ + SELECT jsonb_build_object( + 'owners', (SELECT COALESCE(jsonb_agg(o ORDER BY o), '[]'::jsonb) + FROM (SELECT DISTINCT initcap(lower(NULLIF(owner, ''))) AS o + FROM tickets.crq WHERE NULLIF(owner, '') IS NOT NULL) s), + 'clusters', (SELECT COALESCE(jsonb_agg(c ORDER BY c), '[]'::jsonb) + FROM (SELECT DISTINCT cluster AS c + FROM tickets.crq WHERE NULLIF(cluster, '') IS NOT NULL) s), + 'open_ticket_ids', (SELECT COALESCE(jsonb_agg(ticket_id ORDER BY ticket_id), '[]'::jsonb) + FROM tickets.crq WHERE COALESCE(is_actionable, false)) + ); +$function$; + +-- ── grants (guarded: roles may not exist on a fresh DB) ────────────────────── +DO $grants$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dashboard_ro') THEN + GRANT SELECT ON tickets.crq_open_sla TO dashboard_ro; + GRANT EXECUTE ON FUNCTION reporting.fn_crq_dashboard(text, text, text, timestamptz, timestamptz) TO dashboard_ro; + GRANT EXECUTE ON FUNCTION reporting.fn_crq_search(text, text, text, text, text, timestamptz, timestamptz, integer) TO dashboard_ro; + GRANT EXECUTE ON FUNCTION reporting.fn_crq_filter_options() TO dashboard_ro; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN + GRANT SELECT ON tickets.crq_open_sla TO grafana_ro; + GRANT EXECUTE ON FUNCTION reporting.fn_crq_dashboard(text, text, text, timestamptz, timestamptz) TO grafana_ro; + GRANT EXECUTE ON FUNCTION reporting.fn_crq_search(text, text, text, text, text, timestamptz, timestamptz, integer) TO grafana_ro; + GRANT EXECUTE ON FUNCTION reporting.fn_crq_filter_options() TO grafana_ro; + END IF; +END $grants$;