deckgl_tracksolid/db/migrations/004_trips_for_range_rpc.sql
David Kiania 47c86c9d7a Add deckgl trips visualization stack
Initial implementation of the public trips dashboard:

- db/migrations/001..005: read-only viz_anon role + thin trips_viz_v1
  view + three SECURITY DEFINER RPCs (trips_for_day, trips_for_range,
  list_cost_centres). Builds path on demand from position_history;
  coalesces missing cost_centre to 'Unassigned'. Smoke-tested against
  staging: 982 trips / 13 cost centres for 2026-04-29.

- compose/: PostgREST v12 service + trips_web Caddy service. CORS
  allow-listed to the web FQDN; viz_anon role is the only authorization.

- web/: Vite + React + TS SPA. deck.gl TripsLayer animated over
  PathLayer (whole route in low opacity), Mapbox GL dark base map,
  Zustand store, TanStack Query for fetching. Sidebar = date controls
  + cost-centre multi-select + vehicle drilldown. Timebar = scrubber
  with 1x/10x/60x/600x speeds. tsc + vite build clean.

- README + design doc updated to match the verified schema (path lives
  in tracksolid.position_history, vehicle key is imei, no down-sampling
  needed at observed volume).
2026-05-01 01:13:57 +03:00

65 lines
2 KiB
PL/PgSQL

-- 004_trips_for_range_rpc.sql
-- Multi-day variant of trips_for_day. The 14-day cap is enforced here in SQL
-- so PostgREST can't be tricked into asking for an absurd range via direct RPC.
CREATE OR REPLACE FUNCTION public.trips_for_range(
p_start date,
p_end date,
p_cost_centres text[] DEFAULT NULL
) RETURNS TABLE (
trip_id bigint,
imei text,
vehicle_name text,
vehicle_number text,
cost_centre text,
start_time timestamptz,
end_time timestamptz,
distance_km numeric,
path_geojson json,
timestamps_rel int[]
)
LANGUAGE plpgsql STABLE
SECURITY DEFINER
SET search_path = public, tracksolid
AS $$
BEGIN
IF p_start IS NULL OR p_end IS NULL OR p_end < p_start THEN
RAISE EXCEPTION 'Invalid date range: p_start=%, p_end=%', p_start, p_end;
END IF;
IF (p_end - p_start) > 13 THEN
RAISE EXCEPTION 'Range too large: max 14 days, got %', (p_end - p_start + 1);
END IF;
RETURN QUERY
WITH range_trips AS (
SELECT v.*
FROM public.trips_viz_v1 v
WHERE v.start_time::date BETWEEN p_start AND p_end
AND (p_cost_centres IS NULL OR v.cost_centre = ANY(p_cost_centres))
)
SELECT
rt.trip_id,
rt.imei,
rt.vehicle_name,
rt.vehicle_number,
rt.cost_centre,
rt.start_time,
rt.end_time,
rt.distance_km,
ST_AsGeoJSON(ST_MakeLine(ph.geom ORDER BY ph.gps_time))::json AS path_geojson,
array_agg(
EXTRACT(EPOCH FROM ph.gps_time - rt.start_time)::int
ORDER BY ph.gps_time
) AS timestamps_rel
FROM range_trips rt
JOIN tracksolid.position_history ph
ON ph.imei = rt.imei
AND ph.gps_time BETWEEN rt.start_time AND rt.end_time
AND ph.geom IS NOT NULL
GROUP BY rt.trip_id, rt.imei, rt.vehicle_name, rt.vehicle_number,
rt.cost_centre, rt.start_time, rt.end_time, rt.distance_km
HAVING count(ph.geom) >= 2;
END;
$$;
GRANT EXECUTE ON FUNCTION public.trips_for_range(date, date, text[]) TO viz_anon;