-- ============================================================================ -- LUPMIS2 — external layer definitions -- -- Stores the layers a user adds through the map's "Add External Layer" dialog, -- so that they persist, can be shared within a district, and survive a -- re-install of the application. -- -- Two tables: -- -- spatial.hlp_layer_types the kinds of layer that can be added -- (wms, wfs, xyz, cog, and whatever comes next) -- spatial.es_external_layers one row per layer a user has added -- -- The four fields the dialog collects are layer_type, url, layer_name and -- title. Everything else in es_external_layers exists for one of three reasons, -- marked in the column comments: -- -- [dialog] captured directly from the Add External Layer form -- [parity] needed to describe the external layers the application already -- hard-codes; without these, moving those layers into the database -- would lose information the map currently relies on -- [admin] ownership, scoping, ordering and audit -- -- Target: PostgreSQL 10+ (uses GENERATED ALWAYS AS IDENTITY). -- Schema: `spatial` is used for consistency with the rest of LUPMIS, although -- neither table holds geometry — see the notes at the end. -- -- NOTE ON SEQUENCING. The application still hard-codes the four layer types in -- the Add External Layer dialog and will keep doing so until the endpoint in -- note 4 exists. Until then hlp_layer_types is the database's own record, and -- the two must be kept in step by hand: adding a row here does not make the -- type appear in the dialog, and adding a radio button there without a row -- here will be rejected by the foreign key. -- ============================================================================ -- ============================================================================ -- 1. Helper table — the available layer types -- -- This exists so a new kind of layer (WMTS, ArcGIS REST, vector tiles, a -- GeoPackage URL) can be introduced by inserting a row, with no change to the -- schema, no deployment and no code release on the database side. -- -- `hlp_` marks it as a lookup/helper table rather than data. -- ============================================================================ CREATE TABLE spatial.hlp_layer_types ( code text PRIMARY KEY, -- the value stored in es_external_layers.layer_type and -- sent in the API payload: lowercase, no spaces label text NOT NULL, -- what the dialog shows on the button, e.g. 'WMS' description text, -- one line explaining the type, for a tooltip or docs -- ---- rules the client and the database both need ----------------------- requires_layer_name boolean NOT NULL DEFAULT false, -- true for WMS/WFS, where a service publishes many -- layers and one must be named. This is the rule that -- used to be a hard-coded CHECK constraint. layer_name_label text, -- what to call that field for this type, e.g. -- 'WMS LAYERS parameter' vs 'WFS typename' url_placeholder text, -- example URL shown in the empty form field hint text, -- extra guidance shown when the type is selected -- ---- housekeeping ------------------------------------------------------ sort_order integer NOT NULL DEFAULT 0, is_active boolean NOT NULL DEFAULT true, -- false retires a type: no new layers may use it, but -- existing layers of that type keep working created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), -- The code travels through URLs, JSON payloads and CSS class names, so it -- is restricted to a plain lowercase identifier. CONSTRAINT hlp_layer_types_code_chk CHECK (code ~ '^[a-z][a-z0-9_]*$') ); COMMENT ON TABLE spatial.hlp_layer_types IS 'Lookup of layer types available in the Add External Layer dialog. A new ' 'type is added by inserting a row — no schema change required.'; COMMENT ON COLUMN spatial.hlp_layer_types.requires_layer_name IS 'Whether es_external_layers.layer_name is mandatory for this type. ' 'Enforced by the trigger on that table, because a CHECK constraint cannot ' 'read another table.'; COMMENT ON COLUMN spatial.hlp_layer_types.is_active IS 'false stops new layers being created with this type without invalidating ' 'the ones that already exist.'; INSERT INTO spatial.hlp_layer_types (code, label, description, requires_layer_name, layer_name_label, url_placeholder, hint, sort_order) VALUES ('wms', 'WMS', 'OGC Web Map Service — server-rendered map images.', true, 'WMS LAYERS parameter (e.g. workspace:layer)', 'https://example.com/wms', NULL, 10), ('wfs', 'WFS', 'OGC Web Feature Service — vector features as GeoJSON.', true, 'WFS typename (e.g. workspace:layer)', 'https://example.com/wfs', NULL, 20), ('xyz', 'XYZ', 'Pre-rendered tile pyramid addressed by z/x/y.', false, NULL, 'https://example.com/tiles/{z}/{x}/{y}.png', NULL, 30), ('cog', 'COG', 'Cloud-Optimized GeoTIFF streamed by byte-range request.', false, NULL, 'https://example.com/data/elevation_cog.tif', 'The server must allow CORS and byte-range requests.', 40); -- ============================================================================ -- 2. The layers themselves -- ============================================================================ CREATE TABLE spatial.es_external_layers ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- ---- from the Add External Layer dialog ------------------------------- layer_type text NOT NULL REFERENCES spatial.hlp_layer_types (code) ON UPDATE CASCADE ON DELETE RESTRICT, -- [dialog] see hlp_layer_types url text NOT NULL, -- [dialog] service or file URL layer_name text, -- [dialog] required when the type -- says so; see the trigger title text NOT NULL, -- [dialog] name in the layer switcher -- ---- scoping and ownership ------------------------------------------- districtid integer, -- [admin] NULL = every district userid integer NOT NULL, -- [admin] who added it is_shared boolean NOT NULL DEFAULT false, -- [admin] false = creator only; -- true = the district -- ---- rendering ------------------------------------------------------- layer_group_id integer NOT NULL DEFAULT 8, -- [admin] 8 = "External Sources" style_name text, -- [parity] WMS STYLES parameter server_type text, -- [parity] geoserver | mapserver | qgis | NULL opacity numeric(3,2) NOT NULL DEFAULT 1.00, -- [parity] z_index integer, -- [parity] negative sits behind overlays visible_by_default boolean NOT NULL DEFAULT false, -- [parity] online_only boolean NOT NULL DEFAULT false, -- [parity] warn when toggled on offline attribution text, -- [parity] credit/licence; often required legend_url text, -- [parity] legend shown while visible min_zoom smallint, -- [parity] max_zoom smallint, -- [parity] -- ---- lifecycle and audit --------------------------------------------- description text, -- [admin] free note about the source is_active boolean NOT NULL DEFAULT true, -- [admin] soft delete sort_order integer NOT NULL DEFAULT 0, -- [admin] created_at timestamptz NOT NULL DEFAULT now(), -- [admin] updated_at timestamptz NOT NULL DEFAULT now(), -- [admin] updated_by integer, -- [admin] -- ---- constraints ------------------------------------------------------ -- layer_type is validated by the foreign key above, and the conditional -- layer_name rule by the trigger below, because a CHECK constraint cannot -- read another table. -- The PWA is served over HTTPS, so a browser blocks any http:// sub-resource -- as mixed content. An http URL cannot render, it just fails silently — so -- it is rejected here rather than stored and puzzled over later. CONSTRAINT es_external_layers_https_chk CHECK (url ~* '^https://'), CONSTRAINT es_external_layers_opacity_chk CHECK (opacity >= 0 AND opacity <= 1), CONSTRAINT es_external_layers_zoom_chk CHECK (min_zoom IS NULL OR max_zoom IS NULL OR min_zoom <= max_zoom) ); -- --------------------------------------------------------------------------- -- The rule that moved out of the schema and into the data -- -- `requires_layer_name` now lives in hlp_layer_types, so it has to be enforced -- somewhere that can read it. A CHECK constraint cannot; a trigger can. -- -- It raises check_violation deliberately, so any API error handling that -- already distinguishes constraint failures keeps working unchanged. -- -- The is_active test applies to INSERT only: retiring a type should stop new -- layers being created with it without freezing the ones that already exist. -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION spatial.es_external_layers_validate() RETURNS trigger AS $$ DECLARE t spatial.hlp_layer_types%ROWTYPE; BEGIN SELECT * INTO t FROM spatial.hlp_layer_types WHERE code = NEW.layer_type; -- The foreign key guarantees a row exists, so no NOT FOUND branch here. IF TG_OP = 'INSERT' AND NOT t.is_active THEN RAISE EXCEPTION 'Layer type "%" is retired and cannot be used for new layers', NEW.layer_type USING ERRCODE = 'check_violation'; END IF; IF t.requires_layer_name AND (NEW.layer_name IS NULL OR btrim(NEW.layer_name) = '') THEN RAISE EXCEPTION 'Layer type "%" requires layer_name (%)', NEW.layer_type, COALESCE(t.layer_name_label, 'layer name') USING ERRCODE = 'check_violation'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER es_external_layers_validate_trg BEFORE INSERT OR UPDATE ON spatial.es_external_layers FOR EACH ROW EXECUTE FUNCTION spatial.es_external_layers_validate(); -- --------------------------------------------------------------------------- -- Foreign keys to districts and users -- -- Left commented because the referenced table and column names were not -- verifiable from the application side — it only ever sees districts and users -- through the API. Confirm them, then enable: -- -- SELECT table_schema, table_name FROM information_schema.tables -- WHERE table_name ILIKE '%district%' OR table_name ILIKE '%user%'; -- -- ON DELETE RESTRICT for the district: removing a district that still has -- layers attached should be a decision, not a side effect. -- --------------------------------------------------------------------------- -- ALTER TABLE spatial.es_external_layers -- ADD CONSTRAINT es_external_layers_district_fk -- FOREIGN KEY (districtid) REFERENCES spatial.districts (districtid) -- ON DELETE RESTRICT; -- -- ALTER TABLE spatial.es_external_layers -- ADD CONSTRAINT es_external_layers_user_fk -- FOREIGN KEY (userid) REFERENCES public.users (id) -- ON DELETE RESTRICT; -- --------------------------------------------------------------------------- -- Stop the same layer being added twice -- -- COALESCE is needed because NULL never equals NULL: without it a duplicate -- would be allowed whenever districtid or layer_name is NULL, which is exactly -- the XYZ and COG case. -- --------------------------------------------------------------------------- CREATE UNIQUE INDEX es_external_layers_unique_idx ON spatial.es_external_layers ( COALESCE(districtid, -1), layer_type, url, COALESCE(layer_name, '') ) WHERE is_active; -- --------------------------------------------------------------------------- -- Indexes for the read path -- --------------------------------------------------------------------------- -- The endpoint's main query: everything this district may see, which is the -- district's own layers plus the global ones (districtid IS NULL). CREATE INDEX es_external_layers_district_idx ON spatial.es_external_layers (districtid, sort_order) WHERE is_active; CREATE INDEX es_external_layers_user_idx ON spatial.es_external_layers (userid) WHERE is_active; -- Supports the ON DELETE RESTRICT check on hlp_layer_types, and "how many -- layers use this type" questions. CREATE INDEX es_external_layers_type_idx ON spatial.es_external_layers (layer_type); -- --------------------------------------------------------------------------- -- Keep updated_at honest, on both tables -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION spatial.touch_updated_at() RETURNS trigger AS $$ BEGIN NEW.updated_at := now(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER es_external_layers_touch_trg BEFORE UPDATE ON spatial.es_external_layers FOR EACH ROW EXECUTE FUNCTION spatial.touch_updated_at(); CREATE TRIGGER hlp_layer_types_touch_trg BEFORE UPDATE ON spatial.hlp_layer_types FOR EACH ROW EXECUTE FUNCTION spatial.touch_updated_at(); -- --------------------------------------------------------------------------- -- Documentation that travels with the schema -- --------------------------------------------------------------------------- COMMENT ON TABLE spatial.es_external_layers IS 'External map layers added by users through the Add External Layer dialog. ' 'One row per layer. Holds no geometry — the data stays on the remote server ' 'and is fetched by the client at display time.'; COMMENT ON COLUMN spatial.es_external_layers.layer_type IS 'References spatial.hlp_layer_types.code. Determines how the client builds ' 'the request and which other columns apply.'; COMMENT ON COLUMN spatial.es_external_layers.url IS 'WMS/WFS service endpoint, XYZ template with {z}/{x}/{y}, or the URL of a ' 'Cloud-Optimized GeoTIFF. Must be https — see the mixed-content constraint.'; COMMENT ON COLUMN spatial.es_external_layers.layer_name IS 'WMS LAYERS parameter or WFS typename, e.g. workspace:layer. Required only ' 'for types whose hlp_layer_types.requires_layer_name is true.'; COMMENT ON COLUMN spatial.es_external_layers.districtid IS 'NULL means the layer is offered in every district. A value restricts it ' 'to that district.'; COMMENT ON COLUMN spatial.es_external_layers.is_shared IS 'false: visible only to the user who created it. true: visible to everyone ' 'within its district scope.'; COMMENT ON COLUMN spatial.es_external_layers.is_active IS 'Soft delete. The dialog marks user-added layers as removable, and a hard ' 'delete would silently break the map for colleagues who rely on a shared ' 'layer, with no way to tell what disappeared.'; COMMENT ON COLUMN spatial.es_external_layers.online_only IS 'The layer needs connectivity. The client uses this to explain, rather ' 'than show an empty layer, when it is switched on in the field.'; COMMENT ON COLUMN spatial.es_external_layers.z_index IS 'Render order. Negative values push a layer behind the standard overlays — ' 'used for background imagery such as slope or hillshade.'; -- --------------------------------------------------------------------------- -- Examples -- -- The first is a layer the application currently hard-codes. It is the reason -- for the [parity] columns: without style_name, opacity, z_index, attribution, -- legend_url and online_only, this layer could not be represented here. -- --------------------------------------------------------------------------- INSERT INTO spatial.es_external_layers (layer_type, url, layer_name, title, districtid, userid, is_shared, style_name, server_type, opacity, z_index, visible_by_default, online_only, attribution, legend_url, description) VALUES ('wms', 'https://ows.digitalearth.africa/wms', 'srtm_deriv', 'DEAfrica Slope (SRTM 30m)', NULL, -- every district 1, true, 'style_slope', NULL, -- not a GeoServer; serverType must stay unset 0.50, -50, -- behind the standard overlays false, true, '© Digital Earth Africa — SRTM-derived Slope', 'https://ows.digitalearth.africa/legend/srtm_deriv/style_slope/legend.png', 'Terrain slope derived from SRTM, served by Digital Earth Africa.'); -- A raster published to the LUSPA object store: no layer name, single file. INSERT INTO spatial.es_external_layers (layer_type, url, title, districtid, userid, is_shared, description) VALUES ('cog', 'https://minioapi.lupmis4luspa.org/raster-objects/dem/koforidua_dem.tif', 'Koforidua DEM', 1, 1, true, 'Digital elevation model for the district, published as a Cloud-Optimized GeoTIFF.'); -- ============================================================================ -- Adding a layer type later -- -- This is the whole point of the helper table. No schema change, no migration: -- -- INSERT INTO spatial.hlp_layer_types -- (code, label, description, requires_layer_name, layer_name_label, -- url_placeholder, sort_order) -- VALUES -- ('wmts', 'WMTS', 'OGC Web Map Tile Service — pre-rendered tiles.', -- true, 'WMTS layer identifier', -- 'https://example.com/wmts', 50); -- -- Retiring one, without invalidating the layers already using it: -- -- UPDATE spatial.hlp_layer_types SET is_active = false WHERE code = 'wfs'; -- -- Deleting a type that is still in use is refused by the foreign key, which is -- the intended behaviour — retire it instead. -- ============================================================================ -- ============================================================================ -- Notes for the database team -- -- 1. Schema choice. `spatial` matches the rest of LUPMIS, but neither table -- holds geometry — they are configuration. If there is a schema for -- application settings they belong there instead; only the qualifier changes. -- -- 2. Prefixes. `hlp_` marks a helper/lookup table, as requested. `es_` follows -- the existing convention of naming a table after its layer group (`be_` -- biophysical, `lu_` land use, `pi_` physical infrastructure); group 8 is -- "External Sources". -- -- 3. The client still hard-codes the four types. Until the endpoint below -- exists, the dialog in src/components/MapView.js and the rows in -- hlp_layer_types must be changed together. Adding a row here alone will not -- surface the type in the application; adding a radio button there alone -- will fail the foreign key on save. -- -- 4. Suggested endpoints, in the shape of the existing spatial_planning ones -- ({ api_token, district_id } in, { success, data: [...] } out): -- -- get_layer_types.php rows where is_active, ordered by sort_order -- — this is what lets the dialog stop -- hard-coding the list -- get_external_layers.php rows where is_active -- and (districtid = :district OR districtid IS NULL) -- and (is_shared OR userid = :user) -- ordered by sort_order, title -- save_external_layer.php insert or update one row -- delete_external_layer.php sets is_active = false, never DELETE -- -- 5. Authorisation is the part worth deciding before this ships. The read query -- above assumes a user may see their own layers plus shared ones in their -- district. Who may edit or deactivate a *shared* layer — only its creator, -- or any user in the district — is a policy question this table can support -- either way, through userid. -- -- 6. The url column holds a value the browser will fetch. Two consequences: -- the https constraint is functional, not cosmetic; and the endpoint should -- validate the URL server-side as well, since a stored URL is effectively an -- instruction to every client that loads the layer. -- ============================================================================