feat: add cross-organization member search and template cloning functionality

This commit is contained in:
T. Zehetbauer
2026-04-01 10:15:35 +02:00
parent d3db316a68
commit fd8c2cc32a
36 changed files with 9025 additions and 94 deletions

View File

@@ -0,0 +1,13 @@
/*
* Pre-module enum values: add all module-specific permission enum values
* in a separate transaction. Postgres requires new enum values to be
* committed before they can be used in policies/functions.
*
* Covers: fischerei, sitzungsprotokolle, verbandsverwaltung
*/
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'fischerei.read';
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'fischerei.write';
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'meetings.read';
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'meetings.write';
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'verband.read';
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'verband.write';

View File

@@ -40,11 +40,10 @@ CREATE TYPE public.fish_size_category AS ENUM(
-- =====================================================
-- 2. Extend app_permissions
-- (Moved to 20260411900001_fischerei_enum_values.sql — Postgres requires
-- new enum values to be committed in a separate transaction)
-- =====================================================
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'fischerei.read';
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'fischerei.write';
-- =====================================================
-- 3. cost_centers (shared, may already exist)
-- =====================================================

View File

@@ -7,11 +7,9 @@
-- =====================================================
-- 1. Extend app_permissions
-- (Moved to 20260411900001_fischerei_enum_values.sql)
-- =====================================================
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'meetings.read';
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'meetings.write';
-- =====================================================
-- 2. Enums
-- =====================================================

View File

@@ -8,11 +8,9 @@
-- =====================================================
-- 1. Extend app_permissions
-- (Moved to 20260411900001_fischerei_enum_values.sql)
-- =====================================================
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'verband.read';
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'verband.write';
-- =====================================================
-- 2. association_types (Verbandsarten)
-- =====================================================

View File

@@ -54,16 +54,16 @@ BEGIN
-- Walk up from the proposed parent; if we find NEW.id, it's a cycle
IF EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id, parent_account_id
FROM public.accounts
WHERE id = NEW.parent_account_id
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
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
)
CYCLE id SET is_cycle USING path
SELECT 1 FROM ancestors WHERE id = NEW.id AND NOT is_cycle
SELECT 1 FROM ancestors WHERE id = NEW.id
) THEN
RAISE EXCEPTION 'Setting parent_account_id would create a hierarchy cycle';
END IF;
@@ -73,7 +73,9 @@ BEGIN
END;
$$;
CREATE OR REPLACE TRIGGER prevent_account_hierarchy_cycle
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
@@ -82,7 +84,8 @@ CREATE OR REPLACE TRIGGER prevent_account_hierarchy_cycle
-- -------------------------------------------------------
-- Helper: get all descendant account IDs (recursive, cycle-safe)
-- Restricted to service_role — called via RLS helper functions
-- 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
@@ -90,15 +93,15 @@ LANGUAGE sql STABLE
SET search_path = ''
AS $$
WITH RECURSIVE tree AS (
SELECT id, parent_account_id
FROM public.accounts WHERE id = root_id
SELECT acc.id, ARRAY[acc.id] AS path
FROM public.accounts acc WHERE acc.id = root_id
UNION ALL
SELECT a.id, a.parent_account_id
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)
)
CYCLE id SET is_cycle USING path
SELECT id FROM tree WHERE NOT is_cycle;
SELECT tree.id FROM tree;
$$;
GRANT EXECUTE ON FUNCTION public.get_account_descendants(uuid)
@@ -106,7 +109,7 @@ GRANT EXECUTE ON FUNCTION public.get_account_descendants(uuid)
-- -------------------------------------------------------
-- Helper: get all ancestor account IDs (walk up, cycle-safe)
-- Restricted to service_role — called via RLS helper functions
-- Restricted to service_role
-- -------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_account_ancestors(child_id uuid)
RETURNS SETOF uuid
@@ -114,15 +117,15 @@ LANGUAGE sql STABLE
SET search_path = ''
AS $$
WITH RECURSIVE tree AS (
SELECT id, parent_account_id
FROM public.accounts WHERE id = child_id
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
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)
)
CYCLE id SET is_cycle USING path
SELECT id FROM tree WHERE NOT is_cycle;
SELECT tree.id FROM tree;
$$;
GRANT EXECUTE ON FUNCTION public.get_account_ancestors(uuid)
@@ -138,16 +141,16 @@ LANGUAGE sql STABLE
SET search_path = ''
AS $$
WITH RECURSIVE tree AS (
SELECT id, parent_account_id, 0 AS depth
FROM public.accounts
WHERE id = account_id
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
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)
)
CYCLE id SET is_cycle USING path
SELECT MAX(depth) FROM tree WHERE NOT is_cycle;
SELECT MAX(depth) FROM tree;
$$;
GRANT EXECUTE ON FUNCTION public.get_account_depth(uuid)

