-- 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();