From 04dc1185982ce66eb142794c8348792030efcf3a Mon Sep 17 00:00:00 2001 From: patrickzerhusen Date: Thu, 16 Apr 2026 16:44:24 +0200 Subject: [PATCH] added initial database schema migration --- migrations/001_initial_schema.sql | 169 ++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 migrations/001_initial_schema.sql diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql new file mode 100644 index 0000000..54a7574 --- /dev/null +++ b/migrations/001_initial_schema.sql @@ -0,0 +1,169 @@ +-- ===================================================================== +-- WebGIS Citizen Participation Portal — Initial Schema +-- Migration: 001_initial_schema.sql +-- Description: Creates Core Tables for a multi-tenant Citizen +-- Participation Platform with Point/Line/Polygon +-- Contributions, Voting, and Moderation Workflow. +-- ===================================================================== + + +-- --------------------------------------------------------------------- +-- Block 1: Checks PostGIS Extension +-- --------------------------------------------------------------------- +CREATE EXTENSION IF NOT EXISTS postgis; + + +-- --------------------------------------------------------------------- +-- Block 2: Creates Table "municipalities" +-- One Row per Municipalitiy using the Portal (multi-tenant setup). +-- --------------------------------------------------------------------- +CREATE TABLE municipalities ( + municipality_id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, -- Municipalitiy Name + slug VARCHAR(50) NOT NULL UNIQUE, -- URL-safe Identifier, e.g. "lohne" + center_lat DOUBLE PRECISION NOT NULL, -- Map Center Latitude + center_lng DOUBLE PRECISION NOT NULL, -- Map Center Longitude + default_zoom SMALLINT NOT NULL DEFAULT 13, -- Map Default Zoom Level + logo_path VARCHAR(255), -- Relative Path to Municipality Logo + primary_color VARCHAR(7) DEFAULT '#6a6a6a', -- HexColor for UI Theme + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE municipalities IS 'Configuration Per Municipality (Tenant) using the Citizen Participation Portal.'; + + +-- --------------------------------------------------------------------- +-- Block 3: Table "contributions" +-- Aitizen and Administration Contributions as Points, Lines, and +-- Polygons stored together in one mixed-geometry Column. +-- --------------------------------------------------------------------- +CREATE TABLE contributions ( + contribution_id SERIAL PRIMARY KEY, + municipality_id INTEGER NOT NULL REFERENCES municipalities(municipality_id) ON DELETE CASCADE, + geom GEOMETRY(Geometry, 4326) NOT NULL, -- Mixed Geometry: Point, Line, Polygon, ... (WGS84) + geom_type VARCHAR(20) NOT NULL, -- 'point' | 'line' | 'polygon' + category VARCHAR(50) NOT NULL, -- Contribution Category + title VARCHAR(200) NOT NULL, + description TEXT, + author_name VARCHAR(100) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + likes_count INTEGER NOT NULL DEFAULT 0, + dislikes_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT contributions_geom_type_check + CHECK (geom_type IN ('point', 'line', 'polygon')), + CONSTRAINT contributions_status_check + CHECK (status IN ('pending', 'approved', 'rejected', 'in_progress', 'done')) +); + +COMMENT ON TABLE contributions IS 'Citizen and Administration Contributions with mixed Geometry Types.'; + + +-- --------------------------------------------------------------------- +-- Block 4: Indexes for fast Queries +-- --------------------------------------------------------------------- +CREATE INDEX contributions_geom_idx ON contributions USING GIST (geom); +CREATE INDEX contributions_municipality_idx ON contributions (municipality_id); +CREATE INDEX contributions_status_idx ON contributions (status); +CREATE INDEX contributions_category_idx ON contributions (category); + + +-- --------------------------------------------------------------------- +-- Block 5: Table "votes" +-- Individual like and dislike Records. UNIQUE Constraint prevents the +-- same voter from liking or disliking the same contribution multiple times. +-- --------------------------------------------------------------------- +CREATE TABLE votes ( + vote_id SERIAL PRIMARY KEY, + contribution_id INTEGER NOT NULL REFERENCES contributions(contribution_id) ON DELETE CASCADE, + voter_name VARCHAR(100) NOT NULL, -- ToDo: Replace with user_id once Authentification exists + vote_type VARCHAR(10) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT votes_unique_per_voter UNIQUE (contribution_id, voter_name), + CONSTRAINT votes_vote_type_check CHECK (vote_type IN ('like', 'dislike')) +); + +COMMENT ON TABLE votes IS 'Individual Votes to prevent duplicate Likes and Dislikes.'; + + +-- --------------------------------------------------------------------- +-- Block 6: Trigger Functions +-- --------------------------------------------------------------------- + +-- Automatically Refresh updated_at on every UPDATE. +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER contributions_updated_at + BEFORE UPDATE ON contributions + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TRIGGER municipalities_updated_at + BEFORE UPDATE ON municipalities + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + +-- Keeps likes_count / dislikes_count synchronized with the votes Table. +CREATE OR REPLACE FUNCTION update_vote_counts() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.vote_type = 'like' THEN + UPDATE contributions SET likes_count = likes_count + 1 + WHERE contribution_id = NEW.contribution_id; + ELSE + UPDATE contributions SET dislikes_count = dislikes_count + 1 + WHERE contribution_id = NEW.contribution_id; + END IF; + ELSIF TG_OP = 'DELETE' THEN + IF OLD.vote_type = 'like' THEN + UPDATE contributions SET likes_count = GREATEST(likes_count - 1, 0) + WHERE contribution_id = OLD.contribution_id; + ELSE + UPDATE contributions SET dislikes_count = GREATEST(dislikes_count - 1, 0) + WHERE contribution_id = OLD.contribution_id; + END IF; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER votes_count_sync + AFTER INSERT OR DELETE ON votes + FOR EACH ROW EXECUTE FUNCTION update_vote_counts(); + + +-- --------------------------------------------------------------------- +-- Block 7: Typed Geometry Views for QGIS +-- QGIS handles mixed-geometry Tables awkwardly, so one View per +-- Geometry Type is created. Reflects live Data from the Contributions Table. +-- --------------------------------------------------------------------- +CREATE VIEW contributions_points AS + SELECT * FROM contributions WHERE geom_type = 'point'; + +CREATE VIEW contributions_lines AS + SELECT * FROM contributions WHERE geom_type = 'line'; + +CREATE VIEW contributions_polygons AS + SELECT * FROM contributions WHERE geom_type = 'polygon'; + + +-- --------------------------------------------------------------------- +-- Block 8: Seed Data — Initial Municipality +-- --------------------------------------------------------------------- +INSERT INTO municipalities (name, slug, center_lat, center_lng, default_zoom, primary_color) +VALUES ('Lohne (Oldenburg)', 'lohne', 52.66639, 8.23306, 14, '#00376D'); + + +-- ===================================================================== +-- End of migration 001_initial_schema.sql +-- ===================================================================== \ No newline at end of file