-- 21_tickets.sql -- Field-ops TICKET layer for FleetOps: INC (incident / customer fault) + CRQ -- (new-installation request). Tickets are produced by the client's email -- automation as full-snapshot files in the rustfs `tickets` bucket -- (automations/{inc,crq}/latest.json) and ingested by tools/import_tickets.py -- into tracksolid.tickets. The map read layer is reporting.fn_tickets_for_map, -- served by dashboard_api GET /webhook/tickets and rendered as INC/CRQ overlay -- layers in the FleetOps "Tickets" tab (FleetNow-style map). -- -- Geocoding: source rows carry NO coordinates (latitude/longitude are null in -- every row observed). geom is resolved from a small cluster gazetteer -- (tracksolid.geo_clusters), or directly from the feed lat/lng if the upstream -- ever starts populating them. Until the gazetteer is seeded, geom is NULL and -- tickets are simply not mapped (fn_tickets_for_map filters geom IS NOT NULL). -- -- Safe to re-apply (IF NOT EXISTS / CREATE OR REPLACE / DROP TRIGGER IF EXISTS). SET search_path = tracksolid, reporting, public; -- ── normalize helper (cluster_key) ──────────────────────────────────────────── -- upper + collapse internal whitespace + trim; '' → NULL. IMMUTABLE so it can -- back a functional index. CREATE OR REPLACE FUNCTION tracksolid.norm_cluster(p text) RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $function$ SELECT NULLIF(upper(regexp_replace(trim(COALESCE(p, '')), '\s+', ' ', 'g')), '') $function$; -- ── gazetteer: normalized cluster → coordinates ────────────────────────────── CREATE TABLE IF NOT EXISTS tracksolid.geo_clusters ( cluster_key text PRIMARY KEY, -- tracksolid.norm_cluster(cluster) region text, lat double precision, lng double precision, geom geometry(Point, 4326), source text, -- 'nominatim' | 'manual' | … verified boolean NOT NULL DEFAULT false, updated_at timestamptz NOT NULL DEFAULT now() ); CREATE OR REPLACE FUNCTION tracksolid.tg_geo_clusters_geom() RETURNS trigger LANGUAGE plpgsql AS $function$ BEGIN IF NEW.lat IS NOT NULL AND NEW.lng IS NOT NULL AND NEW.lat BETWEEN -90 AND 90 AND NEW.lng BETWEEN -180 AND 180 AND NOT (NEW.lat = 0 AND NEW.lng = 0) THEN NEW.geom := ST_SetSRID(ST_MakePoint(NEW.lng, NEW.lat), 4326); ELSE NEW.geom := NULL; END IF; NEW.updated_at := now(); RETURN NEW; END $function$; DROP TRIGGER IF EXISTS trg_geo_clusters_geom ON tracksolid.geo_clusters; CREATE TRIGGER trg_geo_clusters_geom BEFORE INSERT OR UPDATE ON tracksolid.geo_clusters FOR EACH ROW EXECUTE FUNCTION tracksolid.tg_geo_clusters_geom(); -- ── tickets ─────────────────────────────────────────────────────────────────── -- Columns mirror the source snapshot schema (32 fields, identical for INC/CRQ) -- 1:1, plus locally-derived geom / geo_source / raw / ingested_at. CREATE TABLE IF NOT EXISTS tracksolid.tickets ( ticket_id text PRIMARY KEY, source_type text, service_type text, -- 'inc' | 'crq' (type discriminator) bucket text, raw_status text, normalized_status text, created_at_service timestamptz, scheduled_at timestamptz, closed_at timestamptz, last_seen_at timestamptz, first_seen_at timestamptz, week_start date, week_end date, cluster text, region text, location_name text, latitude double precision, longitude double precision, department text, assigned_team text, owner text, sla_status text, mttr double precision, is_auto_created boolean, is_auto_closed boolean, is_alarm boolean, is_actionable boolean, source_s3_bucket text, source_s3_key text, source_snapshot_id bigint, created_at timestamptz, updated_at timestamptz, -- locally derived geom geometry(Point, 4326), geo_source text, -- 'feed' | 'cluster' | 'none' raw jsonb, -- full source row (hedge for new fields) ingested_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT tickets_service_type_chk CHECK (service_type IS NULL OR service_type IN ('inc', 'crq')) ); -- geom resolution: feed coords first, else cluster gazetteer, else none. CREATE OR REPLACE FUNCTION tracksolid.tg_tickets_geom() RETURNS trigger LANGUAGE plpgsql AS $function$ DECLARE g geometry(Point, 4326); BEGIN IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL AND NEW.latitude BETWEEN -90 AND 90 AND NEW.longitude BETWEEN -180 AND 180 AND NOT (NEW.latitude = 0 AND NEW.longitude = 0) THEN NEW.geom := ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326); NEW.geo_source := 'feed'; ELSE SELECT gc.geom INTO g FROM tracksolid.geo_clusters gc WHERE gc.cluster_key = tracksolid.norm_cluster(NEW.cluster) AND gc.geom IS NOT NULL LIMIT 1; IF g IS NOT NULL THEN NEW.geom := g; NEW.geo_source := 'cluster'; ELSE NEW.geom := NULL; NEW.geo_source := 'none'; END IF; END IF; RETURN NEW; END $function$; DROP TRIGGER IF EXISTS trg_tickets_geom ON tracksolid.tickets; CREATE TRIGGER trg_tickets_geom BEFORE INSERT OR UPDATE ON tracksolid.tickets FOR EACH ROW EXECUTE FUNCTION tracksolid.tg_tickets_geom(); -- Re-resolve geoms for already-loaded tickets after the gazetteer is (re)seeded, -- without re-running ingestion. Skips rows already pinned by feed coordinates. -- Returns the number of rows updated. Call: SELECT tracksolid.resolve_ticket_geoms(); CREATE OR REPLACE FUNCTION tracksolid.resolve_ticket_geoms() RETURNS integer LANGUAGE plpgsql AS $function$ DECLARE n integer; BEGIN UPDATE tracksolid.tickets t SET geom = gc.geom, geo_source = 'cluster' FROM tracksolid.geo_clusters gc WHERE gc.cluster_key = tracksolid.norm_cluster(t.cluster) AND gc.geom IS NOT NULL AND t.geo_source IS DISTINCT FROM 'feed' AND t.geom IS DISTINCT FROM gc.geom; GET DIAGNOSTICS n = ROW_COUNT; RETURN n; END $function$; -- ── indexes ─────────────────────────────────────────────────────────────────── CREATE INDEX IF NOT EXISTS ix_tickets_service_type ON tracksolid.tickets (service_type); CREATE INDEX IF NOT EXISTS ix_tickets_status ON tracksolid.tickets (normalized_status); CREATE INDEX IF NOT EXISTS ix_tickets_type_status ON tracksolid.tickets (service_type, normalized_status); CREATE INDEX IF NOT EXISTS ix_tickets_actionable ON tracksolid.tickets (is_actionable) WHERE is_actionable; CREATE INDEX IF NOT EXISTS ix_tickets_geom ON tracksolid.tickets USING gist (geom); CREATE INDEX IF NOT EXISTS ix_tickets_cluster ON tracksolid.tickets (tracksolid.norm_cluster(cluster)); -- ── map read function ───────────────────────────────────────────────────────── -- Modeled on reporting.fn_live_positions: NULLIF-cleaned params, single filtered -- CTE, returns { summary, geojson:FeatureCollection }. Maps only geocoded rows -- (geom IS NOT NULL). p_open_only restricts to actionable (open) tickets — the -- map default, since the bulk of INC are CLOSED. CREATE OR REPLACE FUNCTION reporting.fn_tickets_for_map( p_service_type text DEFAULT NULL, p_status text DEFAULT NULL, p_open_only boolean DEFAULT true ) RETURNS jsonb LANGUAGE plpgsql STABLE AS $function$ DECLARE v_result jsonb; BEGIN p_service_type := lower(NULLIF(p_service_type, '')); p_status := NULLIF(p_status, ''); WITH filtered AS ( SELECT * FROM tracksolid.tickets WHERE geom IS NOT NULL AND (p_service_type IS NULL OR service_type = p_service_type) AND (p_status IS NULL OR normalized_status = p_status) AND (NOT p_open_only OR is_actionable IS TRUE) ) SELECT jsonb_build_object( 'summary', jsonb_build_object( 'ticket_count', COUNT(*), 'inc', COUNT(*) FILTER (WHERE service_type = 'inc'), 'crq', COUNT(*) FILTER (WHERE service_type = 'crq'), 'open', COUNT(*) FILTER (WHERE is_actionable IS TRUE), 'by_status', (SELECT jsonb_object_agg(s, c) FROM (SELECT normalized_status AS s, COUNT(*) AS c FROM filtered GROUP BY normalized_status) z) ), 'geojson', jsonb_build_object( 'type', 'FeatureCollection', 'features', COALESCE(jsonb_agg( jsonb_build_object( 'type', 'Feature', 'properties', jsonb_build_object( 'ticket_id', ticket_id, 'service_type', service_type, 'status', normalized_status, 'raw_status', raw_status, 'cluster', cluster, 'region', region, 'location_name', location_name, 'department', department, 'owner', owner, 'assigned_team', assigned_team, 'sla_status', sla_status, 'is_actionable', is_actionable, 'geo_source', geo_source, 'created_at', to_char(created_at_service, 'YYYY-MM-DD HH24:MI:SS'), 'scheduled_at', to_char(scheduled_at, 'YYYY-MM-DD HH24:MI:SS') ), 'geometry', ST_AsGeoJSON(geom)::jsonb ) ), '[]'::jsonb) ) ) INTO v_result FROM filtered; RETURN v_result; END $function$; COMMENT ON FUNCTION reporting.fn_tickets_for_map(text, text, boolean) IS 'INC/CRQ tickets as a GeoJSON FeatureCollection for the FleetOps Tickets map. Migration 21.'; -- ── 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 USAGE ON SCHEMA tracksolid TO dashboard_ro; GRANT SELECT ON tracksolid.tickets, tracksolid.geo_clusters TO dashboard_ro; GRANT EXECUTE ON FUNCTION reporting.fn_tickets_for_map(text, text, boolean) TO dashboard_ro; END IF; IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN GRANT SELECT ON tracksolid.tickets, tracksolid.geo_clusters TO grafana_ro; GRANT EXECUTE ON FUNCTION reporting.fn_tickets_for_map(text, text, boolean) TO grafana_ro; END IF; END $grants$;