-- 01_tickets_schema.sql — fleettickets · INC/CRQ ticket store (raw-jsonb-first) -- ───────────────────────────────────────────────────────────────────────────── -- Consolidated final-state schema for the field-ops ticket layer. This supersedes -- the historical tracksolid migrations 21→23 (where this feature was first built); -- fleettickets now owns the `tickets` schema. The schema lives in the shared -- database (`tracksolid_db` today) so the existing dashboard_api read-API and the -- FleetOps Tickets map keep working unchanged. -- -- tickets.inc / tickets.crq one raw-jsonb row per ticket (ticket_id + raw + -- derived geom/geo_source). INC = incident/fault, -- CRQ = new-installation. -- tickets.geo_clusters cluster -> coordinate gazetteer (coarse fallback) -- tickets.geo_locations cleaned-location -> coordinate cache (precise) -- reporting.fn_tickets_for_map GeoJSON read function consumed by dashboard_api -- -- geom resolution: feed coords (raw lat/lng) -> location cache -> cluster centroid -- -> none. Idempotent: safe on a fresh DB and re-appliable on the live DB. -- Requires PostGIS (present on the shared DB; on a brand-new DB a superuser must -- run CREATE EXTENSION postgis first). -- ───────────────────────────────────────────────────────────────────────────── CREATE EXTENSION IF NOT EXISTS postgis; CREATE SCHEMA IF NOT EXISTS tickets; CREATE SCHEMA IF NOT EXISTS reporting; -- shared read layer (the fn lives here for dashboard_api) SET search_path = tickets, public; -- ── normalize helper (generic upper/collapse/trim key) ─────────────────────── CREATE OR REPLACE FUNCTION tickets.norm_cluster(p text) RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $fn$ SELECT NULLIF(upper(regexp_replace(trim(COALESCE(p, '')), '\s+', ' ', 'g')), '') $fn$; -- ── gazetteer: cluster -> coordinates (coarse fallback) ────────────────────── CREATE TABLE IF NOT EXISTS tickets.geo_clusters ( cluster_key text PRIMARY KEY, region text, lat double precision, lng double precision, geom geometry(Point, 4326), source text, verified boolean NOT NULL DEFAULT false, updated_at timestamptz NOT NULL DEFAULT now() ); CREATE OR REPLACE FUNCTION tickets.tg_geo_clusters_geom() RETURNS trigger LANGUAGE plpgsql AS $fn$ 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 $fn$; DROP TRIGGER IF EXISTS trg_geo_clusters_geom ON tickets.geo_clusters; CREATE TRIGGER trg_geo_clusters_geom BEFORE INSERT OR UPDATE ON tickets.geo_clusters FOR EACH ROW EXECUTE FUNCTION tickets.tg_geo_clusters_geom(); -- ── location geocode cache: cleaned location_name -> coordinates (precise) ─── -- query_key = tickets.norm_cluster(location_name); resolve joins on it without -- re-deriving the place-extraction regex (that lives in the loader). CREATE TABLE IF NOT EXISTS tickets.geo_locations ( query_key text PRIMARY KEY, location_name text, cluster text, region text, query text, lat double precision, lng double precision, geom geometry(Point, 4326), confidence numeric, provider text, verified boolean NOT NULL DEFAULT false, updated_at timestamptz NOT NULL DEFAULT now() ); CREATE OR REPLACE FUNCTION tickets.tg_geo_locations_geom() RETURNS trigger LANGUAGE plpgsql AS $fn$ 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 $fn$; DROP TRIGGER IF EXISTS trg_geo_locations_geom ON tickets.geo_locations; CREATE TRIGGER trg_geo_locations_geom BEFORE INSERT OR UPDATE ON tickets.geo_locations FOR EACH ROW EXECUTE FUNCTION tickets.tg_geo_locations_geom(); -- ── per-type ticket tables (raw-jsonb-first) ───────────────────────────────── CREATE TABLE IF NOT EXISTS tickets.inc ( ticket_id text PRIMARY KEY, raw jsonb NOT NULL, geom geometry(Point, 4326), geo_source text, -- 'feed' | 'location' | 'cluster' | 'none' ingested_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS tickets.crq (LIKE tickets.inc INCLUDING ALL); -- ── geom trigger — read from raw; never clobber a deliberate geom-only update ─ CREATE OR REPLACE FUNCTION tickets.tg_ticket_geom() RETURNS trigger LANGUAGE plpgsql AS $fn$ DECLARE v_lat double precision := NULLIF(NEW.raw->>'latitude','')::double precision; v_lng double precision := NULLIF(NEW.raw->>'longitude','')::double precision; g geometry(Point, 4326); BEGIN IF TG_OP = 'UPDATE' AND NEW.raw IS NOT DISTINCT FROM OLD.raw THEN RETURN NEW; -- geom/geo_source-only update — keep caller's value END IF; IF v_lat IS NOT NULL AND v_lng IS NOT NULL AND v_lat BETWEEN -90 AND 90 AND v_lng BETWEEN -180 AND 180 AND NOT (v_lat = 0 AND v_lng = 0) THEN NEW.geom := ST_SetSRID(ST_MakePoint(v_lng, v_lat), 4326); NEW.geo_source := 'feed'; ELSE SELECT gc.geom INTO g FROM tickets.geo_clusters gc WHERE gc.cluster_key = tickets.norm_cluster(NEW.raw->>'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 $fn$; DROP TRIGGER IF EXISTS trg_inc_geom ON tickets.inc; CREATE TRIGGER trg_inc_geom BEFORE INSERT OR UPDATE ON tickets.inc FOR EACH ROW EXECUTE FUNCTION tickets.tg_ticket_geom(); DROP TRIGGER IF EXISTS trg_crq_geom ON tickets.crq; CREATE TRIGGER trg_crq_geom BEFORE INSERT OR UPDATE ON tickets.crq FOR EACH ROW EXECUTE FUNCTION tickets.tg_ticket_geom(); -- ── resolve — prefer location cache, else cluster centroid (non-feed rows) ─── CREATE OR REPLACE FUNCTION tickets.resolve_ticket_geoms() RETURNS integer LANGUAGE plpgsql AS $fn$ DECLARE n integer; m integer; BEGIN UPDATE tickets.inc t SET geom = COALESCE(loc.geom, gc.geom), geo_source = CASE WHEN loc.geom IS NOT NULL THEN 'location' WHEN gc.geom IS NOT NULL THEN 'cluster' ELSE 'none' END FROM tickets.inc base LEFT JOIN tickets.geo_locations loc ON loc.query_key = tickets.norm_cluster(base.raw->>'location_name') AND loc.geom IS NOT NULL LEFT JOIN tickets.geo_clusters gc ON gc.cluster_key = tickets.norm_cluster(base.raw->>'cluster') AND gc.geom IS NOT NULL WHERE t.ticket_id = base.ticket_id AND t.geo_source IS DISTINCT FROM 'feed' AND (t.geom IS DISTINCT FROM COALESCE(loc.geom, gc.geom) OR t.geo_source IS DISTINCT FROM CASE WHEN loc.geom IS NOT NULL THEN 'location' WHEN gc.geom IS NOT NULL THEN 'cluster' ELSE 'none' END); GET DIAGNOSTICS n = ROW_COUNT; UPDATE tickets.crq t SET geom = COALESCE(loc.geom, gc.geom), geo_source = CASE WHEN loc.geom IS NOT NULL THEN 'location' WHEN gc.geom IS NOT NULL THEN 'cluster' ELSE 'none' END FROM tickets.crq base LEFT JOIN tickets.geo_locations loc ON loc.query_key = tickets.norm_cluster(base.raw->>'location_name') AND loc.geom IS NOT NULL LEFT JOIN tickets.geo_clusters gc ON gc.cluster_key = tickets.norm_cluster(base.raw->>'cluster') AND gc.geom IS NOT NULL WHERE t.ticket_id = base.ticket_id AND t.geo_source IS DISTINCT FROM 'feed' AND (t.geom IS DISTINCT FROM COALESCE(loc.geom, gc.geom) OR t.geo_source IS DISTINCT FROM CASE WHEN loc.geom IS NOT NULL THEN 'location' WHEN gc.geom IS NOT NULL THEN 'cluster' ELSE 'none' END); GET DIAGNOSTICS m = ROW_COUNT; RETURN n + m; END $fn$; -- ── indexes ─────────────────────────────────────────────────────────────────── CREATE INDEX IF NOT EXISTS ix_inc_status_raw ON tickets.inc ((raw->>'normalized_status')); CREATE INDEX IF NOT EXISTS ix_inc_actionable_raw ON tickets.inc (((raw->>'is_actionable')::boolean)) WHERE (raw->>'is_actionable')::boolean; CREATE INDEX IF NOT EXISTS ix_inc_cluster_raw ON tickets.inc (tickets.norm_cluster(raw->>'cluster')); CREATE INDEX IF NOT EXISTS ix_inc_loc_raw ON tickets.inc (tickets.norm_cluster(raw->>'location_name')); CREATE INDEX IF NOT EXISTS ix_inc_geom ON tickets.inc USING gist (geom); CREATE INDEX IF NOT EXISTS ix_crq_status_raw ON tickets.crq ((raw->>'normalized_status')); CREATE INDEX IF NOT EXISTS ix_crq_actionable_raw ON tickets.crq (((raw->>'is_actionable')::boolean)) WHERE (raw->>'is_actionable')::boolean; CREATE INDEX IF NOT EXISTS ix_crq_cluster_raw ON tickets.crq (tickets.norm_cluster(raw->>'cluster')); CREATE INDEX IF NOT EXISTS ix_crq_loc_raw ON tickets.crq (tickets.norm_cluster(raw->>'location_name')); CREATE INDEX IF NOT EXISTS ix_crq_geom ON tickets.crq USING gist (geom); -- ── read function (GeoJSON; consumed by dashboard_api GET /webhook/tickets) ── 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 $fn$ DECLARE v_result jsonb; BEGIN p_service_type := lower(NULLIF(p_service_type, '')); p_status := NULLIF(p_status, ''); WITH filtered AS ( SELECT 'inc'::text AS service_type, raw, geom, geo_source FROM tickets.inc WHERE geom IS NOT NULL AND (p_service_type IS NULL OR p_service_type = 'inc') AND (p_status IS NULL OR raw->>'normalized_status' = p_status) AND (NOT p_open_only OR (raw->>'is_actionable')::boolean IS TRUE) UNION ALL SELECT 'crq'::text AS service_type, raw, geom, geo_source FROM tickets.crq WHERE geom IS NOT NULL AND (p_service_type IS NULL OR p_service_type = 'crq') AND (p_status IS NULL OR raw->>'normalized_status' = p_status) AND (NOT p_open_only OR (raw->>'is_actionable')::boolean 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 (raw->>'is_actionable')::boolean IS TRUE), 'by_status', (SELECT jsonb_object_agg(s, c) FROM (SELECT raw->>'normalized_status' AS s, COUNT(*) AS c FROM filtered GROUP BY raw->>'normalized_status') z) ), 'geojson', jsonb_build_object( 'type', 'FeatureCollection', 'features', COALESCE(jsonb_agg( jsonb_build_object( 'type', 'Feature', 'properties', jsonb_build_object( 'ticket_id', raw->>'ticket_id', 'service_type', service_type, 'status', raw->>'normalized_status', 'raw_status', raw->>'raw_status', 'cluster', raw->>'cluster', 'region', raw->>'region', 'location_name', raw->>'location_name', 'department', raw->>'department', 'owner', raw->>'owner', 'assigned_team', raw->>'assigned_team', 'sla_status', raw->>'sla_status', 'is_actionable', (raw->>'is_actionable')::boolean, 'geo_source', geo_source, 'created_at', raw->>'created_at_service', 'scheduled_at', raw->>'scheduled_at' ), 'geometry', ST_AsGeoJSON(geom)::jsonb ) ), '[]'::jsonb) ) ) INTO v_result FROM filtered; RETURN v_result; END $fn$; COMMENT ON FUNCTION reporting.fn_tickets_for_map(text, text, boolean) IS 'INC/CRQ tickets (tickets.inc + tickets.crq, raw-jsonb-first) as GeoJSON. fleettickets 01.'; -- ── grants (guarded: roles may not exist on a fresh DB) ─────────────────────── DO $grants$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tracksolid_owner') THEN GRANT USAGE, CREATE ON SCHEMA tickets TO tracksolid_owner; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tickets TO tracksolid_owner; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA tickets TO tracksolid_owner; END IF; IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dashboard_ro') THEN GRANT USAGE ON SCHEMA tickets TO dashboard_ro; GRANT SELECT ON tickets.inc, tickets.crq, tickets.geo_clusters, tickets.geo_locations 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 USAGE ON SCHEMA tickets TO grafana_ro; GRANT SELECT ON tickets.inc, tickets.crq, tickets.geo_clusters, tickets.geo_locations TO grafana_ro; GRANT EXECUTE ON FUNCTION reporting.fn_tickets_for_map(text, text, boolean) TO grafana_ro; END IF; END $grants$;