-- ============================================================= -- 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; $$;