View File

@@ -30,7 +30,7 @@ AS $$
FROM public.accounts_memberships membership
WHERE membership.user_id = (SELECT auth.uid())
AND membership.account_id IN (
SELECT id FROM public.get_account_ancestors(target_account_id)
SELECT public.get_account_ancestors(target_account_id)
)
AND (
membership.account_role = has_role_on_account_or_ancestor.account_role
@@ -62,7 +62,7 @@ BEGIN
JOIN public.role_permissions rp ON am.account_role = rp.role
WHERE am.user_id = has_permission_or_ancestor.user_id
AND am.account_id IN (
SELECT id FROM public.get_account_ancestors(target_account_id)
SELECT public.get_account_ancestors(target_account_id)
)
AND rp.permission = has_permission_or_ancestor.permission_name
);

View File

@@ -0,0 +1,109 @@
/*
* -------------------------------------------------------
* Cross-Organization Member Search
*
* Enables Verband admins to search members across all
* descendant accounts in the hierarchy. Uses the existing
* members_hierarchy_read RLS policy for access control.
* -------------------------------------------------------
*/
-- Enable pg_trgm for trigram-based text search
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Full-text search index on member names for fast lookups
CREATE INDEX IF NOT EXISTS ix_members_name_trgm
ON public.members USING gin (
(lower(first_name || ' ' || last_name)) gin_trgm_ops
);
-- Search members across a hierarchy with text search + filters
CREATE OR REPLACE FUNCTION public.search_members_across_hierarchy(
root_account_id uuid,
search_term text DEFAULT NULL,
status_filter text DEFAULT NULL,
account_filter uuid DEFAULT NULL,
page_number int DEFAULT 1,
page_size int DEFAULT 25
)
RETURNS TABLE (
id uuid,
account_id uuid,
account_name varchar,
account_slug text,
member_number text,
first_name text,
last_name text,
email text,
phone text,
city text,
status public.membership_status,
entry_date date,
total_count bigint
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_offset int;
v_total bigint;
BEGIN
-- Verify caller has a role on the root account
IF NOT public.has_role_on_account(root_account_id) THEN
RETURN;
END IF;
v_offset := (page_number - 1) * page_size;
-- Count total matches first
SELECT count(*) INTO v_total
FROM public.members m
JOIN public.accounts a ON a.id = m.account_id
WHERE m.account_id IN (
SELECT d.id FROM public.get_account_descendants(root_account_id) d(id)
)
AND (search_term IS NULL OR search_term = '' OR
lower(m.first_name || ' ' || m.last_name) LIKE '%' || lower(search_term) || '%' OR
lower(m.email) LIKE '%' || lower(search_term) || '%' OR
m.member_number ILIKE '%' || search_term || '%'
)
AND (status_filter IS NULL OR m.status = status_filter::public.membership_status)
AND (account_filter IS NULL OR m.account_id = account_filter);
-- Return results with total count
RETURN QUERY
SELECT
m.id,
m.account_id,
a.name AS account_name,
a.slug AS account_slug,
m.member_number,
m.first_name,
m.last_name,
m.email,
m.phone,
m.city,
m.status,
m.entry_date,
v_total AS total_count
FROM public.members m
JOIN public.accounts a ON a.id = m.account_id
WHERE m.account_id IN (
SELECT d.id FROM public.get_account_descendants(root_account_id) d(id)
)
AND (search_term IS NULL OR search_term = '' OR
lower(m.first_name || ' ' || m.last_name) LIKE '%' || lower(search_term) || '%' OR
lower(m.email) LIKE '%' || lower(search_term) || '%' OR
m.member_number ILIKE '%' || search_term || '%'
)
AND (status_filter IS NULL OR m.status = status_filter::public.membership_status)
AND (account_filter IS NULL OR m.account_id = account_filter)
ORDER BY m.last_name, m.first_name
LIMIT page_size
OFFSET v_offset;
END;
$$;
GRANT EXECUTE ON FUNCTION public.search_members_across_hierarchy(uuid, text, text, uuid, int, int)
TO authenticated, service_role;

View File

@@ -0,0 +1,198 @@
/*
* -------------------------------------------------------
* Member Transfer Between Accounts
*
* Enables transferring a member from one Verein to another
* within the same Verband hierarchy.
*
* Design:
* - Personal data always moves with the member
* - Course enrollments, event registrations, bookings are
* linked via member_id (not account_id) so they survive
* the transfer automatically
* - Only org-specific admin data is cleared:
* dues_category_id (org-specific pricing),
* member_number (unique per org)
* - SEPA bank data (IBAN/BIC) is preserved, but mandate
* status resets to 'pending' (needs re-confirmation)
* - Financial records (invoices, SEPA batches) stay in
* source org — they're legally tied to that entity
* -------------------------------------------------------
*/
-- Transfer log table
CREATE TABLE IF NOT EXISTS public.member_transfers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
source_account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE SET NULL,
target_account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE SET NULL,
transferred_by uuid NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
reason text,
-- Snapshot what was cleared so it can be reviewed later
cleared_data jsonb NOT NULL DEFAULT '{}'::jsonb,
transferred_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_member_transfers_member
ON public.member_transfers(member_id);
CREATE INDEX IF NOT EXISTS ix_member_transfers_source
ON public.member_transfers(source_account_id);
CREATE INDEX IF NOT EXISTS ix_member_transfers_target
ON public.member_transfers(target_account_id);
ALTER TABLE public.member_transfers ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.member_transfers FROM authenticated, service_role;
GRANT SELECT, INSERT ON public.member_transfers TO authenticated;
GRANT ALL ON public.member_transfers TO service_role;
-- Readable by members of source or target account (or ancestor via hierarchy)
CREATE POLICY member_transfers_read ON public.member_transfers
FOR SELECT TO authenticated
USING (
public.has_role_on_account_or_ancestor(source_account_id) OR
public.has_role_on_account_or_ancestor(target_account_id)
);
-- -------------------------------------------------------
-- Transfer function
--
-- Only clears org-specific admin data. All cross-org
-- relationships (courses, events, bookings) survive because
-- they reference member_id, not account_id.
-- -------------------------------------------------------
CREATE OR REPLACE FUNCTION public.transfer_member(
p_member_id uuid,
p_target_account_id uuid,
p_reason text DEFAULT NULL,
p_keep_sepa boolean DEFAULT true
)
RETURNS uuid -- returns the transfer log ID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_source_account_id uuid;
v_transfer_id uuid;
v_caller_id uuid;
v_old_member_number text;
v_old_dues_category_id uuid;
v_old_sepa_mandate_id text;
v_source_name text;
v_target_name text;
v_active_courses int;
v_active_events int;
BEGIN
v_caller_id := (SELECT auth.uid());
-- Get the member's current account and data we'll clear
SELECT account_id, member_number, dues_category_id, sepa_mandate_id
INTO v_source_account_id, v_old_member_number, v_old_dues_category_id, v_old_sepa_mandate_id
FROM public.members
WHERE id = p_member_id;
IF v_source_account_id IS NULL THEN
RAISE EXCEPTION 'Mitglied nicht gefunden';
END IF;
IF v_source_account_id = p_target_account_id THEN
RAISE EXCEPTION 'Mitglied ist bereits in dieser Organisation';
END IF;
-- Target must be a team account
IF NOT EXISTS (
SELECT 1 FROM public.accounts
WHERE id = p_target_account_id AND is_personal_account = false
) THEN
RAISE EXCEPTION 'Zielorganisation nicht gefunden';
END IF;
-- Verify caller has visibility on BOTH accounts via hierarchy
IF NOT (
public.has_role_on_account_or_ancestor(v_source_account_id)
AND public.has_role_on_account_or_ancestor(p_target_account_id)
) THEN
RAISE EXCEPTION 'Keine Berechtigung für den Transfer';
END IF;
-- Verify both accounts share a common ancestor (same Verband)
IF NOT EXISTS (
SELECT 1
FROM public.get_account_ancestors(v_source_account_id) sa
JOIN public.get_account_ancestors(p_target_account_id) ta ON sa = ta
) THEN
RAISE EXCEPTION 'Organisationen gehören nicht zum selben Verband';
END IF;
-- Get org names for the transfer note
SELECT name INTO v_source_name FROM public.accounts WHERE id = v_source_account_id;
SELECT name INTO v_target_name FROM public.accounts WHERE id = p_target_account_id;
-- Count active relationships (informational, for the log)
SELECT count(*) INTO v_active_courses
FROM public.course_participants cp
JOIN public.courses c ON c.id = cp.course_id
WHERE cp.member_id = p_member_id AND cp.status = 'enrolled';
SELECT count(*) INTO v_active_events
FROM public.event_registrations er
JOIN public.events e ON e.id = er.event_id
WHERE er.email = (SELECT email FROM public.members WHERE id = p_member_id)
AND er.status IN ('confirmed', 'pending')
AND e.event_date >= current_date;
-- Perform the transfer
UPDATE public.members
SET
account_id = p_target_account_id,
-- Clear org-specific admin data
dues_category_id = NULL,
member_number = NULL,
-- SEPA: keep bank data (IBAN/BIC/account_holder), just reset mandate status
sepa_mandate_id = CASE WHEN p_keep_sepa THEN sepa_mandate_id ELSE NULL END,
sepa_mandate_date = CASE WHEN p_keep_sepa THEN sepa_mandate_date ELSE NULL END,
sepa_mandate_status = 'pending', -- always needs re-confirmation in new org
-- Append transfer note
notes = COALESCE(notes, '') ||
E'\n[Transfer ' || now()::date || '] ' ||
v_source_name || '' || v_target_name ||
COALESCE(E'' || p_reason, '') ||
CASE WHEN v_active_courses > 0
THEN E'\n ↳ ' || v_active_courses || ' aktive Kurseinschreibungen bleiben erhalten'
ELSE '' END ||
CASE WHEN v_active_events > 0
THEN E'\n ↳ ' || v_active_events || ' aktive Veranstaltungsanmeldungen bleiben erhalten'
ELSE '' END,
updated_by = v_caller_id,
updated_at = now()
WHERE id = p_member_id;
-- Log the transfer with snapshot of cleared data
INSERT INTO public.member_transfers (
member_id, source_account_id, target_account_id, transferred_by, reason, cleared_data
) VALUES (
p_member_id,
v_source_account_id,
p_target_account_id,
v_caller_id,
p_reason,
jsonb_build_object(
'old_member_number', v_old_member_number,
'old_dues_category_id', v_old_dues_category_id,
'old_sepa_mandate_id', v_old_sepa_mandate_id,
'active_courses_at_transfer', v_active_courses,
'active_events_at_transfer', v_active_events,
'sepa_kept', p_keep_sepa
)
)
RETURNING id INTO v_transfer_id;
RETURN v_transfer_id;
END;
$$;
GRANT EXECUTE ON FUNCTION public.transfer_member(uuid, uuid, text, boolean)
TO authenticated, service_role;

View File

@@ -0,0 +1,270 @@
/*
* -------------------------------------------------------
* Feature: Shared Events, Consolidated SEPA, Reporting
*
* 1. Shared events: flag on events to share across hierarchy
* 2. Consolidated SEPA: Verband-level batch across child accounts
* 3. Reporting: aggregated stats RPC functions
* -------------------------------------------------------
*/
-- =====================================================
-- 1. Shared Events
-- =====================================================
-- Flag to share an event with the entire hierarchy
ALTER TABLE public.events
ADD COLUMN IF NOT EXISTS shared_with_hierarchy boolean NOT NULL DEFAULT false;
-- Hierarchy-aware event listing: events visible to current user
-- either directly (own account) or via hierarchy sharing
CREATE OR REPLACE FUNCTION public.list_hierarchy_events(
root_account_id uuid,
p_from_date date DEFAULT NULL,
p_status text DEFAULT NULL,
p_shared_only boolean DEFAULT false,
p_page int DEFAULT 1,
p_page_size int DEFAULT 25
)
RETURNS TABLE (
id uuid,
account_id uuid,
account_name varchar,
name text,
description text,
event_date date,
event_time time,
end_date date,
location text,
capacity integer,
fee numeric,
status text,
registration_deadline date,
registration_count bigint,
shared_with_hierarchy boolean,
total_count bigint
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_offset int;
v_total bigint;
BEGIN
IF NOT public.has_role_on_account(root_account_id) THEN
RETURN;
END IF;
v_offset := (p_page - 1) * p_page_size;
-- Count
SELECT count(*) INTO v_total
FROM public.events e
WHERE e.account_id IN (SELECT d FROM public.get_account_descendants(root_account_id) d)
AND (NOT p_shared_only OR e.shared_with_hierarchy = true)
AND (p_from_date IS NULL OR e.event_date >= p_from_date)
AND (p_status IS NULL OR e.status = p_status);
RETURN QUERY
SELECT
e.id,
e.account_id,
a.name AS account_name,
e.name,
e.description,
e.event_date,
e.event_time,
e.end_date,
e.location,
e.capacity,
e.fee,
e.status,
e.registration_deadline,
(SELECT count(*) FROM public.event_registrations er WHERE er.event_id = e.id AND er.status IN ('confirmed', 'pending'))::bigint AS registration_count,
e.shared_with_hierarchy,
v_total AS total_count
FROM public.events e
JOIN public.accounts a ON a.id = e.account_id
WHERE e.account_id IN (SELECT d FROM public.get_account_descendants(root_account_id) d)
AND (NOT p_shared_only OR e.shared_with_hierarchy = true)
AND (p_from_date IS NULL OR e.event_date >= p_from_date)
AND (p_status IS NULL OR e.status = p_status)
ORDER BY e.event_date ASC
LIMIT p_page_size OFFSET v_offset;
END;
$$;
GRANT EXECUTE ON FUNCTION public.list_hierarchy_events(uuid, date, text, boolean, int, int)
TO authenticated, service_role;
-- =====================================================
-- 2. Consolidated SEPA Billing
-- =====================================================
-- Track which child accounts are included in a consolidated batch
ALTER TABLE public.sepa_batches
ADD COLUMN IF NOT EXISTS is_consolidated boolean NOT NULL DEFAULT false;
-- RPC: gather members with active SEPA mandates across hierarchy
CREATE OR REPLACE FUNCTION public.list_hierarchy_sepa_eligible_members(
root_account_id uuid,
p_account_filter uuid DEFAULT NULL
)
RETURNS TABLE (
member_id uuid,
account_id uuid,
account_name varchar,
first_name text,
last_name text,
iban text,
bic text,
account_holder text,
mandate_id text,
mandate_date date,
dues_amount numeric
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF NOT public.has_role_on_account(root_account_id) THEN
RETURN;
END IF;
RETURN QUERY
SELECT
m.id AS member_id,
m.account_id,
a.name AS account_name,
m.first_name,
m.last_name,
m.iban,
m.bic,
m.account_holder,
m.sepa_mandate_id AS mandate_id,
m.sepa_mandate_date AS mandate_date,
COALESCE(dc.amount, 0) AS dues_amount
FROM public.members m
JOIN public.accounts a ON a.id = m.account_id
LEFT JOIN public.dues_categories dc ON dc.id = m.dues_category_id
WHERE m.account_id IN (SELECT d FROM public.get_account_descendants(root_account_id) d)
AND m.status = 'active'
AND m.iban IS NOT NULL
AND m.sepa_mandate_status = 'active'
AND (p_account_filter IS NULL OR m.account_id = p_account_filter)
ORDER BY a.name, m.last_name, m.first_name;
END;
$$;
GRANT EXECUTE ON FUNCTION public.list_hierarchy_sepa_eligible_members(uuid, uuid)
TO authenticated, service_role;
-- =====================================================
-- 3. Aggregated Reporting
-- =====================================================
-- Comprehensive stats across the hierarchy
CREATE OR REPLACE FUNCTION public.get_hierarchy_report(
root_account_id uuid
)
RETURNS TABLE (
org_id uuid,
org_name varchar,
org_slug text,
depth int,
active_members bigint,
inactive_members bigint,
total_members bigint,
new_members_this_year bigint,
active_courses bigint,
upcoming_events bigint,
open_invoices bigint,
open_invoice_amount numeric,
sepa_batches_this_year bigint
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF NOT public.has_role_on_account(root_account_id) THEN
RETURN;
END IF;
RETURN QUERY
WITH descendants AS (
SELECT d AS id FROM public.get_account_descendants(root_account_id) d
)
SELECT
a.id AS org_id,
a.name AS org_name,
a.slug AS org_slug,
(SELECT public.get_account_depth(a.id)) AS depth,
-- Members
(SELECT count(*) FROM public.members m WHERE m.account_id = a.id AND m.status = 'active')::bigint AS active_members,
(SELECT count(*) FROM public.members m WHERE m.account_id = a.id AND m.status != 'active')::bigint AS inactive_members,
(SELECT count(*) FROM public.members m WHERE m.account_id = a.id)::bigint AS total_members,
(SELECT count(*) FROM public.members m WHERE m.account_id = a.id AND m.entry_date >= date_trunc('year', current_date))::bigint AS new_members_this_year,
-- Courses
(SELECT count(*) FROM public.courses c WHERE c.account_id = a.id AND c.end_date >= current_date)::bigint AS active_courses,
-- Events
(SELECT count(*) FROM public.events e WHERE e.account_id = a.id AND e.event_date >= current_date AND e.status IN ('planned', 'open'))::bigint AS upcoming_events,
-- Finance
(SELECT count(*) FROM public.invoices i WHERE i.account_id = a.id AND i.status IN ('sent', 'overdue'))::bigint AS open_invoices,
COALESCE((SELECT sum(i.total_amount - i.paid_amount) FROM public.invoices i WHERE i.account_id = a.id AND i.status IN ('sent', 'overdue')), 0) AS open_invoice_amount,
(SELECT count(*) FROM public.sepa_batches sb WHERE sb.account_id = a.id AND sb.created_at >= date_trunc('year', current_date))::bigint AS sepa_batches_this_year
FROM public.accounts a
JOIN descendants d ON d.id = a.id
WHERE a.is_personal_account = false
ORDER BY a.name;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_hierarchy_report(uuid)
TO authenticated, service_role;
-- Summary totals across entire hierarchy
CREATE OR REPLACE FUNCTION public.get_hierarchy_summary(
root_account_id uuid
)
RETURNS TABLE (
total_orgs bigint,
total_active_members bigint,
total_members bigint,
new_members_this_year bigint,
total_upcoming_events bigint,
total_active_courses bigint,
total_open_invoices bigint,
total_open_invoice_amount numeric,
total_sepa_batches_this_year bigint
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF NOT public.has_role_on_account(root_account_id) THEN
RETURN;
END IF;
RETURN QUERY
WITH desc_ids AS (
SELECT d AS id FROM public.get_account_descendants(root_account_id) d
)
SELECT
(SELECT count(*) FROM desc_ids di JOIN public.accounts ac ON ac.id = di.id WHERE ac.is_personal_account = false)::bigint,
(SELECT count(*) FROM public.members m WHERE m.account_id IN (SELECT di.id FROM desc_ids di) AND m.status = 'active')::bigint,
(SELECT count(*) FROM public.members m WHERE m.account_id IN (SELECT di.id FROM desc_ids di))::bigint,
(SELECT count(*) FROM public.members m WHERE m.account_id IN (SELECT di.id FROM desc_ids di) AND m.entry_date >= date_trunc('year', current_date))::bigint,
(SELECT count(*) FROM public.events e WHERE e.account_id IN (SELECT di.id FROM desc_ids di) AND e.event_date >= current_date AND e.status IN ('planned', 'open'))::bigint,
(SELECT count(*) FROM public.courses c WHERE c.account_id IN (SELECT di.id FROM desc_ids di) AND c.end_date >= current_date)::bigint,
(SELECT count(*) FROM public.invoices i WHERE i.account_id IN (SELECT di.id FROM desc_ids di) AND i.status IN ('sent', 'overdue'))::bigint,
COALESCE((SELECT sum(i.total_amount - i.paid_amount) FROM public.invoices i WHERE i.account_id IN (SELECT di.id FROM desc_ids di) AND i.status IN ('sent', 'overdue')), 0),
(SELECT count(*) FROM public.sepa_batches sb WHERE sb.account_id IN (SELECT di.id FROM desc_ids di) AND sb.created_at >= date_trunc('year', current_date))::bigint;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_hierarchy_summary(uuid)
TO authenticated, service_role;

View File

@@ -0,0 +1,282 @@
/*
* -------------------------------------------------------
* Shared Templates Across Hierarchy
*
* 1. Add shared_with_hierarchy flag to newsletter_templates
* 2. Create document_templates table with hierarchy sharing
* 3. Template cloning function for child orgs
* 4. Hierarchy-aware RLS for template reads
* -------------------------------------------------------
*/
-- =====================================================
-- 1. Newsletter Templates: add sharing flag
-- =====================================================
ALTER TABLE public.newsletter_templates
ADD COLUMN IF NOT EXISTS shared_with_hierarchy boolean NOT NULL DEFAULT false;
-- Allow child orgs to read parent's shared templates
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'public' AND tablename = 'newsletter_templates'
AND policyname = 'newsletter_templates_hierarchy_read'
) THEN
EXECUTE 'CREATE POLICY newsletter_templates_hierarchy_read
ON public.newsletter_templates
FOR SELECT TO authenticated
USING (
shared_with_hierarchy = true
AND public.has_role_on_account_or_ancestor(account_id)
)';
END IF;
END $$;
-- =====================================================
-- 2. Document Templates table
-- =====================================================
CREATE TABLE IF NOT EXISTS public.document_templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
template_type text NOT NULL DEFAULT 'generic'
CHECK (template_type IN (
'generic', 'member_card', 'invoice', 'receipt',
'certificate', 'letter', 'label', 'report'
)),
-- Template content: HTML with variable placeholders like {{member.first_name}}
body_html text NOT NULL DEFAULT '',
-- Available variables for this template
variables jsonb NOT NULL DEFAULT '[]'::jsonb,
-- Page settings
page_format text NOT NULL DEFAULT 'A4'
CHECK (page_format IN ('A4', 'A5', 'A6', 'letter', 'label')),
orientation text NOT NULL DEFAULT 'portrait'
CHECK (orientation IN ('portrait', 'landscape')),
-- Hierarchy sharing
shared_with_hierarchy boolean NOT NULL DEFAULT false,
-- Meta
is_default boolean NOT NULL DEFAULT false,
sort_order integer NOT NULL DEFAULT 0,
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_document_templates_account
ON public.document_templates(account_id);
CREATE INDEX IF NOT EXISTS ix_document_templates_type
ON public.document_templates(account_id, template_type);
ALTER TABLE public.document_templates ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.document_templates FROM authenticated, service_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.document_templates TO authenticated;
GRANT ALL ON public.document_templates TO service_role;
-- Own account read/write
CREATE POLICY document_templates_select ON public.document_templates
FOR SELECT TO authenticated
USING (public.has_role_on_account(account_id));
CREATE POLICY document_templates_mutate ON public.document_templates
FOR ALL TO authenticated
USING (public.has_permission(auth.uid(), account_id, 'documents.generate'::public.app_permissions));
-- Hierarchy: child orgs can read parent's shared templates
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'public' AND tablename = 'document_templates'
AND policyname = 'document_templates_hierarchy_read'
) THEN
EXECUTE 'CREATE POLICY document_templates_hierarchy_read
ON public.document_templates
FOR SELECT TO authenticated
USING (
shared_with_hierarchy = true
AND public.has_role_on_account_or_ancestor(account_id)
)';
END IF;
END $$;
CREATE TRIGGER trg_document_templates_updated_at
BEFORE UPDATE ON public.document_templates
FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp();
-- =====================================================
-- 3. Template cloning function
-- =====================================================
-- Clone a shared template into a child org's own templates
CREATE OR REPLACE FUNCTION public.clone_template(
p_template_type text, -- 'newsletter' or 'document'
p_template_id uuid,
p_target_account_id uuid,
p_new_name text DEFAULT NULL
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_new_id uuid;
v_caller_id uuid;
v_source_account_id uuid;
BEGIN
v_caller_id := (SELECT auth.uid());
-- Verify caller has a role on the target account
IF NOT public.has_role_on_account(p_target_account_id) THEN
RAISE EXCEPTION 'Keine Berechtigung für die Zielorganisation';
END IF;
IF p_template_type = 'newsletter' THEN
-- Get source account for verification
SELECT account_id INTO v_source_account_id
FROM public.newsletter_templates WHERE id = p_template_id;
IF v_source_account_id IS NULL THEN
RAISE EXCEPTION 'Vorlage nicht gefunden';
END IF;
-- Must be shared or from own account
IF v_source_account_id != p_target_account_id THEN
IF NOT EXISTS (
SELECT 1 FROM public.newsletter_templates
WHERE id = p_template_id AND shared_with_hierarchy = true
) THEN
RAISE EXCEPTION 'Vorlage ist nicht für die Hierarchie freigegeben';
END IF;
END IF;
INSERT INTO public.newsletter_templates (
account_id, name, subject, body_html, body_text, variables
)
SELECT
p_target_account_id,
COALESCE(p_new_name, name || ' (Kopie)'),
subject, body_html, body_text, variables
FROM public.newsletter_templates
WHERE id = p_template_id
RETURNING id INTO v_new_id;
ELSIF p_template_type = 'document' THEN
SELECT account_id INTO v_source_account_id
FROM public.document_templates WHERE id = p_template_id;
IF v_source_account_id IS NULL THEN
RAISE EXCEPTION 'Vorlage nicht gefunden';
END IF;
IF v_source_account_id != p_target_account_id THEN
IF NOT EXISTS (
SELECT 1 FROM public.document_templates
WHERE id = p_template_id AND shared_with_hierarchy = true
) THEN
RAISE EXCEPTION 'Vorlage ist nicht für die Hierarchie freigegeben';
END IF;
END IF;
INSERT INTO public.document_templates (
account_id, name, description, template_type,
body_html, variables, page_format, orientation, created_by
)
SELECT
p_target_account_id,
COALESCE(p_new_name, name || ' (Kopie)'),
description, template_type,
body_html, variables, page_format, orientation, v_caller_id
FROM public.document_templates
WHERE id = p_template_id
RETURNING id INTO v_new_id;
ELSE
RAISE EXCEPTION 'Ungültiger Vorlagentyp: %', p_template_type;
END IF;
RETURN v_new_id;
END;
$$;
GRANT EXECUTE ON FUNCTION public.clone_template(text, uuid, uuid, text)
TO authenticated, service_role;
-- =====================================================
-- 4. List shared templates across hierarchy
-- =====================================================
CREATE OR REPLACE FUNCTION public.list_hierarchy_shared_templates(
root_account_id uuid,
p_template_type text DEFAULT NULL -- 'newsletter', 'document', or NULL for both
)
RETURNS TABLE (
id uuid,
account_id uuid,
account_name varchar,
template_source text, -- 'newsletter' or 'document'
name text,
description text,
template_type text,
shared_with_hierarchy boolean,
created_at timestamptz
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF NOT public.has_role_on_account(root_account_id) THEN
RETURN;
END IF;
RETURN QUERY
-- Newsletter templates
SELECT
nt.id,
nt.account_id,
a.name AS account_name,
'newsletter'::text AS template_source,
nt.name,
NULL::text AS description,
'newsletter'::text AS template_type,
nt.shared_with_hierarchy,
nt.created_at
FROM public.newsletter_templates nt
JOIN public.accounts a ON a.id = nt.account_id
WHERE nt.account_id IN (SELECT d FROM public.get_account_descendants(root_account_id) d)
AND nt.shared_with_hierarchy = true
AND (p_template_type IS NULL OR p_template_type = 'newsletter')
UNION ALL
-- Document templates
SELECT
dt.id,
dt.account_id,
a.name AS account_name,
'document'::text AS template_source,
dt.name,
dt.description,
dt.template_type,
dt.shared_with_hierarchy,
dt.created_at
FROM public.document_templates dt
JOIN public.accounts a ON a.id = dt.account_id
WHERE dt.account_id IN (SELECT d FROM public.get_account_descendants(root_account_id) d)
AND dt.shared_with_hierarchy = true
AND (p_template_type IS NULL OR p_template_type = 'document')
ORDER BY created_at DESC;
END;
$$;
GRANT EXECUTE ON FUNCTION public.list_hierarchy_shared_templates(uuid, text)
TO authenticated, service_role;