-- migrate:up -- -- Full rollback of the CSV import (migrations 15 + 16 + the data mutations -- performed by scripts/import_csv_roster.py). After this runs, the database -- is back to the post-migration-14 shape: ~144 vehicles split per device_name -- (preserving _Track / _CAM suffix vehicles), no CSV-only columns, and -- serve.fn_live_view back to its v3 body (driver_name from heuristic only). -- -- Why a single forward migration instead of `dbmate rollback`: -- - The CSV import mutated row state, not just schema. A schema-only down -- would leave 124 merged vehicles in place. -- - dbmate rollback runs the down blocks in reverse, but mig 16's down only -- drops the function — it doesn't restore the previous body. Doing it -- forward lets us be explicit about the target state. -- --------------------------------------------------------------------------- -- 1. Re-split vehicles by parsed plate from device_name -- --------------------------------------------------------------------------- DO $rollback$ DECLARE rec RECORD; new_vid integer; BEGIN ALTER TABLE domain.vehicles DROP CONSTRAINT IF EXISTS vehicles_plate_key; FOR rec IN SELECT d.imei, d.vehicle_id AS current_vid, v.plate AS current_plate, regexp_replace( (regexp_match(lp.device_name, '^.* - (.+)$'))[1], '_(cam|CAM)$', '' ) AS parsed_plate FROM domain.devices d JOIN state.live_positions lp ON lp.imei = d.imei JOIN domain.vehicles v ON v.vehicle_id = d.vehicle_id WHERE lp.device_name LIKE '% - %' LOOP IF rec.parsed_plate IS NULL OR rec.parsed_plate = '' THEN CONTINUE; END IF; IF rec.parsed_plate !~ '[A-Z]' OR rec.parsed_plate !~ '[0-9]' THEN CONTINUE; END IF; IF rec.parsed_plate = rec.current_plate THEN CONTINUE; END IF; SELECT vehicle_id INTO new_vid FROM domain.vehicles WHERE plate = rec.parsed_plate; IF new_vid IS NULL THEN INSERT INTO domain.vehicles (plate) VALUES (rec.parsed_plate) RETURNING vehicle_id INTO new_vid; END IF; UPDATE domain.devices SET vehicle_id = new_vid WHERE imei = rec.imei; UPDATE state.live_positions SET vehicle_id = new_vid WHERE imei = rec.imei; UPDATE state.position_history SET vehicle_id = new_vid WHERE imei = rec.imei; END LOOP; -- Delete vehicles that no device or state row references anymore DELETE FROM domain.vehicles v WHERE NOT EXISTS (SELECT 1 FROM domain.devices d WHERE d.vehicle_id = v.vehicle_id) AND NOT EXISTS (SELECT 1 FROM state.live_positions lp WHERE lp.vehicle_id = v.vehicle_id) AND NOT EXISTS (SELECT 1 FROM state.position_history ph WHERE ph.vehicle_id = v.vehicle_id); ALTER TABLE domain.vehicles ADD CONSTRAINT vehicles_plate_key UNIQUE (plate); END $rollback$; -- --------------------------------------------------------------------------- -- 2. Restore serve.fn_live_view to the v3 body (driver_name heuristic only) -- --------------------------------------------------------------------------- DROP FUNCTION IF EXISTS serve.fn_live_view(jsonb); CREATE OR REPLACE FUNCTION serve.fn_live_view(filters jsonb) RETURNS jsonb LANGUAGE plpgsql STABLE AS $$ DECLARE fresh_window interval := COALESCE((filters->>'fresh_window')::interval, interval '24 hours'); offline_after interval := COALESCE((filters->>'offline_after')::interval, interval '5 minutes'); move_speed_kmh numeric := COALESCE((filters->>'move_speed_kmh')::numeric, 5); p_cost_centre text := filters->>'cost_centre'; p_assigned_city text := filters->>'assigned_city'; p_vehicle_numbers text[] := CASE WHEN filters ? 'vehicle_numbers' THEN ARRAY(SELECT jsonb_array_elements_text(filters->'vehicle_numbers')) ELSE NULL END; result jsonb; BEGIN WITH candidates AS ( SELECT lp.imei, lp.occurred_at, lp.geom, lp.speed_kmh, lp.direction_deg, lp.mc_type, lp.current_mileage_km, lp.gps_signal, lp.satellites, lp.device_name, lp.pos_type, d.device_type, d.activation_at, v.vehicle_id, v.plate, v.cost_centre, v.assigned_city FROM state.live_positions lp JOIN domain.devices d ON d.imei = lp.imei JOIN domain.vehicles v ON v.vehicle_id = d.vehicle_id WHERE d.lifecycle = 'active' AND (p_cost_centre IS NULL OR v.cost_centre = p_cost_centre) AND (p_assigned_city IS NULL OR v.assigned_city = p_assigned_city) AND (p_vehicle_numbers IS NULL OR v.plate = ANY (p_vehicle_numbers)) ), ranked AS ( SELECT c.*, ROW_NUMBER() OVER ( PARTITION BY c.vehicle_id ORDER BY CASE c.device_type WHEN 'tracker' THEN 0 ELSE 1 END, CASE WHEN c.occurred_at > now() - fresh_window THEN 0 ELSE 1 END, c.occurred_at DESC, c.activation_at DESC NULLS LAST ) AS rn FROM candidates c ), deduped AS (SELECT * FROM ranked WHERE rn = 1), enriched AS ( SELECT d.*, CASE WHEN d.occurred_at <= now() - offline_after THEN 'offline' WHEN d.speed_kmh IS NOT NULL AND d.speed_kmh > move_speed_kmh THEN 'moving' ELSE 'parked' END AS operational_state, serve._cost_centre_color(d.cost_centre) AS cost_centre_color, EXTRACT(EPOCH FROM (now() - d.occurred_at))::int AS age_sec, round(ST_Y(d.geom)::numeric, 4) AS lat_rounded, round(ST_X(d.geom)::numeric, 4) AS lng_rounded FROM deduped d ), with_addr AS ( SELECT e.*, g.address, g.address_short FROM enriched e LEFT JOIN state.geocoded_positions g ON g.lat_rounded = e.lat_rounded AND g.lng_rounded = e.lng_rounded ), summary AS ( SELECT jsonb_build_object( 'total_active', count(*), 'moving', count(*) FILTER (WHERE operational_state = 'moving'), 'parked', count(*) FILTER (WHERE operational_state = 'parked'), 'offline', count(*) FILTER (WHERE operational_state = 'offline'), 'below_freshness_slo', count(*) FILTER ( WHERE occurred_at <= now() - interval '90 seconds' ), 'as_of', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') ) AS s FROM with_addr ), features AS ( SELECT COALESCE(jsonb_agg( jsonb_build_object( 'type', 'Feature', 'geometry', ST_AsGeoJSON(e.geom)::jsonb, 'properties', jsonb_build_object( 'vehicle_id', e.vehicle_id, 'plate', e.plate, 'plate_short', serve._label_short(e.device_name, e.plate), 'driver_name', serve._driver_name(e.device_name), 'imei', e.imei, 'device_type', e.device_type, 'device_name', e.device_name, 'mc_type', e.mc_type, 'pos_type', e.pos_type, 'cost_centre', e.cost_centre, 'cost_centre_color', e.cost_centre_color, 'assigned_city', e.assigned_city, 'address', e.address, 'address_short', e.address_short, 'occurred_at', to_char(e.occurred_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), 'age_sec', e.age_sec, 'speed_kmh', e.speed_kmh, 'heading_deg', e.direction_deg, 'gps_signal', e.gps_signal, 'satellites', e.satellites, 'current_mileage_km', e.current_mileage_km, 'operational_state', e.operational_state, 'style_class', 'vehicle-' || e.operational_state, 'marker_color', CASE WHEN e.operational_state = 'moving' THEN e.cost_centre_color ELSE '#9ca3af' END, 'show_arrow', (e.operational_state = 'moving' AND e.direction_deg IS NOT NULL) ) ) ), '[]'::jsonb) AS feats FROM with_addr e ), slo_block AS ( SELECT COALESCE(jsonb_object_agg( metric, jsonb_build_object('threshold', threshold, 'current', current_value, 'status', status) ), '{}'::jsonb) AS ss FROM slo.v_current_status ) SELECT jsonb_build_object( 'summary', (SELECT s FROM summary), 'geojson', jsonb_build_object('type', 'FeatureCollection', 'features', (SELECT feats FROM features)), 'slo_status', (SELECT ss FROM slo_block) ) INTO result; RETURN result; END; $$; -- --------------------------------------------------------------------------- -- 3. Drop the CSV-only columns from domain.devices -- --------------------------------------------------------------------------- ALTER TABLE domain.devices DROP COLUMN IF EXISTS driver_name, DROP COLUMN IF EXISTS driver_phone, DROP COLUMN IF EXISTS iccid, DROP COLUMN IF EXISTS expiration_at, DROP COLUMN IF EXISTS device_group, DROP COLUMN IF EXISTS roster_synced_at; -- --------------------------------------------------------------------------- -- 4. Remove the dead schema_migrations rows for the deleted migrations -- --------------------------------------------------------------------------- -- -- 20260601000015 (devices_csv_columns) and 20260601000016 (prefer_csv_driver) -- have been deleted from db/migrations/ — drop their tracker rows so dbmate -- status stays consistent with the files on disk. DELETE FROM public.schema_migrations WHERE version IN ('20260601000015', '20260601000016'); -- --------------------------------------------------------------------------- -- 5. Sanity check — log the resulting counts -- --------------------------------------------------------------------------- DO $check$ DECLARE v_count integer; d_count integer; BEGIN SELECT count(*) INTO v_count FROM domain.vehicles; SELECT count(*) INTO d_count FROM domain.devices; RAISE NOTICE 'rollback complete: % vehicles, % devices', v_count, d_count; END $check$; -- migrate:down -- No-op: this migration is itself a rollback. To re-apply the CSV import, -- re-run migrations 15 + 16 and scripts/import_csv_roster.py.