Files
myeasycms-v2/apps/web/supabase/migrations/20260416000012_reporting_functions.sql
T. Zehetbauer 5c5aaabae5
Some checks failed
Workflow / ʦ TypeScript (pull_request) Failing after 5m57s
Workflow / ⚫️ Test (pull_request) Has been skipped
refactor: remove obsolete member management API module
2026-04-03 14:08:31 +02:00

296 lines
8.4 KiB
PL/PgSQL

-- =====================================================
-- Reporting & Analytics RPC Functions
--
-- Enterprise-grade reporting: demographics, retention,
-- geographic distribution, dues collection, membership
-- duration analysis.
-- =====================================================
-- 1. Age demographics by gender
CREATE OR REPLACE FUNCTION public.get_member_demographics(p_account_id uuid)
RETURNS TABLE (
age_group text,
male_count bigint,
female_count bigint,
diverse_count bigint,
unknown_count bigint,
total bigint
)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
IF NOT public.has_role_on_account(p_account_id) THEN
RAISE EXCEPTION 'Access denied';
END IF;
RETURN QUERY
SELECT
CASE
WHEN age < 18 THEN 'Unter 18'
WHEN age BETWEEN 18 AND 30 THEN '18-30'
WHEN age BETWEEN 31 AND 50 THEN '31-50'
WHEN age BETWEEN 51 AND 65 THEN '51-65'
WHEN age > 65 THEN 'Über 65'
ELSE 'Unbekannt'
END AS age_group,
count(*) FILTER (WHERE m.gender = 'male') AS male_count,
count(*) FILTER (WHERE m.gender = 'female') AS female_count,
count(*) FILTER (WHERE m.gender = 'diverse') AS diverse_count,
count(*) FILTER (WHERE m.gender IS NULL OR m.gender NOT IN ('male', 'female', 'diverse')) AS unknown_count,
count(*) AS total
FROM public.members m
LEFT JOIN LATERAL (
SELECT CASE
WHEN m.date_of_birth IS NOT NULL THEN
extract(year FROM age(current_date, m.date_of_birth))::int
ELSE NULL
END AS age
) ages ON true
WHERE m.account_id = p_account_id
AND m.status = 'active'
AND m.is_archived = false
GROUP BY age_group
ORDER BY
CASE age_group
WHEN 'Unter 18' THEN 1
WHEN '18-30' THEN 2
WHEN '31-50' THEN 3
WHEN '51-65' THEN 4
WHEN 'Über 65' THEN 5
ELSE 6
END;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_member_demographics(uuid) TO authenticated;
-- 2. Year-over-year membership retention
CREATE OR REPLACE FUNCTION public.get_member_retention(
p_account_id uuid,
p_years int DEFAULT 5
)
RETURNS TABLE (
year int,
members_start bigint,
new_members bigint,
resigned_members bigint,
members_end bigint,
retention_rate numeric
)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
IF NOT public.has_role_on_account(p_account_id) THEN
RAISE EXCEPTION 'Access denied';
END IF;
RETURN QUERY
WITH years AS (
SELECT generate_series(
extract(year FROM current_date)::int - p_years + 1,
extract(year FROM current_date)::int
) AS yr
),
stats AS (
SELECT
y.yr,
count(*) FILTER (WHERE m.entry_date < make_date(y.yr, 1, 1)
AND (m.exit_date IS NULL OR m.exit_date >= make_date(y.yr, 1, 1))) AS members_start,
count(*) FILTER (WHERE extract(year FROM m.entry_date) = y.yr) AS new_members,
count(*) FILTER (WHERE extract(year FROM m.exit_date) = y.yr) AS resigned_members,
count(*) FILTER (WHERE m.entry_date <= make_date(y.yr, 12, 31)
AND (m.exit_date IS NULL OR m.exit_date > make_date(y.yr, 12, 31))) AS members_end
FROM years y
CROSS JOIN public.members m
WHERE m.account_id = p_account_id AND m.is_archived = false
GROUP BY y.yr
)
SELECT
s.yr AS year,
s.members_start,
s.new_members,
s.resigned_members,
s.members_end,
CASE WHEN s.members_start > 0
THEN round((s.members_end::numeric / s.members_start) * 100, 1)
ELSE 0
END AS retention_rate
FROM stats s
ORDER BY s.yr;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_member_retention(uuid, int) TO authenticated;
-- 3. Geographic distribution by postal code prefix
CREATE OR REPLACE FUNCTION public.get_member_geographic_distribution(p_account_id uuid)
RETURNS TABLE (
postal_prefix text,
city text,
member_count bigint
)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
IF NOT public.has_role_on_account(p_account_id) THEN
RAISE EXCEPTION 'Access denied';
END IF;
RETURN QUERY
SELECT
CASE
WHEN m.postal_code IS NULL OR m.postal_code = '' THEN 'Keine Angabe'
ELSE left(m.postal_code, 2)
END AS postal_prefix,
COALESCE(NULLIF(m.city, ''), 'Keine Angabe') AS city,
count(*) AS member_count
FROM public.members m
WHERE m.account_id = p_account_id
AND m.status = 'active'
AND m.is_archived = false
GROUP BY postal_prefix, m.city
ORDER BY member_count DESC;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_member_geographic_distribution(uuid) TO authenticated;
-- 4. Dues collection rates by category
CREATE OR REPLACE FUNCTION public.get_dues_collection_report(p_account_id uuid)
RETURNS TABLE (
category_name text,
member_count bigint,
expected_amount numeric,
paid_count bigint,
collection_rate numeric
)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
IF NOT public.has_role_on_account(p_account_id) THEN
RAISE EXCEPTION 'Access denied';
END IF;
RETURN QUERY
SELECT
COALESCE(dc.name, 'Keine Kategorie') AS category_name,
count(m.id) AS member_count,
COALESCE(sum(dc.amount), 0) AS expected_amount,
count(*) FILTER (WHERE m.dues_paid = true) AS paid_count,
CASE WHEN count(m.id) > 0
THEN round((count(*) FILTER (WHERE m.dues_paid = true)::numeric / count(m.id)) * 100, 1)
ELSE 0
END AS collection_rate
FROM public.members m
LEFT JOIN public.dues_categories dc ON dc.id = m.dues_category_id
WHERE m.account_id = p_account_id
AND m.status = 'active'
AND m.is_archived = false
GROUP BY dc.name, dc.sort_order
ORDER BY dc.sort_order NULLS LAST;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_dues_collection_report(uuid) TO authenticated;
-- 5. Membership duration analysis
CREATE OR REPLACE FUNCTION public.get_membership_duration_analysis(p_account_id uuid)
RETURNS TABLE (
duration_bucket text,
member_count bigint,
percentage numeric
)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
AS $$
DECLARE
v_total bigint;
BEGIN
IF NOT public.has_role_on_account(p_account_id) THEN
RAISE EXCEPTION 'Access denied';
END IF;
SELECT count(*) INTO v_total
FROM public.members
WHERE account_id = p_account_id AND status = 'active' AND is_archived = false;
RETURN QUERY
SELECT
CASE
WHEN years < 1 THEN 'Unter 1 Jahr'
WHEN years BETWEEN 1 AND 5 THEN '1-5 Jahre'
WHEN years BETWEEN 6 AND 10 THEN '6-10 Jahre'
WHEN years BETWEEN 11 AND 25 THEN '11-25 Jahre'
WHEN years > 25 THEN 'Über 25 Jahre'
ELSE 'Unbekannt'
END AS duration_bucket,
count(*) AS member_count,
CASE WHEN v_total > 0
THEN round((count(*)::numeric / v_total) * 100, 1)
ELSE 0
END AS percentage
FROM (
SELECT
CASE WHEN m.entry_date IS NOT NULL
THEN extract(year FROM age(current_date, m.entry_date))::int
ELSE NULL
END AS years
FROM public.members m
WHERE m.account_id = p_account_id
AND m.status = 'active'
AND m.is_archived = false
) sub
GROUP BY duration_bucket
ORDER BY
CASE duration_bucket
WHEN 'Unter 1 Jahr' THEN 1
WHEN '1-5 Jahre' THEN 2
WHEN '6-10 Jahre' THEN 3
WHEN '11-25 Jahre' THEN 4
WHEN 'Über 25 Jahre' THEN 5
ELSE 6
END;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_membership_duration_analysis(uuid) TO authenticated;
-- 6. Department distribution
CREATE OR REPLACE FUNCTION public.get_department_distribution(p_account_id uuid)
RETURNS TABLE (
department_name text,
member_count bigint,
percentage numeric
)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
AS $$
DECLARE
v_total bigint;
BEGIN
IF NOT public.has_role_on_account(p_account_id) THEN
RAISE EXCEPTION 'Access denied';
END IF;
SELECT count(DISTINCT m.id) INTO v_total
FROM public.members m
WHERE m.account_id = p_account_id AND m.status = 'active' AND m.is_archived = false;
RETURN QUERY
SELECT
d.name AS department_name,
count(DISTINCT mda.member_id) AS member_count,
CASE WHEN v_total > 0
THEN round((count(DISTINCT mda.member_id)::numeric / v_total) * 100, 1)
ELSE 0
END AS percentage
FROM public.member_departments d
LEFT JOIN public.member_department_assignments mda ON mda.department_id = d.id
LEFT JOIN public.members m ON m.id = mda.member_id
AND m.status = 'active' AND m.is_archived = false
WHERE d.account_id = p_account_id
GROUP BY d.name, d.sort_order
ORDER BY member_count DESC;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_department_distribution(uuid) TO authenticated;