-- ============================================================================ -- TABLERO CDC — MÉTRICAS PARA DASHBOARD FUTURO -- Fecha: 2026-08-16 -- -- Objetivo: -- 1) Registrar usuarios/sesiones que realmente abren Tablero CDC. -- 2) Registrar eventos relevantes de proyectos sin depender del frontend. -- 3) Exponer un resumen listo para un futuro dashboard. -- -- Es idempotente: puede ejecutarse nuevamente sin duplicar el backfill. -- ============================================================================ create extension if not exists pgcrypto; create table if not exists public.tablero_cdc_app_metrics_events ( id uuid primary key default gen_random_uuid(), event_type text not null, event_key text not null unique, actor_user_id uuid references auth.users(id) on delete set null, actor_email text, actor_name text, project_id uuid references public.tablero_cdc_projects(id) on delete set null, session_id text, metadata jsonb not null default '{}'::jsonb, occurred_at timestamptz not null default now(), created_at timestamptz not null default now() ); create index if not exists tablero_cdc_metrics_event_type_idx on public.tablero_cdc_app_metrics_events (event_type, occurred_at desc); create index if not exists tablero_cdc_metrics_actor_idx on public.tablero_cdc_app_metrics_events (actor_user_id, occurred_at desc); create index if not exists tablero_cdc_metrics_project_idx on public.tablero_cdc_app_metrics_events (project_id, occurred_at desc) where project_id is not null; alter table public.tablero_cdc_app_metrics_events enable row level security; -- El dashboard futuro podrá leer las métricas únicamente con usuarios activos -- que ya tienen permiso para ver/controlar el resumen económico del Tablero. drop policy if exists "tablero_cdc_metrics_select_authorized" on public.tablero_cdc_app_metrics_events; create policy "tablero_cdc_metrics_select_authorized" on public.tablero_cdc_app_metrics_events for select to authenticated using ( exists ( select 1 from public.tablero_cdc_allowed_users u where lower(u.email) = lower(coalesce(auth.jwt() ->> 'email', '')) and u.is_active = true and coalesce(u.can_control_pricing_summary, false) = true ) ); -- No damos INSERT directo al cliente. Las aperturas se registran vía RPC y -- los eventos de proyecto vía trigger, evitando que el navegador falsifique métricas. revoke insert, update, delete on public.tablero_cdc_app_metrics_events from authenticated; grant select on public.tablero_cdc_app_metrics_events to authenticated; -- -------------------------------------------------------------------------- -- Helper de identidad para los eventos automáticos. -- -------------------------------------------------------------------------- create or replace function public.tablero_cdc_metric_actor( p_user_id uuid, out actor_email text, out actor_name text ) language plpgsql security definer set search_path = public, auth as $$ begin actor_email := null; actor_name := null; if p_user_id is null then return; end if; select u.email, coalesce( nullif(u.raw_user_meta_data ->> 'full_name', ''), nullif(u.raw_user_meta_data ->> 'name', ''), u.email ) into actor_email, actor_name from auth.users u where u.id = p_user_id limit 1; end; $$; revoke all on function public.tablero_cdc_metric_actor(uuid) from public; -- -------------------------------------------------------------------------- -- RPC: una apertura por sesión autenticada/autorizada. -- -------------------------------------------------------------------------- create or replace function public.tablero_cdc_track_app_open(p_session_id text) returns void language plpgsql security definer set search_path = public, auth as $$ declare v_uid uuid := auth.uid(); v_email text; v_name text; v_session text := nullif(trim(coalesce(p_session_id, '')), ''); begin if v_uid is null then raise exception 'Usuario no autenticado'; end if; if v_session is null or length(v_session) > 200 then raise exception 'session_id inválido'; end if; select actor_email, actor_name into v_email, v_name from public.tablero_cdc_metric_actor(v_uid); if not exists ( select 1 from public.tablero_cdc_allowed_users u where lower(u.email) = lower(coalesce(v_email, '')) and u.is_active = true ) then raise exception 'Usuario no autorizado para Tablero CDC'; end if; insert into public.tablero_cdc_app_metrics_events ( event_type, event_key, actor_user_id, actor_email, actor_name, session_id, metadata ) values ( 'app_opened', 'app_opened:' || v_uid::text || ':' || v_session, v_uid, v_email, v_name, v_session, jsonb_build_object('app', 'tablero_cdc') ) on conflict (event_key) do nothing; end; $$; revoke all on function public.tablero_cdc_track_app_open(text) from public; grant execute on function public.tablero_cdc_track_app_open(text) to authenticated; -- -------------------------------------------------------------------------- -- Trigger de proyectos: creación + cambios de estado relevantes. -- -------------------------------------------------------------------------- create or replace function public.tablero_cdc_capture_project_metric() returns trigger language plpgsql security definer set search_path = public, auth as $$ declare v_actor uuid; v_email text; v_name text; v_old_status text; v_new_status text; v_event text; begin v_actor := coalesce(new.updated_by, new.created_by, auth.uid()); select actor_email, actor_name into v_email, v_name from public.tablero_cdc_metric_actor(v_actor); if tg_op = 'INSERT' then insert into public.tablero_cdc_app_metrics_events ( event_type, event_key, actor_user_id, actor_email, actor_name, project_id, metadata, occurred_at ) values ( 'project_created', 'project_created:' || new.id::text, v_actor, v_email, v_name, new.id, jsonb_build_object( 'title', coalesce(new.title, ''), 'client', coalesce(new.client, ''), 'brand', coalesce(new.brand, ''), 'country', coalesce(new.country, '') ), coalesce(new.created_at, now()) ) on conflict (event_key) do nothing; return new; end if; v_old_status := lower(trim(coalesce(old.status, ''))); v_new_status := lower(trim(coalesce(new.status, ''))); if v_old_status is not distinct from v_new_status then return new; end if; v_event := case when v_new_status = 'aprobado' then 'project_approved' when v_new_status = 'no aprobado' then 'project_rejected' when v_new_status in ('', 'activo') then 'project_reopened' else 'project_closed' end; insert into public.tablero_cdc_app_metrics_events ( event_type, event_key, actor_user_id, actor_email, actor_name, project_id, metadata ) values ( v_event, v_event || ':' || new.id::text || ':' || gen_random_uuid()::text, v_actor, v_email, v_name, new.id, jsonb_build_object( 'title', coalesce(new.title, ''), 'previous_status', coalesce(old.status, ''), 'new_status', coalesce(new.status, '') ) ); return new; end; $$; drop trigger if exists trg_tablero_cdc_project_metrics on public.tablero_cdc_projects; create trigger trg_tablero_cdc_project_metrics after insert or update of status on public.tablero_cdc_projects for each row execute function public.tablero_cdc_capture_project_metric(); -- -------------------------------------------------------------------------- -- Trigger de tiempo: permite medir uso real y horas registradas históricamente. -- -------------------------------------------------------------------------- create or replace function public.tablero_cdc_capture_time_metric() returns trigger language plpgsql security definer set search_path = public, auth as $$ begin insert into public.tablero_cdc_app_metrics_events ( event_type, event_key, actor_user_id, actor_email, actor_name, project_id, metadata, occurred_at ) values ( 'time_logged', 'time_logged:' || new.id::text, new.created_by, new.created_by_email, coalesce(nullif(new.created_by_name, ''), new.created_by_email), new.project_id, jsonb_build_object( 'country', coalesce(new.country, ''), 'task_name', coalesce(new.task_name, ''), 'duration_minutes', coalesce(new.duration_minutes, 0), 'work_date', new.work_date ), coalesce(new.created_at, now()) ) on conflict (event_key) do nothing; return new; end; $$; drop trigger if exists trg_tablero_cdc_time_metrics on public.tablero_cdc_project_time_entries; create trigger trg_tablero_cdc_time_metrics after insert on public.tablero_cdc_project_time_entries for each row execute function public.tablero_cdc_capture_time_metric(); -- -------------------------------------------------------------------------- -- Métricas de Briefs (siempre útiles para este Tablero): recibidos/aprobados/ -- rechazados. La tabla ya forma parte del módulo CDC Brief de esta versión. -- -------------------------------------------------------------------------- create or replace function public.tablero_cdc_capture_brief_metric() returns trigger language plpgsql security definer set search_path = public, auth as $$ declare v_actor uuid; v_email text; v_name text; v_event text; begin if tg_op = 'INSERT' then insert into public.tablero_cdc_app_metrics_events ( event_type, event_key, actor_user_id, actor_email, actor_name, metadata, occurred_at ) values ( 'brief_received', 'brief_received:' || new.id::text, null, null, null, jsonb_build_object('brief_id', coalesce(new.brief_id, ''), 'title', coalesce(new.title, '')), coalesce(new.created_at, now()) ) on conflict (event_key) do nothing; return new; end if; if old.review_status is not distinct from new.review_status then return new; end if; v_event := case when new.review_status = 'approved' then 'brief_approved' when new.review_status = 'rejected' then 'brief_rejected' else null end; if v_event is null then return new; end if; v_actor := coalesce(new.reviewed_by, auth.uid()); select actor_email, actor_name into v_email, v_name from public.tablero_cdc_metric_actor(v_actor); insert into public.tablero_cdc_app_metrics_events ( event_type, event_key, actor_user_id, actor_email, actor_name, project_id, metadata ) values ( v_event, v_event || ':' || new.id::text || ':' || gen_random_uuid()::text, v_actor, coalesce(new.reviewed_by_email, v_email), coalesce(new.reviewed_by_name, v_name), new.approved_project_id, jsonb_build_object( 'brief_id', coalesce(new.brief_id, ''), 'title', coalesce(new.title, ''), 'review_status', new.review_status ) ); return new; end; $$; drop trigger if exists trg_tablero_cdc_brief_metrics on public.tablero_cdc_brief_inbox; create trigger trg_tablero_cdc_brief_metrics after insert or update of review_status on public.tablero_cdc_brief_inbox for each row execute function public.tablero_cdc_capture_brief_metric(); -- -------------------------------------------------------------------------- -- Backfill seguro: proyectos y briefs existentes para que el futuro dashboard -- no empiece desde cero. No duplica si se ejecuta otra vez. -- -------------------------------------------------------------------------- insert into public.tablero_cdc_app_metrics_events ( event_type, event_key, actor_user_id, actor_email, actor_name, project_id, metadata, occurred_at ) select 'project_created', 'project_created:' || p.id::text, p.created_by, au.email, coalesce(nullif(au.raw_user_meta_data ->> 'full_name', ''), nullif(au.raw_user_meta_data ->> 'name', ''), au.email), p.id, jsonb_build_object( 'title', coalesce(p.title, ''), 'client', coalesce(p.client, ''), 'brand', coalesce(p.brand, ''), 'country', coalesce(p.country, ''), 'backfill', true ), coalesce(p.created_at, now()) from public.tablero_cdc_projects p left join auth.users au on au.id = p.created_by on conflict (event_key) do nothing; insert into public.tablero_cdc_app_metrics_events ( event_type, event_key, actor_user_id, actor_email, actor_name, project_id, metadata, occurred_at ) select case when lower(trim(p.status)) = 'aprobado' then 'project_approved' else 'project_rejected' end, case when lower(trim(p.status)) = 'aprobado' then 'project_approved:backfill:' || p.id::text else 'project_rejected:backfill:' || p.id::text end, p.updated_by, au.email, coalesce(nullif(au.raw_user_meta_data ->> 'full_name', ''), nullif(au.raw_user_meta_data ->> 'name', ''), au.email), p.id, jsonb_build_object('status', p.status, 'backfill', true), now() from public.tablero_cdc_projects p left join auth.users au on au.id = p.updated_by where lower(trim(coalesce(p.status, ''))) in ('aprobado', 'no aprobado') on conflict (event_key) do nothing; insert into public.tablero_cdc_app_metrics_events ( event_type, event_key, actor_user_id, actor_email, actor_name, project_id, metadata, occurred_at ) select 'time_logged', 'time_logged:' || t.id::text, t.created_by, t.created_by_email, coalesce(nullif(t.created_by_name, ''), t.created_by_email), t.project_id, jsonb_build_object( 'country', coalesce(t.country, ''), 'task_name', coalesce(t.task_name, ''), 'duration_minutes', coalesce(t.duration_minutes, 0), 'work_date', t.work_date, 'backfill', true ), coalesce(t.created_at, now()) from public.tablero_cdc_project_time_entries t on conflict (event_key) do nothing; insert into public.tablero_cdc_app_metrics_events ( event_type, event_key, actor_user_id, actor_email, actor_name, project_id, metadata, occurred_at ) select 'brief_received', 'brief_received:' || b.id::text, null, null, null, b.approved_project_id, jsonb_build_object('brief_id', coalesce(b.brief_id, ''), 'title', coalesce(b.title, ''), 'backfill', true), coalesce(b.created_at, now()) from public.tablero_cdc_brief_inbox b on conflict (event_key) do nothing; -- -------------------------------------------------------------------------- -- Vista resumen para el dashboard futuro. -- Los conteos de estado salen del estado ACTUAL de proyectos, evitando inflar -- métricas cuando un proyecto cambia de estado varias veces. -- -------------------------------------------------------------------------- create or replace view public.tablero_cdc_dashboard_metrics as select (select count(distinct actor_user_id) from public.tablero_cdc_app_metrics_events where actor_user_id is not null) as usuarios_que_han_usado_app, (select count(*) from public.tablero_cdc_app_metrics_events where event_type = 'app_opened') as sesiones_registradas, (select count(*) from public.tablero_cdc_app_metrics_events where event_type = 'project_created') as proyectos_creados_historico, (select count(*) from public.tablero_cdc_projects) as proyectos_actuales, (select count(*) from public.tablero_cdc_projects where lower(trim(coalesce(status, ''))) in ('', 'activo')) as proyectos_activos_actuales, (select count(*) from public.tablero_cdc_projects where lower(trim(coalesce(status, ''))) = 'aprobado') as proyectos_aprobados_actuales, (select count(*) from public.tablero_cdc_projects where lower(trim(coalesce(status, ''))) = 'no aprobado') as proyectos_rechazados_actuales, (select count(*) from public.tablero_cdc_app_metrics_events where event_type = 'project_approved') as aprobaciones_registradas, (select count(*) from public.tablero_cdc_app_metrics_events where event_type = 'project_rejected') as rechazos_registrados, (select count(*) from public.tablero_cdc_project_time_entries) as registros_de_tiempo, (select round(coalesce(sum(duration_minutes), 0)::numeric / 60.0, 2) from public.tablero_cdc_project_time_entries) as horas_registradas, (select count(*) from public.tablero_cdc_brief_inbox) as briefs_recibidos, (select count(*) from public.tablero_cdc_brief_inbox where review_status = 'approved') as briefs_aprobados, (select count(*) from public.tablero_cdc_brief_inbox where review_status = 'rejected') as briefs_rechazados, now() as consultado_en; grant select on public.tablero_cdc_dashboard_metrics to authenticated; comment on table public.tablero_cdc_app_metrics_events is 'Eventos de uso y negocio de Tablero CDC para dashboard futuro de aplicaciones GLM.'; comment on view public.tablero_cdc_dashboard_metrics is 'Resumen actual de usuarios, proyectos, tiempos y briefs de Tablero CDC.';