Files
cdc-project-management/supabase_cdc_brief_review_inbox.sql
T

573 lines
19 KiB
PL/PgSQL

-- =============================================================
-- TABLERO CDC — Bandeja de revisión de CDC Brief
-- Fecha: 2026-08-12
--
-- Objetivo:
-- 1) Recibir automáticamente briefs creados por CDC Brief.
-- 2) Mantenerlos fuera de tablero_cdc_projects mientras están en revisión.
-- 3) Aprobarlos de forma transaccional como proyectos Activos.
-- 4) Mover los descartados a "No aprobados" sin crear proyecto.
-- 5) Mantener un contador de briefs no vistos independiente por usuario.
--
-- Este script NO altera la lógica existente de proyectos, tarifarios,
-- tiempos, Google Sheet ni Banco Fulgencio.
-- =============================================================
begin;
create extension if not exists pgcrypto;
create table if not exists public.tablero_cdc_brief_inbox (
id uuid primary key default gen_random_uuid(),
brief_id text not null unique,
source_created_at timestamptz,
title text not null default '',
client text not null default '',
brand text not null default '',
country text not null default '',
requested_by text not null default '',
requested_by_email text not null default '',
delivery_date text not null default '',
brief_link text not null default '',
document_link text not null default '',
summary text not null default '',
request_type text not null default '',
deliverable_type text not null default '',
raw_fields jsonb not null default '{}'::jsonb,
review_status text not null default 'new',
rejection_reason text,
approved_project_id uuid references public.tablero_cdc_projects(id) on delete set null,
reviewed_by uuid references auth.users(id) on delete set null,
reviewed_by_name text,
reviewed_by_email text,
reviewed_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Compatibilidad idempotente si el script se vuelve a ejecutar tras una instalación parcial.
alter table public.tablero_cdc_brief_inbox
add column if not exists source_created_at timestamptz,
add column if not exists title text not null default '',
add column if not exists client text not null default '',
add column if not exists brand text not null default '',
add column if not exists country text not null default '',
add column if not exists requested_by text not null default '',
add column if not exists requested_by_email text not null default '',
add column if not exists delivery_date text not null default '',
add column if not exists brief_link text not null default '',
add column if not exists document_link text not null default '',
add column if not exists summary text not null default '',
add column if not exists request_type text not null default '',
add column if not exists deliverable_type text not null default '',
add column if not exists raw_fields jsonb not null default '{}'::jsonb,
add column if not exists review_status text not null default 'new',
add column if not exists rejection_reason text,
add column if not exists approved_project_id uuid references public.tablero_cdc_projects(id) on delete set null,
add column if not exists reviewed_by uuid references auth.users(id) on delete set null,
add column if not exists reviewed_by_name text,
add column if not exists reviewed_by_email text,
add column if not exists reviewed_at timestamptz,
add column if not exists created_at timestamptz not null default now(),
add column if not exists updated_at timestamptz not null default now();
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'tablero_cdc_brief_inbox_review_status_check'
and conrelid = 'public.tablero_cdc_brief_inbox'::regclass
) then
alter table public.tablero_cdc_brief_inbox
add constraint tablero_cdc_brief_inbox_review_status_check
check (review_status in ('new', 'approved', 'rejected'));
end if;
end $$;
create index if not exists tablero_cdc_brief_inbox_status_created_idx
on public.tablero_cdc_brief_inbox (review_status, created_at desc);
create index if not exists tablero_cdc_brief_inbox_source_created_idx
on public.tablero_cdc_brief_inbox (source_created_at desc);
create index if not exists tablero_cdc_brief_inbox_approved_project_idx
on public.tablero_cdc_brief_inbox (approved_project_id)
where approved_project_id is not null;
create or replace function public.tablero_cdc_brief_inbox_touch_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists tablero_cdc_brief_inbox_touch_updated_at
on public.tablero_cdc_brief_inbox;
create trigger tablero_cdc_brief_inbox_touch_updated_at
before update on public.tablero_cdc_brief_inbox
for each row
execute function public.tablero_cdc_brief_inbox_touch_updated_at();
-- Verifica que la sesión corresponda a un usuario activo del Tablero CDC.
create or replace function public.tablero_cdc_is_active_allowed_user()
returns boolean
language sql
stable
security definer
set search_path = public, auth
as $$
select exists (
select 1
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = lower(trim(coalesce(auth.jwt() ->> 'email', '')))
and u.is_active = true
);
$$;
revoke all on function public.tablero_cdc_is_active_allowed_user() from public;
grant execute on function public.tablero_cdc_is_active_allowed_user() to authenticated;
alter table public.tablero_cdc_brief_inbox enable row level security;
drop policy if exists "tablero_cdc_brief_inbox_select_allowed"
on public.tablero_cdc_brief_inbox;
create policy "tablero_cdc_brief_inbox_select_allowed"
on public.tablero_cdc_brief_inbox
for select
to authenticated
using (public.tablero_cdc_is_active_allowed_user());
-- El frontend solo necesita SELECT directo. Las decisiones se realizan por RPC
-- para que aprobar + crear proyecto sea una sola transacción.
revoke insert, update, delete on public.tablero_cdc_brief_inbox from authenticated;
grant select on public.tablero_cdc_brief_inbox to authenticated;
grant all on public.tablero_cdc_brief_inbox to service_role;
-- =============================================================
-- NOTIFICACIONES PERSONALES DE "NUEVOS"
-- =============================================================
-- Cada usuario conserva su propio estado de lectura. Se registra la pareja
-- (usuario, brief) en vez de un timestamp global para evitar carreras: un brief
-- que llegue después de que alguien abrió "Nuevos" seguirá siendo no leído.
create table if not exists public.tablero_cdc_brief_inbox_seen (
user_id uuid not null references auth.users(id) on delete cascade,
inbox_id uuid not null references public.tablero_cdc_brief_inbox(id) on delete cascade,
seen_at timestamptz not null default now(),
primary key (user_id, inbox_id)
);
create index if not exists tablero_cdc_brief_inbox_seen_inbox_idx
on public.tablero_cdc_brief_inbox_seen (inbox_id);
alter table public.tablero_cdc_brief_inbox_seen enable row level security;
drop policy if exists "tablero_cdc_brief_inbox_seen_select_own"
on public.tablero_cdc_brief_inbox_seen;
create policy "tablero_cdc_brief_inbox_seen_select_own"
on public.tablero_cdc_brief_inbox_seen
for select
to authenticated
using (
user_id = auth.uid()
and public.tablero_cdc_is_active_allowed_user()
);
-- La aplicación lee el contador mediante RPC y marca vistos mediante RPC.
-- No concedemos escritura directa a authenticated.
revoke insert, update, delete on public.tablero_cdc_brief_inbox_seen from authenticated;
grant select on public.tablero_cdc_brief_inbox_seen to authenticated;
grant all on public.tablero_cdc_brief_inbox_seen to service_role;
create or replace function public.tablero_cdc_get_unseen_brief_count()
returns integer
language plpgsql
stable
security definer
set search_path = public, auth
as $$
declare
v_count integer;
begin
if auth.uid() is null or not public.tablero_cdc_is_active_allowed_user() then
raise exception 'No tienes permiso para consultar notificaciones de briefs.';
end if;
select count(*)::integer
into v_count
from public.tablero_cdc_brief_inbox i
where i.review_status = 'new'
and not exists (
select 1
from public.tablero_cdc_brief_inbox_seen s
where s.user_id = auth.uid()
and s.inbox_id = i.id
);
return coalesce(v_count, 0);
end;
$$;
revoke all on function public.tablero_cdc_get_unseen_brief_count() from public;
grant execute on function public.tablero_cdc_get_unseen_brief_count() to authenticated;
create or replace function public.tablero_cdc_mark_briefs_seen()
returns jsonb
language plpgsql
security definer
set search_path = public, auth
as $$
declare
v_marked integer := 0;
v_remaining integer := 0;
begin
if auth.uid() is null or not public.tablero_cdc_is_active_allowed_user() then
raise exception 'No tienes permiso para marcar briefs como vistos.';
end if;
insert into public.tablero_cdc_brief_inbox_seen (user_id, inbox_id, seen_at)
select auth.uid(), i.id, now()
from public.tablero_cdc_brief_inbox i
where i.review_status = 'new'
on conflict (user_id, inbox_id) do nothing;
get diagnostics v_marked = row_count;
-- Si un brief nuevo entró concurrentemente después del snapshot anterior,
-- se conserva como no leído en vez de borrarle la notificación por accidente.
select count(*)::integer
into v_remaining
from public.tablero_cdc_brief_inbox i
where i.review_status = 'new'
and not exists (
select 1
from public.tablero_cdc_brief_inbox_seen s
where s.user_id = auth.uid()
and s.inbox_id = i.id
);
return jsonb_build_object(
'ok', true,
'marked_count', v_marked,
'unseen_count', coalesce(v_remaining, 0)
);
end;
$$;
revoke all on function public.tablero_cdc_mark_briefs_seen() from public;
grant execute on function public.tablero_cdc_mark_briefs_seen() to authenticated;
-- =============================================================
-- RPC: APROBAR BRIEF
-- =============================================================
create or replace function public.tablero_cdc_approve_brief(p_inbox_id uuid)
returns jsonb
language plpgsql
security definer
set search_path = public, auth
as $$
declare
v_item public.tablero_cdc_brief_inbox%rowtype;
v_project_id uuid;
v_email text;
v_name text;
v_country_manager text := '';
v_title text;
v_description text;
v_activity_description text;
begin
if auth.uid() is null or not public.tablero_cdc_is_active_allowed_user() then
raise exception 'No tienes permiso para revisar briefs del Tablero CDC.';
end if;
select *
into v_item
from public.tablero_cdc_brief_inbox
where id = p_inbox_id
for update;
if not found then
raise exception 'No se encontró el brief solicitado.';
end if;
-- Idempotencia: si el usuario repite el clic o se reintenta la petición
-- después de haber completado la transacción, devolvemos el mismo proyecto.
if v_item.review_status = 'approved' and v_item.approved_project_id is not null then
return jsonb_build_object(
'ok', true,
'already_approved', true,
'project_id', v_item.approved_project_id,
'brief_id', v_item.brief_id
);
end if;
if v_item.review_status not in ('new', 'rejected') then
raise exception 'Este brief ya fue revisado y no puede aprobarse desde esta bandeja.';
end if;
-- Seguridad adicional: un brief rechazado no debe conservar un proyecto asociado.
-- Si existiera una inconsistencia manual, detenemos la operación para evitar duplicados.
if v_item.review_status = 'rejected' and v_item.approved_project_id is not null then
raise exception 'Este brief rechazado ya tiene un proyecto asociado. Revisa los datos antes de aprobarlo.';
end if;
v_activity_description := case
when v_item.review_status = 'rejected'
then 'El brief fue recuperado de Rechazados y aprobado como proyecto Activo.'
else 'El brief fue revisado y aprobado desde la bandeja Nuevos.'
end;
v_email := lower(trim(coalesce(auth.jwt() ->> 'email', '')));
select coalesce(nullif(trim(u.full_name), ''), nullif(trim(u.email), ''), 'Usuario CDC')
into v_name
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = v_email
and u.is_active = true
limit 1;
v_name := coalesce(v_name, nullif(v_email, ''), 'Usuario CDC');
-- Si existe el mapeo BU -> CM en las listas dinámicas, lo reutilizamos.
-- Si no existe, se deja vacío y el proyecto continúa siendo válido/editable.
begin
select coalesce(nullif(trim(l.label), ''), '')
into v_country_manager
from public.tablero_cdc_app_lists l
where l.is_active = true
and lower(trim(coalesce(l.value, ''))) = lower(trim(coalesce(v_item.country, '')))
and lower(trim(coalesce(l.category, ''))) in (
'country_manager', 'country manager', 'cm', 'bu_cm', 'bu cm', 'manager', 'managers'
)
order by coalesce(l.sort_order, 999999), l.label
limit 1;
exception
when undefined_table then
v_country_manager := '';
end;
v_title := coalesce(
nullif(trim(v_item.title), ''),
nullif(trim(v_item.client), ''),
'Brief CDC ' || v_item.brief_id
);
v_description := coalesce(nullif(trim(v_item.summary), ''), '');
insert into public.tablero_cdc_projects (
title,
client,
brand,
country,
requested_by,
country_manager,
description,
status,
internal_amount,
brief_link,
extra_data,
created_by,
updated_by
)
values (
v_title,
coalesce(v_item.client, ''),
coalesce(v_item.brand, ''),
coalesce(v_item.country, ''),
coalesce(v_item.requested_by, ''),
coalesce(v_country_manager, ''),
v_description,
'Activo',
null,
coalesce(nullif(trim(v_item.brief_link), ''), nullif(trim(v_item.document_link), ''), ''),
jsonb_strip_nulls(
jsonb_build_object(
'source', 'cdc_brief',
'source_brief_id', v_item.brief_id,
'source_brief_inbox_id', v_item.id,
'source_brief_created_at', v_item.source_created_at,
'cdc_brief_document_link', nullif(trim(v_item.document_link), ''),
'delivery_date', nullif(trim(v_item.delivery_date), ''),
'request_type', nullif(trim(v_item.request_type), ''),
'deliverable_type', nullif(trim(v_item.deliverable_type), ''),
'cdc_brief_fields', v_item.raw_fields,
'color', 'blue'
)
),
auth.uid(),
auth.uid()
)
returning id into v_project_id;
update public.tablero_cdc_brief_inbox
set
review_status = 'approved',
approved_project_id = v_project_id,
reviewed_by = auth.uid(),
reviewed_by_name = v_name,
reviewed_by_email = v_email,
reviewed_at = now(),
rejection_reason = null
where id = v_item.id;
-- Mantiene el historial existente cuando el módulo de actividad ya está instalado,
-- sin convertirlo en una dependencia obligatoria de esta migración.
if to_regclass('public.tablero_cdc_project_activity') is not null then
execute $activity$
insert into public.tablero_cdc_project_activity (
project_id,
activity_type,
title,
description,
actor_id,
actor_name,
actor_email
)
values ($1, 'created', 'Proyecto creado desde CDC Brief',
$5, $2, $3, $4)
$activity$
using v_project_id, auth.uid(), v_name, v_email, v_activity_description;
end if;
return jsonb_build_object(
'ok', true,
'already_approved', false,
'project_id', v_project_id,
'brief_id', v_item.brief_id
);
end;
$$;
revoke all on function public.tablero_cdc_approve_brief(uuid) from public;
grant execute on function public.tablero_cdc_approve_brief(uuid) to authenticated;
-- =============================================================
-- RPC: NO APROBAR BRIEF
-- =============================================================
create or replace function public.tablero_cdc_reject_brief(
p_inbox_id uuid,
p_reason text default null
)
returns jsonb
language plpgsql
security definer
set search_path = public, auth
as $$
declare
v_item public.tablero_cdc_brief_inbox%rowtype;
v_email text;
v_name text;
begin
if auth.uid() is null or not public.tablero_cdc_is_active_allowed_user() then
raise exception 'No tienes permiso para revisar briefs del Tablero CDC.';
end if;
select *
into v_item
from public.tablero_cdc_brief_inbox
where id = p_inbox_id
for update;
if not found then
raise exception 'No se encontró el brief solicitado.';
end if;
if v_item.review_status = 'rejected' then
return jsonb_build_object(
'ok', true,
'already_rejected', true,
'brief_id', v_item.brief_id
);
end if;
if v_item.review_status <> 'new' then
raise exception 'Este brief ya fue aprobado y no puede enviarse a No aprobados.';
end if;
v_email := lower(trim(coalesce(auth.jwt() ->> 'email', '')));
select coalesce(nullif(trim(u.full_name), ''), nullif(trim(u.email), ''), 'Usuario CDC')
into v_name
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = v_email
and u.is_active = true
limit 1;
v_name := coalesce(v_name, nullif(v_email, ''), 'Usuario CDC');
update public.tablero_cdc_brief_inbox
set
review_status = 'rejected',
rejection_reason = nullif(trim(coalesce(p_reason, '')), ''),
reviewed_by = auth.uid(),
reviewed_by_name = v_name,
reviewed_by_email = v_email,
reviewed_at = now(),
approved_project_id = null
where id = v_item.id;
return jsonb_build_object(
'ok', true,
'already_rejected', false,
'brief_id', v_item.brief_id
);
end;
$$;
revoke all on function public.tablero_cdc_reject_brief(uuid, text) from public;
grant execute on function public.tablero_cdc_reject_brief(uuid, text) to authenticated;
-- Realtime para que un brief nuevo o una decisión aparezcan/desaparezcan
-- sin que otro miembro del equipo tenga que recargar la página.
do $$
begin
if exists (select 1 from pg_publication where pubname = 'supabase_realtime')
and not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'tablero_cdc_brief_inbox'
) then
alter publication supabase_realtime add table public.tablero_cdc_brief_inbox;
end if;
if exists (select 1 from pg_publication where pubname = 'supabase_realtime')
and not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'tablero_cdc_brief_inbox_seen'
) then
alter publication supabase_realtime add table public.tablero_cdc_brief_inbox_seen;
end if;
end $$;
commit;
-- =============================================================
-- VERIFICACIÓN
-- =============================================================
select
review_status,
count(*) as cantidad
from public.tablero_cdc_brief_inbox
group by review_status
order by review_status;
select
proname as funcion,
pg_get_function_identity_arguments(p.oid) as argumentos
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and proname in ('tablero_cdc_approve_brief', 'tablero_cdc_reject_brief', 'tablero_cdc_get_unseen_brief_count', 'tablero_cdc_mark_briefs_seen')
order by proname;