glam/infrastructure/sql/002_postgis_boundaries.sql
kempersc 83ab098cf7 feat: add PostGIS international boundary architecture
Add schema and tooling for storing administrative boundaries in PostGIS:
- 002_postgis_boundaries.sql: Complete PostGIS schema with:
  - boundary_countries (ISO 3166-1)
  - boundary_admin1 (states/provinces/regions)
  - boundary_admin2 (municipalities/districts)
  - boundary_historical (HALC pre-modern territories)
  - custodian_service_areas (computed werkgebied geometries)
  - geonames_settlements (reverse geocoding)
  - Spatial functions: find_admin_for_point, find_nearest_settlement
  - Views for API access

- load_boundaries_postgis.py: Python loader supporting:
  - GADM (Global Administrative Areas) - primary global source
  - CBS (Dutch municipality boundaries)
  - GeoNames settlements for reverse geocoding
  - Cached downloads and upsert logic

- POSTGIS_BOUNDARY_ARCHITECTURE.md: Design documentation

This replaces the static GeoJSON approach for international coverage.
2025-12-07 14:34:39 +01:00

622 lines
24 KiB
PL/PgSQL

-- PostGIS International Boundary Schema for Heritage Custodian Service Areas
-- Migration: 002_postgis_boundaries.sql
-- Created: 2025-12-07
--
-- Stores administrative boundaries for computing heritage custodian service areas
-- Supports global coverage with multiple data sources (GADM, Natural Earth, OSM, CBS)
-- Enables spatial queries: point-in-polygon, intersection, containment
-- ============================================================================
-- Enable PostGIS Extension
-- ============================================================================
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_topology;
-- ============================================================================
-- Data Source Registry
-- ============================================================================
-- Track data sources for provenance and updates
CREATE TABLE IF NOT EXISTS boundary_data_sources (
id SERIAL PRIMARY KEY,
source_code VARCHAR(50) NOT NULL UNIQUE, -- e.g., "GADM", "CBS", "OSM", "HALC"
source_name VARCHAR(255) NOT NULL, -- Full name
source_url TEXT, -- Official website
license_type VARCHAR(100), -- e.g., "CC-BY-4.0", "ODbL"
license_url TEXT,
description TEXT,
coverage_scope VARCHAR(50), -- "GLOBAL", "EUROPE", "NETHERLANDS", etc.
last_updated DATE,
attribution_required TEXT, -- Required attribution text
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Insert known data sources
INSERT INTO boundary_data_sources (source_code, source_name, source_url, license_type, coverage_scope, description) VALUES
('GADM', 'Global Administrative Areas Database', 'https://gadm.org', 'CC-BY-NC-4.0', 'GLOBAL', 'Global administrative boundaries at multiple levels (country, region, district)'),
('NATURAL_EARTH', 'Natural Earth', 'https://www.naturalearthdata.com', 'Public Domain', 'GLOBAL', 'Free vector and raster map data at 1:10m, 1:50m, and 1:110m scales'),
('OSM', 'OpenStreetMap', 'https://www.openstreetmap.org', 'ODbL', 'GLOBAL', 'Crowdsourced global geographic data'),
('CBS', 'CBS Wijken en Buurten', 'https://www.cbs.nl/nl-nl/dossier/nederland-regionaal/geografische-data', 'CC-BY-4.0', 'NETHERLANDS', 'Official Dutch municipality boundaries from CBS'),
('HALC', 'Historical Atlas of the Low Countries', NULL, 'Academic', 'LOW_COUNTRIES', 'Historical administrative boundaries for Netherlands, Belgium, Luxembourg'),
('EUROSTAT', 'Eurostat NUTS/LAU', 'https://ec.europa.eu/eurostat/web/gisco', 'Eurostat', 'EUROPE', 'European statistical regions (NUTS) and local administrative units (LAU)')
ON CONFLICT (source_code) DO NOTHING;
-- ============================================================================
-- Countries Table
-- ============================================================================
-- ISO 3166-1 countries with boundaries
CREATE TABLE IF NOT EXISTS boundary_countries (
id SERIAL PRIMARY KEY,
iso_a2 CHAR(2) NOT NULL, -- ISO 3166-1 alpha-2 (e.g., "NL", "DE")
iso_a3 CHAR(3) NOT NULL, -- ISO 3166-1 alpha-3 (e.g., "NLD", "DEU")
iso_n3 CHAR(3), -- ISO 3166-1 numeric (e.g., "528")
country_name VARCHAR(255) NOT NULL, -- English name
country_name_local VARCHAR(255), -- Native language name
-- Geometry
geom GEOMETRY(MULTIPOLYGON, 4326), -- WGS84 boundary polygon
centroid GEOMETRY(POINT, 4326), -- Computed centroid
bbox BOX2D, -- Bounding box for quick filtering
-- Metadata
source_id INTEGER REFERENCES boundary_data_sources(id),
source_feature_id VARCHAR(100), -- ID in source dataset
area_km2 NUMERIC, -- Computed area
-- Versioning
valid_from DATE,
valid_to DATE, -- NULL = current
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(iso_a2, valid_to) -- Allow multiple versions, only one current
);
CREATE INDEX IF NOT EXISTS idx_boundary_countries_iso_a2 ON boundary_countries(iso_a2);
CREATE INDEX IF NOT EXISTS idx_boundary_countries_geom ON boundary_countries USING GIST(geom);
CREATE INDEX IF NOT EXISTS idx_boundary_countries_centroid ON boundary_countries USING GIST(centroid);
CREATE INDEX IF NOT EXISTS idx_boundary_countries_valid ON boundary_countries(valid_from, valid_to);
-- ============================================================================
-- Administrative Level 1 (States/Provinces/Regions)
-- ============================================================================
-- ISO 3166-2 regions
CREATE TABLE IF NOT EXISTS boundary_admin1 (
id SERIAL PRIMARY KEY,
country_id INTEGER REFERENCES boundary_countries(id),
-- Identifiers
iso_3166_2 VARCHAR(10), -- e.g., "NL-NH", "DE-BY", "JP-13"
admin1_code VARCHAR(20) NOT NULL, -- Internal code (varies by source)
geonames_id INTEGER, -- GeoNames ID for linking
wikidata_id VARCHAR(20), -- Wikidata Q-number
-- Names
admin1_name VARCHAR(255) NOT NULL, -- English/standard name
admin1_name_local VARCHAR(255), -- Native language name
admin1_type VARCHAR(100), -- e.g., "Province", "State", "Prefecture", "Land"
-- Geometry
geom GEOMETRY(MULTIPOLYGON, 4326),
centroid GEOMETRY(POINT, 4326),
bbox BOX2D,
-- Metadata
source_id INTEGER REFERENCES boundary_data_sources(id),
source_feature_id VARCHAR(100),
area_km2 NUMERIC,
-- Versioning
valid_from DATE,
valid_to DATE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(country_id, admin1_code, valid_to)
);
CREATE INDEX IF NOT EXISTS idx_boundary_admin1_country ON boundary_admin1(country_id);
CREATE INDEX IF NOT EXISTS idx_boundary_admin1_iso ON boundary_admin1(iso_3166_2);
CREATE INDEX IF NOT EXISTS idx_boundary_admin1_geonames ON boundary_admin1(geonames_id);
CREATE INDEX IF NOT EXISTS idx_boundary_admin1_geom ON boundary_admin1 USING GIST(geom);
CREATE INDEX IF NOT EXISTS idx_boundary_admin1_centroid ON boundary_admin1 USING GIST(centroid);
-- ============================================================================
-- Administrative Level 2 (Districts/Counties/Municipalities)
-- ============================================================================
CREATE TABLE IF NOT EXISTS boundary_admin2 (
id SERIAL PRIMARY KEY,
admin1_id INTEGER REFERENCES boundary_admin1(id),
country_id INTEGER REFERENCES boundary_countries(id),
-- Identifiers
admin2_code VARCHAR(50) NOT NULL, -- Internal code
nuts_code VARCHAR(10), -- NUTS code (EU)
lau_code VARCHAR(20), -- LAU code (EU)
geonames_id INTEGER,
wikidata_id VARCHAR(20),
-- CBS-specific (Netherlands)
cbs_gemeente_code VARCHAR(10), -- Dutch municipality code (GM0363)
-- Names
admin2_name VARCHAR(255) NOT NULL,
admin2_name_local VARCHAR(255),
admin2_type VARCHAR(100), -- e.g., "Municipality", "District", "County"
-- Geometry
geom GEOMETRY(MULTIPOLYGON, 4326),
centroid GEOMETRY(POINT, 4326),
bbox BOX2D,
-- Metadata
source_id INTEGER REFERENCES boundary_data_sources(id),
source_feature_id VARCHAR(100),
area_km2 NUMERIC,
population INTEGER, -- If available from source
-- Versioning
valid_from DATE,
valid_to DATE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(admin1_id, admin2_code, valid_to)
);
CREATE INDEX IF NOT EXISTS idx_boundary_admin2_admin1 ON boundary_admin2(admin1_id);
CREATE INDEX IF NOT EXISTS idx_boundary_admin2_country ON boundary_admin2(country_id);
CREATE INDEX IF NOT EXISTS idx_boundary_admin2_geonames ON boundary_admin2(geonames_id);
CREATE INDEX IF NOT EXISTS idx_boundary_admin2_cbs ON boundary_admin2(cbs_gemeente_code);
CREATE INDEX IF NOT EXISTS idx_boundary_admin2_geom ON boundary_admin2 USING GIST(geom);
CREATE INDEX IF NOT EXISTS idx_boundary_admin2_centroid ON boundary_admin2 USING GIST(centroid);
-- ============================================================================
-- Historical Boundaries
-- ============================================================================
-- Pre-modern administrative boundaries (e.g., HALC 1500 data)
CREATE TABLE IF NOT EXISTS boundary_historical (
id SERIAL PRIMARY KEY,
-- Identifiers
historical_id VARCHAR(100) NOT NULL, -- Source-specific ID
halc_adm1_code VARCHAR(20), -- HALC ADM1 territory code (e.g., "VI")
halc_adm2_name VARCHAR(255), -- HALC ADM2 district name
wikidata_id VARCHAR(20),
-- Location context
modern_country_iso_a2 CHAR(2), -- Modern country this is within
-- Names
territory_name VARCHAR(255) NOT NULL,
territory_name_historical VARCHAR(255), -- Name in historical period
territory_type VARCHAR(100), -- e.g., "County", "Duchy", "Lordship"
-- Temporal extent
period_start SMALLINT, -- Year (e.g., 1500)
period_end SMALLINT, -- Year (e.g., 1795)
period_description VARCHAR(255), -- e.g., "Ancien Regime", "Medieval"
-- Geometry
geom GEOMETRY(MULTIPOLYGON, 4326),
centroid GEOMETRY(POINT, 4326),
bbox BOX2D,
-- Metadata
source_id INTEGER REFERENCES boundary_data_sources(id),
source_feature_id VARCHAR(100),
area_km2 NUMERIC,
certainty VARCHAR(50), -- "HIGH", "MEDIUM", "LOW", "APPROXIMATE"
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(historical_id, source_id)
);
CREATE INDEX IF NOT EXISTS idx_boundary_historical_halc ON boundary_historical(halc_adm1_code);
CREATE INDEX IF NOT EXISTS idx_boundary_historical_country ON boundary_historical(modern_country_iso_a2);
CREATE INDEX IF NOT EXISTS idx_boundary_historical_period ON boundary_historical(period_start, period_end);
CREATE INDEX IF NOT EXISTS idx_boundary_historical_geom ON boundary_historical USING GIST(geom);
-- ============================================================================
-- Custodian Service Areas (Computed/Custom)
-- ============================================================================
-- Stores pre-computed or custom service area geometries for heritage custodians
CREATE TABLE IF NOT EXISTS custodian_service_areas (
id SERIAL PRIMARY KEY,
-- Custodian link (GHCID)
ghcid VARCHAR(100) NOT NULL, -- e.g., "NL-NH-HAA-A-NHA"
ghcid_uuid UUID, -- UUID v5 of GHCID
-- Service area identity
service_area_name VARCHAR(255) NOT NULL,
service_area_type VARCHAR(50) NOT NULL, -- From ServiceAreaTypeEnum
-- Geometry (can be computed union or custom-drawn)
geom GEOMETRY(MULTIPOLYGON, 4326),
centroid GEOMETRY(POINT, 4326),
bbox BOX2D,
-- Composition (which admin units make up this service area)
admin2_ids INTEGER[], -- Array of boundary_admin2.id
admin1_ids INTEGER[], -- Array of boundary_admin1.id
historical_ids INTEGER[], -- Array of boundary_historical.id
-- Properties
is_historical BOOLEAN DEFAULT FALSE,
is_custom BOOLEAN DEFAULT FALSE, -- True if hand-drawn, not computed from admin units
-- Temporal extent
valid_from DATE,
valid_to DATE, -- NULL = current
-- Display properties
display_fill_color VARCHAR(20) DEFAULT '#3498db',
display_fill_opacity NUMERIC(3,2) DEFAULT 0.20,
display_line_color VARCHAR(20) DEFAULT '#2980b9',
display_line_width NUMERIC(3,1) DEFAULT 2.0,
-- Metadata
source_dataset VARCHAR(100), -- Data provenance
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(ghcid, valid_to)
);
CREATE INDEX IF NOT EXISTS idx_custodian_sa_ghcid ON custodian_service_areas(ghcid);
CREATE INDEX IF NOT EXISTS idx_custodian_sa_uuid ON custodian_service_areas(ghcid_uuid);
CREATE INDEX IF NOT EXISTS idx_custodian_sa_geom ON custodian_service_areas USING GIST(geom);
CREATE INDEX IF NOT EXISTS idx_custodian_sa_type ON custodian_service_areas(service_area_type);
CREATE INDEX IF NOT EXISTS idx_custodian_sa_historical ON custodian_service_areas(is_historical);
-- ============================================================================
-- GeoNames Settlements (for reverse geocoding and linking)
-- ============================================================================
-- Subset of GeoNames populated places for settlement lookups
CREATE TABLE IF NOT EXISTS geonames_settlements (
geonames_id INTEGER PRIMARY KEY,
-- Names
name VARCHAR(200) NOT NULL,
ascii_name VARCHAR(200),
alternate_names TEXT, -- Comma-separated
-- Location
latitude NUMERIC(10, 6) NOT NULL,
longitude NUMERIC(10, 6) NOT NULL,
geom GEOMETRY(POINT, 4326), -- Computed from lat/lon
-- Classification
feature_class CHAR(1), -- 'P' = populated place
feature_code VARCHAR(10), -- 'PPL', 'PPLA', 'PPLC', etc.
-- Administrative hierarchy
country_code CHAR(2),
admin1_code VARCHAR(20), -- GeoNames admin1
admin2_code VARCHAR(80),
admin3_code VARCHAR(20),
admin4_code VARCHAR(20),
-- Metadata
population INTEGER,
elevation INTEGER,
timezone VARCHAR(40),
modification_date DATE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_geonames_country ON geonames_settlements(country_code);
CREATE INDEX IF NOT EXISTS idx_geonames_admin1 ON geonames_settlements(country_code, admin1_code);
CREATE INDEX IF NOT EXISTS idx_geonames_feature ON geonames_settlements(feature_class, feature_code);
CREATE INDEX IF NOT EXISTS idx_geonames_geom ON geonames_settlements USING GIST(geom);
CREATE INDEX IF NOT EXISTS idx_geonames_name ON geonames_settlements(name);
CREATE INDEX IF NOT EXISTS idx_geonames_ascii ON geonames_settlements(ascii_name);
CREATE INDEX IF NOT EXISTS idx_geonames_population ON geonames_settlements(population DESC);
-- ============================================================================
-- Spatial Functions
-- ============================================================================
-- Function: Find admin units containing a point
CREATE OR REPLACE FUNCTION find_admin_for_point(
p_lon NUMERIC,
p_lat NUMERIC,
p_country_code CHAR(2) DEFAULT NULL
)
RETURNS TABLE (
admin_level INTEGER,
admin_code VARCHAR,
admin_name VARCHAR,
iso_code VARCHAR,
geonames_id INTEGER
) AS $$
BEGIN
RETURN QUERY
-- Admin Level 1
SELECT
1::INTEGER AS admin_level,
a.admin1_code,
a.admin1_name,
a.iso_3166_2,
a.geonames_id
FROM boundary_admin1 a
JOIN boundary_countries c ON a.country_id = c.id
WHERE ST_Contains(a.geom, ST_SetSRID(ST_Point(p_lon, p_lat), 4326))
AND a.valid_to IS NULL
AND (p_country_code IS NULL OR c.iso_a2 = p_country_code)
UNION ALL
-- Admin Level 2
SELECT
2::INTEGER,
a.admin2_code,
a.admin2_name,
a.nuts_code,
a.geonames_id
FROM boundary_admin2 a
JOIN boundary_countries c ON a.country_id = c.id
WHERE ST_Contains(a.geom, ST_SetSRID(ST_Point(p_lon, p_lat), 4326))
AND a.valid_to IS NULL
AND (p_country_code IS NULL OR c.iso_a2 = p_country_code)
ORDER BY admin_level;
END;
$$ LANGUAGE plpgsql;
-- Function: Find nearest settlement
CREATE OR REPLACE FUNCTION find_nearest_settlement(
p_lon NUMERIC,
p_lat NUMERIC,
p_country_code CHAR(2) DEFAULT NULL,
p_max_distance_km NUMERIC DEFAULT 50
)
RETURNS TABLE (
geonames_id INTEGER,
name VARCHAR,
feature_code VARCHAR,
population INTEGER,
distance_km NUMERIC
) AS $$
DECLARE
v_point GEOMETRY;
BEGIN
v_point := ST_SetSRID(ST_Point(p_lon, p_lat), 4326);
RETURN QUERY
SELECT
g.geonames_id,
g.name,
g.feature_code,
g.population,
ROUND((ST_Distance(g.geom::geography, v_point::geography) / 1000)::NUMERIC, 2) AS distance_km
FROM geonames_settlements g
WHERE g.feature_class = 'P'
AND g.feature_code IN ('PPL', 'PPLA', 'PPLA2', 'PPLA3', 'PPLA4', 'PPLC', 'PPLS', 'PPLG')
AND (p_country_code IS NULL OR g.country_code = p_country_code)
AND ST_DWithin(g.geom::geography, v_point::geography, p_max_distance_km * 1000)
ORDER BY g.geom <-> v_point
LIMIT 5;
END;
$$ LANGUAGE plpgsql;
-- Function: Compute service area from admin units
CREATE OR REPLACE FUNCTION compute_service_area_geometry(
p_admin2_ids INTEGER[],
p_admin1_ids INTEGER[] DEFAULT NULL
)
RETURNS GEOMETRY AS $$
DECLARE
v_geom GEOMETRY;
BEGIN
-- Union all admin2 geometries
IF p_admin2_ids IS NOT NULL AND array_length(p_admin2_ids, 1) > 0 THEN
SELECT ST_Union(geom) INTO v_geom
FROM boundary_admin2
WHERE id = ANY(p_admin2_ids)
AND valid_to IS NULL;
END IF;
-- Add admin1 geometries if specified
IF p_admin1_ids IS NOT NULL AND array_length(p_admin1_ids, 1) > 0 THEN
SELECT ST_Union(COALESCE(v_geom, 'GEOMETRYCOLLECTION EMPTY'::GEOMETRY), ST_Union(geom))
INTO v_geom
FROM boundary_admin1
WHERE id = ANY(p_admin1_ids)
AND valid_to IS NULL;
END IF;
RETURN v_geom;
END;
$$ LANGUAGE plpgsql;
-- Function: Get service area GeoJSON for a custodian
CREATE OR REPLACE FUNCTION get_custodian_service_area_geojson(
p_ghcid VARCHAR,
p_include_historical BOOLEAN DEFAULT FALSE
)
RETURNS JSONB AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_build_object(
'type', 'FeatureCollection',
'features', COALESCE(jsonb_agg(
jsonb_build_object(
'type', 'Feature',
'geometry', ST_AsGeoJSON(sa.geom)::jsonb,
'properties', jsonb_build_object(
'ghcid', sa.ghcid,
'name', sa.service_area_name,
'type', sa.service_area_type,
'is_historical', sa.is_historical,
'valid_from', sa.valid_from,
'valid_to', sa.valid_to,
'fill_color', sa.display_fill_color,
'fill_opacity', sa.display_fill_opacity,
'line_color', sa.display_line_color,
'line_width', sa.display_line_width
)
)
), '[]'::jsonb)
)
INTO v_result
FROM custodian_service_areas sa
WHERE sa.ghcid = p_ghcid
AND (p_include_historical OR sa.is_historical = FALSE);
RETURN v_result;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- Views for API Access
-- ============================================================================
-- Current admin1 boundaries
CREATE OR REPLACE VIEW v_current_admin1 AS
SELECT
a.id,
c.iso_a2 AS country_code,
a.iso_3166_2,
a.admin1_code,
a.admin1_name,
a.admin1_type,
a.geonames_id,
a.wikidata_id,
ST_AsGeoJSON(a.geom)::jsonb AS geometry,
ST_AsGeoJSON(a.centroid)::jsonb AS centroid,
a.area_km2,
s.source_code AS data_source
FROM boundary_admin1 a
JOIN boundary_countries c ON a.country_id = c.id
LEFT JOIN boundary_data_sources s ON a.source_id = s.id
WHERE a.valid_to IS NULL AND c.valid_to IS NULL;
-- Current admin2 boundaries
CREATE OR REPLACE VIEW v_current_admin2 AS
SELECT
a2.id,
c.iso_a2 AS country_code,
a1.iso_3166_2 AS admin1_iso,
a2.admin2_code,
a2.admin2_name,
a2.admin2_type,
a2.cbs_gemeente_code,
a2.nuts_code,
a2.lau_code,
a2.geonames_id,
a2.wikidata_id,
ST_AsGeoJSON(a2.geom)::jsonb AS geometry,
ST_AsGeoJSON(a2.centroid)::jsonb AS centroid,
a2.area_km2,
a2.population,
s.source_code AS data_source
FROM boundary_admin2 a2
JOIN boundary_admin1 a1 ON a2.admin1_id = a1.id
JOIN boundary_countries c ON a2.country_id = c.id
LEFT JOIN boundary_data_sources s ON a2.source_id = s.id
WHERE a2.valid_to IS NULL AND a1.valid_to IS NULL AND c.valid_to IS NULL;
-- Custodian service areas
CREATE OR REPLACE VIEW v_custodian_service_areas AS
SELECT
sa.ghcid,
sa.ghcid_uuid,
sa.service_area_name,
sa.service_area_type,
sa.is_historical,
sa.is_custom,
sa.valid_from,
sa.valid_to,
ST_AsGeoJSON(sa.geom)::jsonb AS geometry,
ST_AsGeoJSON(sa.centroid)::jsonb AS centroid,
sa.display_fill_color,
sa.display_fill_opacity,
sa.display_line_color,
sa.display_line_width,
sa.source_dataset,
sa.notes
FROM custodian_service_areas sa
WHERE sa.valid_to IS NULL;
-- ============================================================================
-- Statistics
-- ============================================================================
CREATE OR REPLACE VIEW v_boundary_stats AS
SELECT
'countries' AS table_name,
COUNT(*) AS total_rows,
COUNT(*) FILTER (WHERE valid_to IS NULL) AS current_rows
FROM boundary_countries
UNION ALL
SELECT 'admin1', COUNT(*), COUNT(*) FILTER (WHERE valid_to IS NULL) FROM boundary_admin1
UNION ALL
SELECT 'admin2', COUNT(*), COUNT(*) FILTER (WHERE valid_to IS NULL) FROM boundary_admin2
UNION ALL
SELECT 'historical', COUNT(*), COUNT(*) FROM boundary_historical
UNION ALL
SELECT 'service_areas', COUNT(*), COUNT(*) FILTER (WHERE valid_to IS NULL) FROM custodian_service_areas
UNION ALL
SELECT 'geonames_settlements', COUNT(*), COUNT(*) FROM geonames_settlements;
-- Country coverage summary
CREATE OR REPLACE VIEW v_boundary_coverage AS
SELECT
c.iso_a2,
c.country_name,
COUNT(DISTINCT a1.id) AS admin1_count,
COUNT(DISTINCT a2.id) AS admin2_count,
(SELECT COUNT(*) FROM geonames_settlements g WHERE g.country_code = c.iso_a2) AS settlement_count,
(SELECT COUNT(*) FROM custodian_service_areas sa WHERE sa.ghcid LIKE c.iso_a2 || '-%') AS service_area_count
FROM boundary_countries c
LEFT JOIN boundary_admin1 a1 ON a1.country_id = c.id AND a1.valid_to IS NULL
LEFT JOIN boundary_admin2 a2 ON a2.country_id = c.id AND a2.valid_to IS NULL
WHERE c.valid_to IS NULL
GROUP BY c.id, c.iso_a2, c.country_name
ORDER BY c.country_name;
-- ============================================================================
-- Permissions
-- ============================================================================
GRANT SELECT ON ALL TABLES IN SCHEMA public TO glam_api;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO glam_api;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO glam_api;
-- ============================================================================
-- Table Comments
-- ============================================================================
COMMENT ON TABLE boundary_data_sources IS 'Registry of data sources for boundary data (GADM, CBS, OSM, etc.)';
COMMENT ON TABLE boundary_countries IS 'Country boundaries with ISO 3166-1 codes';
COMMENT ON TABLE boundary_admin1 IS 'First-level administrative divisions (states, provinces, regions)';
COMMENT ON TABLE boundary_admin2 IS 'Second-level administrative divisions (municipalities, districts, counties)';
COMMENT ON TABLE boundary_historical IS 'Historical administrative boundaries (e.g., HALC 1500 data)';
COMMENT ON TABLE custodian_service_areas IS 'Pre-computed or custom service area geometries for heritage custodians';
COMMENT ON TABLE geonames_settlements IS 'GeoNames populated places for settlement lookups';
COMMENT ON FUNCTION find_admin_for_point IS 'Find administrative units containing a geographic point';
COMMENT ON FUNCTION find_nearest_settlement IS 'Find nearest GeoNames settlement to a point';
COMMENT ON FUNCTION compute_service_area_geometry IS 'Compute union geometry from admin unit IDs';
COMMENT ON FUNCTION get_custodian_service_area_geojson IS 'Get GeoJSON for a custodian service area';