diff --git a/internal/db/migrations/021_fix_function_bodies_after_rename.down.sql b/internal/db/migrations/021_fix_function_bodies_after_rename.down.sql new file mode 100644 index 0000000..8276bbc --- /dev/null +++ b/internal/db/migrations/021_fix_function_bodies_after_rename.down.sql @@ -0,0 +1,131 @@ +-- Best-effort revert of 021. NOT a clean roll-back — this migration only +-- restores the broken German-bodied versions of can_see_project / +-- note_is_visible / projekte_*; if the underlying tables have already been +-- renamed to English (i.e. 020 hasn't been rolled back yet), the German +-- bodies will still error. Run 020 / 018 down first if you really want to +-- get back to a working German state. +-- +-- We do not recreate the dropped policies under their previous German names; +-- migration 018 / 020 down rebuilds the German-named policy set, and the +-- English-named policies created by 021.up are the right starting point for +-- those rollbacks. + +-- --------------------------------------------------------------------------- +-- Revert trigger functions +-- --------------------------------------------------------------------------- +DROP TRIGGER IF EXISTS projects_sync_path_before ON paliad.projects; +DROP TRIGGER IF EXISTS projects_rewrite_subtree_after ON paliad.projects; +DROP FUNCTION IF EXISTS paliad.projects_sync_path() CASCADE; +DROP FUNCTION IF EXISTS paliad.projects_rewrite_subtree() CASCADE; + +CREATE OR REPLACE FUNCTION paliad.projekte_sync_path() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + parent_path text; +BEGIN + IF NEW.parent_id IS NULL THEN + NEW.path := NEW.id::text; + ELSE + SELECT path INTO parent_path + FROM paliad.projekte + WHERE id = NEW.parent_id; + IF parent_path IS NULL THEN + RAISE EXCEPTION 'parent projekt % not found', NEW.parent_id; + END IF; + IF parent_path = NEW.id::text + OR parent_path LIKE '%.' || NEW.id::text + OR parent_path LIKE NEW.id::text || '.%' + OR parent_path LIKE '%.' || NEW.id::text || '.%' + THEN + RAISE EXCEPTION 'cannot set parent to own descendant'; + END IF; + NEW.path := parent_path || '.' || NEW.id::text; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION paliad.projekte_rewrite_subtree() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF OLD.path IS DISTINCT FROM NEW.path THEN + UPDATE paliad.projekte + SET path = NEW.path || substring(path FROM length(OLD.path) + 1) + WHERE path LIKE OLD.path || '.%'; + END IF; + RETURN NEW; +END; +$$; + +-- The triggers can only be bound to a table that exists; pick whichever of +-- paliad.projekte / paliad.projects is currently around. +DO $$ BEGIN + EXECUTE 'CREATE TRIGGER projekte_sync_path_before BEFORE INSERT OR UPDATE OF parent_id ON paliad.projekte FOR EACH ROW EXECUTE FUNCTION paliad.projekte_sync_path()'; +EXCEPTION WHEN undefined_table THEN + EXECUTE 'CREATE TRIGGER projekte_sync_path_before BEFORE INSERT OR UPDATE OF parent_id ON paliad.projects FOR EACH ROW EXECUTE FUNCTION paliad.projekte_sync_path()'; +END $$; + +DO $$ BEGIN + EXECUTE 'CREATE TRIGGER projekte_rewrite_subtree_after AFTER UPDATE OF path ON paliad.projekte FOR EACH ROW EXECUTE FUNCTION paliad.projekte_rewrite_subtree()'; +EXCEPTION WHEN undefined_table THEN + EXECUTE 'CREATE TRIGGER projekte_rewrite_subtree_after AFTER UPDATE OF path ON paliad.projects FOR EACH ROW EXECUTE FUNCTION paliad.projekte_rewrite_subtree()'; +END $$; + +-- --------------------------------------------------------------------------- +-- Revert visibility functions to their pre-021 (broken) state +-- --------------------------------------------------------------------------- +DROP FUNCTION IF EXISTS paliad.note_is_visible(uuid, uuid, uuid, uuid) CASCADE; +DROP FUNCTION IF EXISTS paliad.can_see_project(uuid) CASCADE; + +CREATE FUNCTION paliad.can_see_project(_projekt_id uuid) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = paliad, public +AS $$ + SELECT EXISTS ( + SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.role = 'admin' + ) + OR EXISTS ( + SELECT 1 + FROM paliad.projekte target + JOIN paliad.projekt_teams pt + ON pt.user_id = auth.uid() + AND pt.projekt_id = ANY(string_to_array(target.path, '.')::uuid[]) + WHERE target.id = _projekt_id + ); +$$; + +CREATE FUNCTION paliad.note_is_visible( + _projekt_id uuid, + _frist_id uuid, + _termin_id uuid, + _projekt_event_id uuid +) RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = paliad, public +AS $$ + SELECT CASE + WHEN _projekt_id IS NOT NULL THEN paliad.can_see_projekt(_projekt_id) + WHEN _frist_id IS NOT NULL THEN paliad.can_see_projekt( + (SELECT projekt_id FROM paliad.fristen WHERE id = _frist_id)) + WHEN _termin_id IS NOT NULL THEN + CASE + WHEN (SELECT projekt_id FROM paliad.termine WHERE id = _termin_id) IS NULL + THEN (SELECT created_by FROM paliad.termine WHERE id = _termin_id) = auth.uid() + ELSE paliad.can_see_projekt( + (SELECT projekt_id FROM paliad.termine WHERE id = _termin_id)) + END + WHEN _projekt_event_id IS NOT NULL THEN paliad.can_see_projekt( + (SELECT projekt_id FROM paliad.projekt_events WHERE id = _projekt_event_id)) + ELSE false + END; +$$; diff --git a/internal/db/migrations/021_fix_function_bodies_after_rename.up.sql b/internal/db/migrations/021_fix_function_bodies_after_rename.up.sql new file mode 100644 index 0000000..dd9c080 --- /dev/null +++ b/internal/db/migrations/021_fix_function_bodies_after_rename.up.sql @@ -0,0 +1,316 @@ +-- Hotfix for migration 020: the German→English rename renamed function NAMES +-- via ALTER FUNCTION but never rewrote the function BODIES. On production +-- (which had been seeded from the original German-named migration 018), the +-- bodies still referenced paliad.projekte / paliad.projekt_teams / +-- paliad.fristen / paliad.termine / paliad.projekt_events / the German +-- column names — all of which had been renamed/dropped in 018+020. Any +-- RLS-enforced query against the new English tables exploded with +-- `relation "paliad.projekte" does not exist`, breaking /api/projects, +-- /api/deadlines, /api/appointments, etc. +-- +-- The trigger functions paliad.projekte_sync_path() and +-- paliad.projekte_rewrite_subtree() suffered the same fate — kept their +-- German names and German bodies. Triggers on paliad.projects still pointed +-- at them, so any INSERT/UPDATE on projects tripped them too. +-- +-- Strategy: DROP FUNCTION ... CASCADE drops the broken function and its +-- dependent RLS policies (whose names are still German on production — +-- projekte_*, projekt_teams_*, fristen_all, termine_*, parteien_all, +-- dokumente_all, projekt_events_all, notizen_all). Recreate the functions +-- with English bodies + English parameter names, then rebuild every +-- dependent policy under its canonical English name (matching migration +-- 018). Trigger functions are dropped+recreated under English names with +-- English-table bodies; triggers are recreated to point at the new names. +-- +-- Idempotent: on a fresh DB everything is already English, so the CASCADE +-- drops the same English-named policies and they're recreated identically. + +-- --------------------------------------------------------------------------- +-- 1. Drop broken visibility functions (CASCADE drops dependent policies). +-- --------------------------------------------------------------------------- +DROP FUNCTION IF EXISTS paliad.note_is_visible(uuid, uuid, uuid, uuid) CASCADE; +DROP FUNCTION IF EXISTS paliad.can_see_project(uuid) CASCADE; + +-- --------------------------------------------------------------------------- +-- 2. Recreate can_see_project() with English body + English parameter. +-- --------------------------------------------------------------------------- +CREATE FUNCTION paliad.can_see_project(_project_id uuid) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = paliad, public +AS $$ + SELECT EXISTS ( + SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.role = 'admin' + ) + OR EXISTS ( + -- Any team membership (direct or ancestor). `path` always includes + -- the target's own id as the last label, so this handles direct too. + SELECT 1 + FROM paliad.projects target + JOIN paliad.project_teams pt + ON pt.user_id = auth.uid() + AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[]) + WHERE target.id = _project_id + ); +$$; + +COMMENT ON FUNCTION paliad.can_see_project(uuid) IS + 'Team-based visibility predicate for paliad.projects. Direct or inherited ' + '(ancestor) membership grants access. Admins see all.'; + +-- --------------------------------------------------------------------------- +-- 3. Recreate note_is_visible() with English body + English parameters. +-- --------------------------------------------------------------------------- +CREATE FUNCTION paliad.note_is_visible( + _project_id uuid, + _deadline_id uuid, + _appointment_id uuid, + _project_event_id uuid +) RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = paliad, public +AS $$ + SELECT CASE + WHEN _project_id IS NOT NULL THEN paliad.can_see_project(_project_id) + WHEN _deadline_id IS NOT NULL THEN paliad.can_see_project( + (SELECT project_id FROM paliad.deadlines WHERE id = _deadline_id)) + WHEN _appointment_id IS NOT NULL THEN + CASE + WHEN (SELECT project_id FROM paliad.appointments WHERE id = _appointment_id) IS NULL + THEN (SELECT created_by FROM paliad.appointments WHERE id = _appointment_id) = auth.uid() + ELSE paliad.can_see_project( + (SELECT project_id FROM paliad.appointments WHERE id = _appointment_id)) + END + WHEN _project_event_id IS NOT NULL THEN paliad.can_see_project( + (SELECT project_id FROM paliad.project_events WHERE id = _project_event_id)) + ELSE false + END; +$$; + +-- --------------------------------------------------------------------------- +-- 4. Rebuild RLS policies that the CASCADE just dropped. Names match the +-- canonical English set in migration 018. On production the dropped +-- policies were named projekte_*, projekt_teams_*, fristen_all, +-- termine_*, parteien_all, dokumente_all, projekt_events_all, +-- notizen_all — those names are gone after this migration. +-- --------------------------------------------------------------------------- + +-- projects ------------------------------------------------------------------ +CREATE POLICY projects_select ON paliad.projects + FOR SELECT TO authenticated + USING (paliad.can_see_project(id)); + +CREATE POLICY projects_insert ON paliad.projects + FOR INSERT TO authenticated + WITH CHECK ( + parent_id IS NULL + OR paliad.can_see_project(parent_id) + ); + +CREATE POLICY projects_update ON paliad.projects + FOR UPDATE TO authenticated + USING (paliad.can_see_project(id)) + WITH CHECK (paliad.can_see_project(id)); + +CREATE POLICY projects_delete ON paliad.projects + FOR DELETE TO authenticated + USING ( + paliad.can_see_project(id) + AND EXISTS ( + SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.role IN ('partner','admin') + ) + ); + +-- project_teams ------------------------------------------------------------- +CREATE POLICY project_teams_select ON paliad.project_teams + FOR SELECT TO authenticated + USING (paliad.can_see_project(project_id)); + +CREATE POLICY project_teams_insert ON paliad.project_teams + FOR INSERT TO authenticated + WITH CHECK ( + user_id = auth.uid() + OR paliad.can_see_project(project_id) + ); + +CREATE POLICY project_teams_update ON paliad.project_teams + FOR UPDATE TO authenticated + USING (paliad.can_see_project(project_id)) + WITH CHECK (paliad.can_see_project(project_id)); + +CREATE POLICY project_teams_delete ON paliad.project_teams + FOR DELETE TO authenticated + USING ( + paliad.can_see_project(project_id) + AND ( + user_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.role IN ('partner','admin') + ) + ) + ); + +-- parties / deadlines / documents / project_events -------------------------- +CREATE POLICY parties_all ON paliad.parties + FOR ALL TO authenticated + USING (paliad.can_see_project(project_id)) + WITH CHECK (paliad.can_see_project(project_id)); + +CREATE POLICY deadlines_all ON paliad.deadlines + FOR ALL TO authenticated + USING (paliad.can_see_project(project_id)) + WITH CHECK (paliad.can_see_project(project_id)); + +CREATE POLICY documents_all ON paliad.documents + FOR ALL TO authenticated + USING (paliad.can_see_project(project_id)) + WITH CHECK (paliad.can_see_project(project_id)); + +CREATE POLICY project_events_all ON paliad.project_events + FOR ALL TO authenticated + USING (paliad.can_see_project(project_id)) + WITH CHECK (paliad.can_see_project(project_id)); + +-- appointments (project_id nullable; personal appts stay creator-only) ----- +CREATE POLICY appointments_select ON paliad.appointments + FOR SELECT TO authenticated + USING ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ); + +CREATE POLICY appointments_insert ON paliad.appointments + FOR INSERT TO authenticated + WITH CHECK ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ); + +CREATE POLICY appointments_update ON paliad.appointments + FOR UPDATE TO authenticated + USING ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ) + WITH CHECK ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ); + +CREATE POLICY appointments_delete ON paliad.appointments + FOR DELETE TO authenticated + USING ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ); + +-- notes (polymorphic parent dispatch) -------------------------------------- +CREATE POLICY notes_all ON paliad.notes + FOR ALL TO authenticated + USING (paliad.note_is_visible(project_id, deadline_id, appointment_id, project_event_id)) + WITH CHECK (paliad.note_is_visible(project_id, deadline_id, appointment_id, project_event_id)); + +-- checklist_instances (project_id nullable; personal stays creator-only) --- +CREATE POLICY checklist_instances_select ON paliad.checklist_instances + FOR SELECT TO authenticated + USING ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ); + +CREATE POLICY checklist_instances_insert ON paliad.checklist_instances + FOR INSERT TO authenticated + WITH CHECK ( + created_by = auth.uid() + AND (project_id IS NULL OR paliad.can_see_project(project_id)) + ); + +CREATE POLICY checklist_instances_update ON paliad.checklist_instances + FOR UPDATE TO authenticated + USING ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ) + WITH CHECK ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ); + +CREATE POLICY checklist_instances_delete ON paliad.checklist_instances + FOR DELETE TO authenticated + USING ( + (project_id IS NULL AND created_by = auth.uid()) + OR (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + ); + +-- --------------------------------------------------------------------------- +-- 5. Trigger functions: drop the German-named pair (and their bound +-- triggers), recreate under English names with English-table bodies, +-- rebind the triggers. Migration 020 missed these entirely. +-- --------------------------------------------------------------------------- +DROP TRIGGER IF EXISTS projekte_sync_path_before ON paliad.projects; +DROP TRIGGER IF EXISTS projekte_rewrite_subtree_after ON paliad.projects; +DROP FUNCTION IF EXISTS paliad.projekte_sync_path() CASCADE; +DROP FUNCTION IF EXISTS paliad.projekte_rewrite_subtree() CASCADE; + +CREATE OR REPLACE FUNCTION paliad.projects_sync_path() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + parent_path text; +BEGIN + IF NEW.parent_id IS NULL THEN + NEW.path := NEW.id::text; + ELSE + SELECT path INTO parent_path + FROM paliad.projects + WHERE id = NEW.parent_id; + IF parent_path IS NULL THEN + RAISE EXCEPTION 'parent project % not found', NEW.parent_id; + END IF; + -- Reject cycles: parent's path cannot contain this row's id. + IF parent_path = NEW.id::text + OR parent_path LIKE '%.' || NEW.id::text + OR parent_path LIKE NEW.id::text || '.%' + OR parent_path LIKE '%.' || NEW.id::text || '.%' + THEN + RAISE EXCEPTION 'cannot set parent to own descendant'; + END IF; + NEW.path := parent_path || '.' || NEW.id::text; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION paliad.projects_rewrite_subtree() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF OLD.path IS DISTINCT FROM NEW.path THEN + UPDATE paliad.projects + SET path = NEW.path || substring(path FROM length(OLD.path) + 1) + WHERE path LIKE OLD.path || '.%'; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS projects_sync_path_before ON paliad.projects; +DROP TRIGGER IF EXISTS projects_rewrite_subtree_after ON paliad.projects; + +CREATE TRIGGER projects_sync_path_before + BEFORE INSERT OR UPDATE OF parent_id ON paliad.projects + FOR EACH ROW EXECUTE FUNCTION paliad.projects_sync_path(); + +CREATE TRIGGER projects_rewrite_subtree_after + AFTER UPDATE OF path ON paliad.projects + FOR EACH ROW EXECUTE FUNCTION paliad.projects_rewrite_subtree();