-- ============================================================================ -- PostGIS Schema for GLAM Heritage Institutions -- Database: glam_geo -- ============================================================================ -- Enable PostGIS extensions (should already be enabled) CREATE EXTENSION IF NOT EXISTS postgis; CREATE EXTENSION IF NOT EXISTS postgis_topology; -- ============================================================================ -- Administrative Boundaries -- ============================================================================ -- Provinces (admin level 1) CREATE TABLE IF NOT EXISTS provinces ( id SERIAL PRIMARY KEY, province_code VARCHAR(10) NOT NULL UNIQUE, -- e.g., "PV27" for Noord-Holland iso_code VARCHAR(5), -- ISO 3166-2 code, e.g., "NH" name VARCHAR(100) NOT NULL, name_local VARCHAR(100), country_code CHAR(2) NOT NULL DEFAULT 'NL', geom GEOMETRY(MULTIPOLYGON, 4326), centroid GEOMETRY(POINT, 4326), area_km2 NUMERIC(12, 2), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_provinces_geom ON provinces USING GIST (geom); CREATE INDEX IF NOT EXISTS idx_provinces_country ON provinces (country_code); -- Municipalities (admin level 2) CREATE TABLE IF NOT EXISTS municipalities ( id SERIAL PRIMARY KEY, municipality_code VARCHAR(10) NOT NULL, -- CBS code name VARCHAR(100) NOT NULL, name_local VARCHAR(100), province_id INTEGER REFERENCES provinces(id), country_code CHAR(2) NOT NULL DEFAULT 'NL', geom GEOMETRY(MULTIPOLYGON, 4326), centroid GEOMETRY(POINT, 4326), area_km2 NUMERIC(12, 2), population INTEGER, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(country_code, municipality_code) ); CREATE INDEX IF NOT EXISTS idx_municipalities_geom ON municipalities USING GIST (geom); CREATE INDEX IF NOT EXISTS idx_municipalities_province ON municipalities (province_id); -- Historical boundaries (for historical maps) CREATE TABLE IF NOT EXISTS historical_boundaries ( id SERIAL PRIMARY KEY, boundary_code VARCHAR(50) NOT NULL, name VARCHAR(200) NOT NULL, name_local VARCHAR(200), boundary_type VARCHAR(50) NOT NULL, -- 'territory', 'county', 'diocese', etc. valid_from DATE, -- Start of validity period valid_to DATE, -- End of validity period (NULL = current) reference_year INTEGER NOT NULL, -- e.g., 1500 for historical map country_code CHAR(2) NOT NULL DEFAULT 'NL', geom GEOMETRY(MULTIPOLYGON, 4326), centroid GEOMETRY(POINT, 4326), area_km2 NUMERIC(12, 2), source_dataset VARCHAR(100), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_historical_geom ON historical_boundaries USING GIST (geom); CREATE INDEX IF NOT EXISTS idx_historical_year ON historical_boundaries (reference_year); CREATE INDEX IF NOT EXISTS idx_historical_type ON historical_boundaries (boundary_type); -- ============================================================================ -- Heritage Institutions -- ============================================================================ -- Institution types enum CREATE TYPE institution_type AS ENUM ( 'G', -- Gallery 'L', -- Library 'A', -- Archive 'M', -- Museum 'O', -- Official institution 'R', -- Research center 'C', -- Corporation 'U', -- Unknown 'B', -- Botanical/Zoo 'E', -- Education provider 'S', -- Collecting society 'F', -- Features 'I', -- Intangible heritage group 'X', -- Mixed 'P', -- Personal collection 'H', -- Holy sites 'D', -- Digital platform 'N', -- NGO 'T' -- Taste/smell heritage ); -- Main institutions table CREATE TABLE IF NOT EXISTS institutions ( id SERIAL PRIMARY KEY, ghcid_current VARCHAR(50) NOT NULL UNIQUE, -- e.g., "NL-NH-AMS-M-RM" ghcid_uuid UUID NOT NULL UNIQUE, ghcid_numeric NUMERIC(20, 0), -- Larger than BIGINT to handle 64-bit unsigned hashes -- Names name VARCHAR(500) NOT NULL, name_verified VARCHAR(500), name_source VARCHAR(50), -- Type and classification institution_type institution_type NOT NULL, type_name VARCHAR(100), wikidata_types TEXT[], -- Location geom GEOMETRY(POINT, 4326), address TEXT, city VARCHAR(100), province VARCHAR(100), province_id INTEGER REFERENCES provinces(id), municipality_id INTEGER REFERENCES municipalities(id), country_code CHAR(2) NOT NULL DEFAULT 'NL', -- Metadata description TEXT, website VARCHAR(500), phone VARCHAR(50), -- External identifiers wikidata_id VARCHAR(20), google_place_id VARCHAR(100), isil_code VARCHAR(20), -- Enrichment data (JSONB for flexibility) reviews JSONB, rating NUMERIC(2, 1), total_ratings INTEGER, photos JSONB, -- Genealogy/Archive linkage genealogiewerkbalk JSONB, -- Business status business_status VARCHAR(50), founding_year INTEGER, founding_decade INTEGER, -- Timestamps created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_institutions_geom ON institutions USING GIST (geom); CREATE INDEX IF NOT EXISTS idx_institutions_ghcid ON institutions (ghcid_current); CREATE INDEX IF NOT EXISTS idx_institutions_type ON institutions (institution_type); CREATE INDEX IF NOT EXISTS idx_institutions_province ON institutions (province_id); CREATE INDEX IF NOT EXISTS idx_institutions_municipality ON institutions (municipality_id); CREATE INDEX IF NOT EXISTS idx_institutions_wikidata ON institutions (wikidata_id); CREATE INDEX IF NOT EXISTS idx_institutions_country ON institutions (country_code); -- Full text search on names and descriptions CREATE INDEX IF NOT EXISTS idx_institutions_name_fts ON institutions USING GIN (to_tsvector('simple', name || ' ' || COALESCE(description, ''))); -- ============================================================================ -- Archive Service Areas (werkgebied mapping) -- ============================================================================ CREATE TABLE IF NOT EXISTS archive_service_areas ( id SERIAL PRIMARY KEY, archive_isil VARCHAR(20) NOT NULL, archive_name VARCHAR(200) NOT NULL, archive_website VARCHAR(500), municipality_code VARCHAR(10) NOT NULL, municipality_name VARCHAR(100) NOT NULL, is_municipal BOOLEAN DEFAULT FALSE, is_provincial BOOLEAN DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE(archive_isil, municipality_code) ); CREATE INDEX IF NOT EXISTS idx_archive_service_isil ON archive_service_areas (archive_isil); CREATE INDEX IF NOT EXISTS idx_archive_service_municipality ON archive_service_areas (municipality_code); -- ============================================================================ -- Spatial Query Functions -- ============================================================================ -- Find institutions within a bounding box CREATE OR REPLACE FUNCTION find_institutions_in_bbox( min_lon FLOAT, min_lat FLOAT, max_lon FLOAT, max_lat FLOAT, inst_type institution_type DEFAULT NULL, max_results INTEGER DEFAULT 1000 ) RETURNS TABLE ( id INTEGER, ghcid_current VARCHAR, name VARCHAR, institution_type institution_type, type_name VARCHAR, lon FLOAT, lat FLOAT, city VARCHAR, province VARCHAR, rating NUMERIC, wikidata_id VARCHAR ) AS $$ BEGIN RETURN QUERY SELECT i.id, i.ghcid_current, i.name, i.institution_type, i.type_name, ST_X(i.geom)::FLOAT as lon, ST_Y(i.geom)::FLOAT as lat, i.city, i.province, i.rating, i.wikidata_id FROM institutions i WHERE i.geom && ST_MakeEnvelope(min_lon, min_lat, max_lon, max_lat, 4326) AND (inst_type IS NULL OR i.institution_type = inst_type) ORDER BY i.name LIMIT max_results; END; $$ LANGUAGE plpgsql; -- Find institutions within a radius of a point CREATE OR REPLACE FUNCTION find_institutions_near_point( lon FLOAT, lat FLOAT, radius_km FLOAT DEFAULT 10, inst_type institution_type DEFAULT NULL, max_results INTEGER DEFAULT 100 ) RETURNS TABLE ( id INTEGER, ghcid_current VARCHAR, name VARCHAR, institution_type institution_type, type_name VARCHAR, distance_km FLOAT, city VARCHAR, province VARCHAR, rating NUMERIC ) AS $$ BEGIN RETURN QUERY SELECT i.id, i.ghcid_current, i.name, i.institution_type, i.type_name, (ST_Distance( i.geom::geography, ST_SetSRID(ST_Point(lon, lat), 4326)::geography ) / 1000)::FLOAT as distance_km, i.city, i.province, i.rating FROM institutions i WHERE ST_DWithin( i.geom::geography, ST_SetSRID(ST_Point(lon, lat), 4326)::geography, radius_km * 1000 ) AND (inst_type IS NULL OR i.institution_type = inst_type) ORDER BY distance_km LIMIT max_results; END; $$ LANGUAGE plpgsql; -- Find which municipality/province contains a point CREATE OR REPLACE FUNCTION find_admin_for_point( lon FLOAT, lat FLOAT ) RETURNS TABLE ( province_code VARCHAR, province_name VARCHAR, municipality_code VARCHAR, municipality_name VARCHAR ) AS $$ BEGIN RETURN QUERY SELECT p.province_code, p.name as province_name, m.municipality_code, m.name as municipality_name FROM municipalities m JOIN provinces p ON m.province_id = p.id WHERE ST_Contains(m.geom, ST_SetSRID(ST_Point(lon, lat), 4326)) LIMIT 1; END; $$ LANGUAGE plpgsql; -- Get institutions by province as GeoJSON CREATE OR REPLACE FUNCTION get_institutions_geojson( province_filter VARCHAR DEFAULT NULL, type_filter institution_type DEFAULT NULL ) RETURNS JSON AS $$ BEGIN RETURN ( SELECT json_build_object( 'type', 'FeatureCollection', 'features', COALESCE(json_agg( json_build_object( 'type', 'Feature', 'geometry', ST_AsGeoJSON(i.geom)::json, 'properties', json_build_object( 'id', i.id, 'ghcid', i.ghcid_current, 'name', i.name, 'type', i.institution_type, 'type_name', i.type_name, 'city', i.city, 'province', i.province, 'rating', i.rating, 'wikidata_id', i.wikidata_id, 'website', i.website ) ) ), '[]') ) FROM institutions i WHERE (province_filter IS NULL OR i.province = province_filter) AND (type_filter IS NULL OR i.institution_type = type_filter) ); END; $$ LANGUAGE plpgsql; -- ============================================================================ -- Views for common queries -- ============================================================================ -- Institutions with province info CREATE OR REPLACE VIEW v_institutions_with_admin AS SELECT i.*, p.name as province_name, p.iso_code as province_iso, m.name as municipality_name FROM institutions i LEFT JOIN provinces p ON i.province_id = p.id LEFT JOIN municipalities m ON i.municipality_id = m.id; -- Institution counts by type and province CREATE OR REPLACE VIEW v_institution_stats AS SELECT i.province, i.institution_type, i.type_name, COUNT(*) as count, ROUND(AVG(i.rating), 2) as avg_rating FROM institutions i WHERE i.geom IS NOT NULL GROUP BY i.province, i.institution_type, i.type_name ORDER BY i.province, count DESC; -- ============================================================================ -- Metadata table -- ============================================================================ CREATE TABLE IF NOT EXISTS geo_metadata ( id SERIAL PRIMARY KEY, key VARCHAR(100) NOT NULL UNIQUE, value TEXT, updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); INSERT INTO geo_metadata (key, value) VALUES ('schema_version', '1.0.0'), ('created_at', NOW()::TEXT), ('description', 'PostGIS schema for GLAM heritage institutions') ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW(); -- ============================================================================ -- Grants (adjust user as needed) -- ============================================================================ -- Create API user if not exists DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'glam_api') THEN CREATE ROLE glam_api WITH LOGIN PASSWORD 'glam_secret_2025'; END IF; END $$; -- Grant permissions GRANT USAGE ON SCHEMA public TO glam_api; GRANT SELECT ON ALL TABLES IN SCHEMA public TO glam_api; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO glam_api; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO glam_api; -- Allow inserts for data loading (revoke in production if needed) GRANT INSERT, UPDATE ON institutions, provinces, municipalities, historical_boundaries, archive_service_areas TO glam_api;