From bfcca473533d89b33956733796605ea1faef2382 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Tue, 3 Mar 2026 18:15:45 +0800 Subject: [PATCH] Reports migrations --- .../20260303090000_add_report_rpcs.sql | 382 ++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 supabase/migrations/20260303090000_add_report_rpcs.sql diff --git a/supabase/migrations/20260303090000_add_report_rpcs.sql b/supabase/migrations/20260303090000_add_report_rpcs.sql new file mode 100644 index 00000000..a76848de --- /dev/null +++ b/supabase/migrations/20260303090000_add_report_rpcs.sql @@ -0,0 +1,382 @@ +-- ============================================================= +-- Reports Infrastructure: pg_trgm + Server-side RPC functions +-- ============================================================= +-- All RPCs accept a date range (p_start, p_end) and use +-- SECURITY INVOKER so existing RLS policies apply. +-- ============================================================= + +-- Enable pg_trgm for fuzzy subject clustering +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- GIN trigram indexes for subject similarity searches +CREATE INDEX IF NOT EXISTS idx_tickets_subject_trgm + ON tickets USING gin (lower(subject) gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS idx_tasks_title_trgm + ON tasks USING gin (lower(title) gin_trgm_ops); + +-- ----- 1. Tickets by status ----- +CREATE OR REPLACE FUNCTION report_tickets_by_status( + p_start timestamptz, + p_end timestamptz +) +RETURNS TABLE(status text, count bigint) +LANGUAGE sql STABLE SECURITY INVOKER +AS $$ + SELECT t.status, count(*) + FROM tickets t + WHERE t.created_at >= p_start + AND t.created_at < p_end + GROUP BY t.status + ORDER BY count(*) DESC; +$$; + +-- ----- 2. Tasks by status ----- +CREATE OR REPLACE FUNCTION report_tasks_by_status( + p_start timestamptz, + p_end timestamptz +) +RETURNS TABLE(status text, count bigint) +LANGUAGE sql STABLE SECURITY INVOKER +AS $$ + SELECT t.status::text, count(*) + FROM tasks t + WHERE t.created_at >= p_start + AND t.created_at < p_end + GROUP BY t.status + ORDER BY count(*) DESC; +$$; + +-- ----- 3. Tasks created by hour of day ----- +CREATE OR REPLACE FUNCTION report_tasks_by_hour( + p_start timestamptz, + p_end timestamptz +) +RETURNS TABLE(hour int, count bigint) +LANGUAGE sql STABLE SECURITY INVOKER +AS $$ + SELECT EXTRACT(HOUR FROM t.created_at AT TIME ZONE 'Asia/Manila')::int AS hour, + count(*) + FROM tasks t + WHERE t.created_at >= p_start + AND t.created_at < p_end + GROUP BY hour + ORDER BY hour; +$$; + +-- ----- 4. Tickets created by hour of day ----- +CREATE OR REPLACE FUNCTION report_tickets_by_hour( + p_start timestamptz, + p_end timestamptz +) +RETURNS TABLE(hour int, count bigint) +LANGUAGE sql STABLE SECURITY INVOKER +AS $$ + SELECT EXTRACT(HOUR FROM t.created_at AT TIME ZONE 'Asia/Manila')::int AS hour, + count(*) + FROM tickets t + WHERE t.created_at >= p_start + AND t.created_at < p_end + GROUP BY hour + ORDER BY hour; +$$; + +-- ----- 5. Top offices by ticket count ----- +CREATE OR REPLACE FUNCTION report_top_offices_tickets( + p_start timestamptz, + p_end timestamptz, + p_limit int DEFAULT 10 +) +RETURNS TABLE(office_name text, count bigint) +LANGUAGE sql STABLE SECURITY INVOKER +AS $$ + SELECT o.name AS office_name, count(*) + FROM tickets t + JOIN offices o ON o.id = t.office_id + WHERE t.created_at >= p_start + AND t.created_at < p_end + GROUP BY o.name + ORDER BY count(*) DESC + LIMIT p_limit; +$$; + +-- ----- 6. Top offices by task count ----- +CREATE OR REPLACE FUNCTION report_top_offices_tasks( + p_start timestamptz, + p_end timestamptz, + p_limit int DEFAULT 10 +) +RETURNS TABLE(office_name text, count bigint) +LANGUAGE sql STABLE SECURITY INVOKER +AS $$ + SELECT o.name AS office_name, count(*) + FROM tasks t + JOIN offices o ON o.id = t.office_id + WHERE t.created_at >= p_start + AND t.created_at < p_end + GROUP BY o.name + ORDER BY count(*) DESC + LIMIT p_limit; +$$; + +-- ----- 7. Top ticket subjects (pg_trgm clustering) ----- +CREATE OR REPLACE FUNCTION report_top_ticket_subjects( + p_start timestamptz, + p_end timestamptz, + p_limit int DEFAULT 10, + p_threshold float DEFAULT 0.4 +) +RETURNS TABLE(subject_group text, ticket_count bigint) +LANGUAGE plpgsql STABLE SECURITY INVOKER +AS $$ +DECLARE + rec RECORD; + cluster_leaders text[] := '{}'; + cluster_counts bigint[] := '{}'; + i int; + matched boolean; + best_idx int; + best_sim float; + cur_sim float; +BEGIN + -- Iterate subjects ordered by frequency (greedy clustering) + FOR rec IN + SELECT lower(trim(t.subject)) AS norm_subject, count(*) AS cnt + FROM tickets t + WHERE t.created_at >= p_start + AND t.created_at < p_end + AND t.subject IS NOT NULL + AND trim(t.subject) <> '' + GROUP BY norm_subject + ORDER BY cnt DESC + LOOP + matched := false; + best_idx := 0; + best_sim := 0; + + FOR i IN 1..coalesce(array_length(cluster_leaders, 1), 0) LOOP + cur_sim := similarity(rec.norm_subject, cluster_leaders[i]); + IF cur_sim >= p_threshold AND cur_sim > best_sim THEN + best_sim := cur_sim; + best_idx := i; + matched := true; + END IF; + END LOOP; + + IF matched THEN + cluster_counts[best_idx] := cluster_counts[best_idx] + rec.cnt; + ELSE + cluster_leaders := array_append(cluster_leaders, rec.norm_subject); + cluster_counts := array_append(cluster_counts, rec.cnt); + END IF; + END LOOP; + + -- Return top clusters + RETURN QUERY + SELECT sub.cl AS subject_group, sub.cc AS ticket_count + FROM ( + SELECT unnest(cluster_leaders) AS cl, + unnest(cluster_counts) AS cc + ) sub + ORDER BY sub.cc DESC + LIMIT p_limit; +END; +$$; + +-- ----- 8. Top task subjects (pg_trgm clustering) ----- +CREATE OR REPLACE FUNCTION report_top_task_subjects( + p_start timestamptz, + p_end timestamptz, + p_limit int DEFAULT 10, + p_threshold float DEFAULT 0.4 +) +RETURNS TABLE(subject_group text, task_count bigint) +LANGUAGE plpgsql STABLE SECURITY INVOKER +AS $$ +DECLARE + rec RECORD; + cluster_leaders text[] := '{}'; + cluster_counts bigint[] := '{}'; + i int; + matched boolean; + best_idx int; + best_sim float; + cur_sim float; +BEGIN + FOR rec IN + SELECT lower(trim(t.title)) AS norm_title, count(*) AS cnt + FROM tasks t + WHERE t.created_at >= p_start + AND t.created_at < p_end + AND t.title IS NOT NULL + AND trim(t.title) <> '' + GROUP BY norm_title + ORDER BY cnt DESC + LOOP + matched := false; + best_idx := 0; + best_sim := 0; + + FOR i IN 1..coalesce(array_length(cluster_leaders, 1), 0) LOOP + cur_sim := similarity(rec.norm_title, cluster_leaders[i]); + IF cur_sim >= p_threshold AND cur_sim > best_sim THEN + best_sim := cur_sim; + best_idx := i; + matched := true; + END IF; + END LOOP; + + IF matched THEN + cluster_counts[best_idx] := cluster_counts[best_idx] + rec.cnt; + ELSE + cluster_leaders := array_append(cluster_leaders, rec.norm_title); + cluster_counts := array_append(cluster_counts, rec.cnt); + END IF; + END LOOP; + + RETURN QUERY + SELECT sub.cl AS subject_group, sub.cc AS task_count + FROM ( + SELECT unnest(cluster_leaders) AS cl, + unnest(cluster_counts) AS cc + ) sub + ORDER BY sub.cc DESC + LIMIT p_limit; +END; +$$; + +-- ----- 9. Monthly overview (tickets + tasks) ----- +CREATE OR REPLACE FUNCTION report_monthly_overview( + p_start timestamptz, + p_end timestamptz +) +RETURNS TABLE(month text, ticket_count bigint, task_count bigint) +LANGUAGE sql STABLE SECURITY INVOKER +AS $$ + WITH months AS ( + SELECT to_char(date_trunc('month', d), 'YYYY-MM') AS m + FROM generate_series( + date_trunc('month', p_start), + date_trunc('month', p_end - interval '1 day'), + interval '1 month' + ) d + ), + tk AS ( + SELECT to_char(date_trunc('month', created_at), 'YYYY-MM') AS m, + count(*) AS cnt + FROM tickets + WHERE created_at >= p_start AND created_at < p_end + GROUP BY m + ), + ta AS ( + SELECT to_char(date_trunc('month', created_at), 'YYYY-MM') AS m, + count(*) AS cnt + FROM tasks + WHERE created_at >= p_start AND created_at < p_end + GROUP BY m + ) + SELECT months.m AS month, + coalesce(tk.cnt, 0) AS ticket_count, + coalesce(ta.cnt, 0) AS task_count + FROM months + LEFT JOIN tk ON tk.m = months.m + LEFT JOIN ta ON ta.m = months.m + ORDER BY months.m; +$$; + +-- ----- 10. Request type distribution ----- +CREATE OR REPLACE FUNCTION report_request_type_distribution( + p_start timestamptz, + p_end timestamptz +) +RETURNS TABLE(request_type text, count bigint) +LANGUAGE sql STABLE SECURITY INVOKER +AS $$ + SELECT coalesce(t.request_type::text, 'Unspecified') AS request_type, + count(*) + FROM tasks t + WHERE t.created_at >= p_start + AND t.created_at < p_end + GROUP BY request_type + ORDER BY count(*) DESC; +$$; + +-- ----- 11. Request category distribution ----- +CREATE OR REPLACE FUNCTION report_request_category_distribution( + p_start timestamptz, + p_end timestamptz +) +RETURNS TABLE(request_category text, count bigint) +LANGUAGE sql STABLE SECURITY INVOKER +AS $$ + SELECT coalesce(t.request_category::text, 'Unspecified') AS request_category, + count(*) + FROM tasks t + WHERE t.created_at >= p_start + AND t.created_at < p_end + GROUP BY request_category + ORDER BY count(*) DESC; +$$; + +-- ----- 12. Avg resolution time by office ----- +CREATE OR REPLACE FUNCTION report_avg_resolution_by_office( + p_start timestamptz, + p_end timestamptz +) +RETURNS TABLE(office_name text, avg_hours float) +LANGUAGE sql STABLE SECURITY INVOKER +AS $$ + SELECT o.name AS office_name, + avg(EXTRACT(EPOCH FROM (t.completed_at - t.created_at)) / 3600.0)::float AS avg_hours + FROM tasks t + JOIN offices o ON o.id = t.office_id + WHERE t.created_at >= p_start + AND t.created_at < p_end + AND t.completed_at IS NOT NULL + AND t.status::text = 'completed' + GROUP BY o.name + ORDER BY avg_hours DESC; +$$; + +-- ----- 13. Staff workload (assigned vs completed) ----- +CREATE OR REPLACE FUNCTION report_staff_workload( + p_start timestamptz, + p_end timestamptz +) +RETURNS TABLE(staff_name text, assigned_count bigint, completed_count bigint) +LANGUAGE sql STABLE SECURITY INVOKER +AS $$ + SELECT p.full_name AS staff_name, + count(DISTINCT ta.task_id) AS assigned_count, + count(DISTINCT CASE WHEN t.status::text = 'completed' + AND t.completed_at >= p_start + AND t.completed_at < p_end + THEN t.id END) AS completed_count + FROM task_assignments ta + JOIN profiles p ON p.id = ta.user_id + JOIN tasks t ON t.id = ta.task_id + WHERE ta.created_at >= p_start + AND ta.created_at < p_end + GROUP BY p.full_name + ORDER BY assigned_count DESC; +$$; + +-- ----- 14. Ticket-to-task conversion rate ----- +CREATE OR REPLACE FUNCTION report_ticket_to_task_rate( + p_start timestamptz, + p_end timestamptz +) +RETURNS TABLE(total_tickets bigint, promoted_tickets bigint, conversion_rate float) +LANGUAGE sql STABLE SECURITY INVOKER +AS $$ + SELECT + count(*)::bigint AS total_tickets, + count(*) FILTER (WHERE promoted_at IS NOT NULL)::bigint AS promoted_tickets, + CASE WHEN count(*) > 0 + THEN (count(*) FILTER (WHERE promoted_at IS NOT NULL)::float / count(*)::float * 100) + ELSE 0 + END AS conversion_rate + FROM tickets + WHERE created_at >= p_start + AND created_at < p_end; +$$;