-- Enable PostGIS CREATE EXTENSION IF NOT EXISTS postgis; CREATE EXTENSION IF NOT EXISTS postgis_topology; CREATE EXTENSION IF NOT EXISTS pg_trgm; -- ─── Cities ────────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS cities ( slug TEXT PRIMARY KEY, name TEXT NOT NULL, country_code CHAR(2) NOT NULL DEFAULT '', geofabrik_url TEXT NOT NULL, bbox geometry(Polygon, 4326), resolution_m INTEGER NOT NULL DEFAULT 200, status TEXT NOT NULL DEFAULT 'empty' CHECK (status IN ('empty','pending','processing','ready','error')), error_message TEXT, last_ingested TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Migration for existing databases ALTER TABLE cities ADD COLUMN IF NOT EXISTS resolution_m INTEGER NOT NULL DEFAULT 200; CREATE INDEX IF NOT EXISTS idx_cities_bbox ON cities USING GIST (bbox); -- ─── Raw POIs (created and managed by osm2pgsql flex output) ───────────────── -- osm2pgsql --drop recreates this table on each ingest using the Lua script. -- Columns: osm_id (bigint), osm_type (char), city_slug, category, subcategory, -- name, tags, geom — no auto-generated id column. -- This CREATE TABLE IF NOT EXISTS is a no-op after the first osm2pgsql run. CREATE TABLE IF NOT EXISTS raw_pois ( osm_id BIGINT NOT NULL, osm_type CHAR(1) NOT NULL, city_slug TEXT NOT NULL, category TEXT NOT NULL, subcategory TEXT NOT NULL, name TEXT, tags JSONB, geom geometry(Point, 4326) NOT NULL ); CREATE INDEX IF NOT EXISTS idx_raw_pois_city_cat ON raw_pois (city_slug, category); CREATE INDEX IF NOT EXISTS idx_raw_pois_geom ON raw_pois USING GIST (geom); CREATE INDEX IF NOT EXISTS idx_raw_pois_name ON raw_pois USING GIN (name gin_trgm_ops) WHERE name IS NOT NULL; -- ─── Grid points ───────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS grid_points ( id BIGSERIAL PRIMARY KEY, city_slug TEXT NOT NULL REFERENCES cities(slug) ON DELETE CASCADE, geom geometry(Point, 4326) NOT NULL, grid_x INTEGER NOT NULL, grid_y INTEGER NOT NULL, UNIQUE (city_slug, grid_x, grid_y) ); CREATE INDEX IF NOT EXISTS idx_grid_city ON grid_points (city_slug); CREATE INDEX IF NOT EXISTS idx_grid_geom ON grid_points USING GIST (geom); -- ─── Pre-computed accessibility scores ─────────────────────────────────────── CREATE TABLE IF NOT EXISTS grid_scores ( grid_point_id BIGINT NOT NULL REFERENCES grid_points(id) ON DELETE CASCADE, category TEXT NOT NULL, travel_mode TEXT NOT NULL CHECK (travel_mode IN ('walking','cycling','driving')), threshold_min INTEGER NOT NULL, profile TEXT NOT NULL DEFAULT 'universal', nearest_poi_id BIGINT, distance_m FLOAT, travel_time_s FLOAT, score FLOAT NOT NULL CHECK (score >= 0 AND score <= 1), computed_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (grid_point_id, category, travel_mode, threshold_min, profile) ); -- Migration for existing databases (adds profile column and rebuilds PK) ALTER TABLE grid_scores ADD COLUMN IF NOT EXISTS profile TEXT NOT NULL DEFAULT 'universal'; CREATE INDEX IF NOT EXISTS idx_grid_scores_lookup ON grid_scores (grid_point_id, travel_mode, threshold_min, profile); -- ─── Nearest POI per subcategory per grid point ─────────────────────────────── -- Populated by compute-scores job. Stores the nearest (by routing time) POI for -- each subcategory at each grid point, for each travel mode. Threshold-independent. CREATE TABLE IF NOT EXISTS grid_poi_details ( grid_point_id BIGINT NOT NULL REFERENCES grid_points(id) ON DELETE CASCADE, category TEXT NOT NULL, subcategory TEXT NOT NULL, travel_mode TEXT NOT NULL, nearest_poi_id BIGINT, nearest_poi_name TEXT, distance_m FLOAT, travel_time_s FLOAT, computed_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (grid_point_id, category, subcategory, travel_mode) ); CREATE INDEX IF NOT EXISTS idx_grid_poi_details_lookup ON grid_poi_details (grid_point_id, travel_mode); -- ─── Isochrone cache ────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS isochrone_cache ( id BIGSERIAL PRIMARY KEY, origin_geom geometry(Point, 4326) NOT NULL, travel_mode TEXT NOT NULL, contours_min INTEGER[] NOT NULL, result JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_isochrone_origin ON isochrone_cache USING GIST (origin_geom); CREATE INDEX IF NOT EXISTS idx_isochrone_created ON isochrone_cache (created_at); -- Auto-expire isochrone cache entries older than 30 days -- (handled by periodic cleanup or TTL logic in app)