573 lines
19 KiB
PL/PgSQL
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;
|