158 lines
5.0 KiB
PL/PgSQL
158 lines
5.0 KiB
PL/PgSQL
/*
|
|
* -------------------------------------------------------
|
|
* Account Hierarchy Support
|
|
*
|
|
* Adds parent_account_id to accounts for Verein/Subverein/Verband
|
|
* hierarchy. This migration is additive — no behavior change until
|
|
* hierarchy-aware RLS policies are added in a future migration.
|
|
* -------------------------------------------------------
|
|
*/
|
|
|
|
-- Add nullable parent reference (team accounts only)
|
|
ALTER TABLE public.accounts
|
|
ADD COLUMN IF NOT EXISTS parent_account_id uuid
|
|
REFERENCES public.accounts(id) ON DELETE SET NULL;
|
|
|
|
CREATE INDEX IF NOT EXISTS ix_accounts_parent_account_id
|
|
ON public.accounts(parent_account_id);
|
|
|
|
-- Prevent personal accounts from having a parent (idempotent)
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint WHERE conname = 'accounts_personal_no_parent'
|
|
) THEN
|
|
ALTER TABLE public.accounts
|
|
ADD CONSTRAINT accounts_personal_no_parent
|
|
CHECK (is_personal_account = false OR parent_account_id IS NULL);
|
|
END IF;
|
|
END $$;
|
|
|
|
-- Prevent an account from being its own parent (idempotent)
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint WHERE conname = 'accounts_no_self_parent'
|
|
) THEN
|
|
ALTER TABLE public.accounts
|
|
ADD CONSTRAINT accounts_no_self_parent
|
|
CHECK (id <> parent_account_id);
|
|
END IF;
|
|
END $$;
|
|
|
|
-- -------------------------------------------------------
|
|
-- Trigger: prevent hierarchy cycles on insert/update
|
|
-- -------------------------------------------------------
|
|
CREATE OR REPLACE FUNCTION kit.prevent_account_hierarchy_cycle()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
BEGIN
|
|
IF NEW.parent_account_id IS NOT NULL THEN
|
|
-- Walk up from the proposed parent; if we find NEW.id, it's a cycle
|
|
IF EXISTS (
|
|
WITH RECURSIVE ancestors AS (
|
|
SELECT acc.id, acc.parent_account_id, ARRAY[acc.id] AS path
|
|
FROM public.accounts acc
|
|
WHERE acc.id = NEW.parent_account_id
|
|
UNION ALL
|
|
SELECT a.id, a.parent_account_id, anc.path || a.id
|
|
FROM public.accounts a
|
|
JOIN ancestors anc ON a.id = anc.parent_account_id
|
|
WHERE NOT a.id = ANY(anc.path) -- cycle guard
|
|
)
|
|
SELECT 1 FROM ancestors WHERE id = NEW.id
|
|
) THEN
|
|
RAISE EXCEPTION 'Setting parent_account_id would create a hierarchy cycle';
|
|
END IF;
|
|
END IF;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
DROP TRIGGER IF EXISTS prevent_account_hierarchy_cycle ON public.accounts;
|
|
|
|
CREATE TRIGGER prevent_account_hierarchy_cycle
|
|
BEFORE INSERT OR UPDATE OF parent_account_id
|
|
ON public.accounts
|
|
FOR EACH ROW
|
|
WHEN (NEW.parent_account_id IS NOT NULL)
|
|
EXECUTE FUNCTION kit.prevent_account_hierarchy_cycle();
|
|
|
|
-- -------------------------------------------------------
|
|
-- Helper: get all descendant account IDs (recursive, cycle-safe)
|
|
-- Uses path array for cycle detection (works on Postgres 14+)
|
|
-- Restricted to service_role — called via RLS SECURITY DEFINER functions
|
|
-- -------------------------------------------------------
|
|
CREATE OR REPLACE FUNCTION public.get_account_descendants(root_id uuid)
|
|
RETURNS SETOF uuid
|
|
LANGUAGE sql STABLE
|
|
SET search_path = ''
|
|
AS $$
|
|
WITH RECURSIVE tree AS (
|
|
SELECT acc.id, ARRAY[acc.id] AS path
|
|
FROM public.accounts acc WHERE acc.id = root_id
|
|
UNION ALL
|
|
SELECT a.id, t.path || a.id
|
|
FROM public.accounts a
|
|
JOIN tree t ON a.parent_account_id = t.id
|
|
WHERE NOT a.id = ANY(t.path)
|
|
)
|
|
SELECT tree.id FROM tree;
|
|
$$;
|
|
|
|
GRANT EXECUTE ON FUNCTION public.get_account_descendants(uuid)
|
|
TO service_role;
|
|
|
|
-- -------------------------------------------------------
|
|
-- Helper: get all ancestor account IDs (walk up, cycle-safe)
|
|
-- Restricted to service_role
|
|
-- -------------------------------------------------------
|
|
CREATE OR REPLACE FUNCTION public.get_account_ancestors(child_id uuid)
|
|
RETURNS SETOF uuid
|
|
LANGUAGE sql STABLE
|
|
SET search_path = ''
|
|
AS $$
|
|
WITH RECURSIVE tree AS (
|
|
SELECT acc.id, acc.parent_account_id, ARRAY[acc.id] AS path
|
|
FROM public.accounts acc WHERE acc.id = child_id
|
|
UNION ALL
|
|
SELECT a.id, a.parent_account_id, t.path || a.id
|
|
FROM public.accounts a
|
|
JOIN tree t ON a.id = t.parent_account_id
|
|
WHERE NOT a.id = ANY(t.path)
|
|
)
|
|
SELECT tree.id FROM tree;
|
|
$$;
|
|
|
|
GRANT EXECUTE ON FUNCTION public.get_account_ancestors(uuid)
|
|
TO service_role;
|
|
|
|
-- -------------------------------------------------------
|
|
-- Helper: get the depth of the hierarchy (0 = root, cycle-safe)
|
|
-- Restricted to service_role
|
|
-- -------------------------------------------------------
|
|
CREATE OR REPLACE FUNCTION public.get_account_depth(account_id uuid)
|
|
RETURNS integer
|
|
LANGUAGE sql STABLE
|
|
SET search_path = ''
|
|
AS $$
|
|
WITH RECURSIVE tree AS (
|
|
SELECT acc.id, acc.parent_account_id, 0 AS depth, ARRAY[acc.id] AS path
|
|
FROM public.accounts acc
|
|
WHERE acc.id = get_account_depth.account_id
|
|
UNION ALL
|
|
SELECT a.id, a.parent_account_id, t.depth + 1, t.path || a.id
|
|
FROM public.accounts a
|
|
JOIN tree t ON a.id = t.parent_account_id
|
|
WHERE NOT a.id = ANY(t.path)
|
|
)
|
|
SELECT MAX(depth) FROM tree;
|
|
$$;
|
|
|
|
GRANT EXECUTE ON FUNCTION public.get_account_depth(uuid)
|
|
TO service_role;
|