mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
fix(prospecting): redact discovery data and suppress erased candidates
This commit is contained in:
@@ -25,3 +25,8 @@ scratchpad
|
||||
.opencode
|
||||
.specs
|
||||
.changes
|
||||
|
||||
# Artefatos locais de QA não fazem parte do runtime.
|
||||
evidence
|
||||
.superpowers
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -25,3 +25,11 @@ Há uma campanha ativa por organização, até 50 tentativas em 24 horas no conj
|
||||
Entrada: administrador e pesquisa → `prospecting_campaigns/candidates`. Saída: `createContactHandler`, `createLeadHandler`, `sendMessageHandler` e turno do agente no Inbox. Comandos emitem `prospecting.changed`; cadastro e atendimento conservam as atividades canônicas. Resultados, erros e próximos envios aparecem em `/app/prospecting`, registrado no catálogo de navegação. A falha pausa a fila e exige revisão, e o resultado da conversa altera o estado exibido. A continuidade humana e IA usa o Inbox existente. Não responder não inicia novas insistências automaticamente; o operador revisa o histórico para decidir o próximo passo.
|
||||
|
||||
Mapa: `docs/architecture/prospeccao-nativa.architecture.json`.
|
||||
|
||||
### Anonimização e nova extração
|
||||
|
||||
A anonimização canônica do contato também limpa telefone, endereço, e-mails,
|
||||
links e enriquecimento do candidato e o retira da fila. Tokens pseudônimos,
|
||||
restritos ao servidor e nunca devolvidos pela API, impedem reimportar a mesma
|
||||
origem ou telefone na organização. A exclusão de dados no provedor de busca
|
||||
segue o processo próprio desse provedor.
|
||||
|
||||
@@ -25127,3 +25127,264 @@ alter table public.prospecting_candidates enable row level security;
|
||||
revoke all on public.prospecting_settings, public.prospecting_campaigns, public.prospecting_candidates from public, anon, authenticated;
|
||||
grant all on public.prospecting_settings, public.prospecting_campaigns, public.prospecting_candidates to service_role;
|
||||
notify pgrst, 'reload schema';
|
||||
|
||||
-- Migration 0263: native prospecting redaction and suppression
|
||||
-- 0263: Redact discovery data through the canonical contact cascade.
|
||||
-- Suppression tokens are pseudonymous, server-only and used exclusively to
|
||||
-- refuse re-import. The API explicitly selects public fields and never exposes them.
|
||||
alter table public.prospecting_candidates add column if not exists suppression_salt bytea;
|
||||
alter table public.prospecting_candidates add column if not exists suppression_place bytea;
|
||||
alter table public.prospecting_candidates add column if not exists suppression_phone bytea;
|
||||
create index if not exists prospecting_suppressed_org
|
||||
on public.prospecting_candidates(organization_id) where suppression_salt is not null;
|
||||
|
||||
create or replace function public.fn_prospecting_refuse_erased_candidate()
|
||||
returns trigger language plpgsql security definer
|
||||
set search_path = public, extensions, pg_temp as $$
|
||||
begin
|
||||
if exists (
|
||||
select 1 from public.prospecting_candidates p
|
||||
where p.organization_id = new.organization_id and p.suppression_salt is not null
|
||||
and (p.suppression_place = hmac(convert_to(new.place_id, 'UTF8'), p.suppression_salt, 'sha256')
|
||||
or (new.phone is not null and p.suppression_phone = hmac(convert_to(new.phone, 'UTF8'), p.suppression_salt, 'sha256')))
|
||||
) then
|
||||
return null;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
revoke all on function public.fn_prospecting_refuse_erased_candidate() from public, anon, authenticated;
|
||||
grant execute on function public.fn_prospecting_refuse_erased_candidate() to service_role;
|
||||
drop trigger if exists prospecting_refuse_erased on public.prospecting_candidates;
|
||||
create trigger prospecting_refuse_erased before insert on public.prospecting_candidates
|
||||
for each row execute function public.fn_prospecting_refuse_erased_candidate();
|
||||
|
||||
CREATE OR REPLACE FUNCTION "public"."fn_lgpd_cascade_redact_contact"("p_organization_id" "uuid", "p_contact_id" "uuid", "p_request_id" "uuid") RETURNS "jsonb"
|
||||
LANGUAGE "plpgsql" SECURITY DEFINER
|
||||
SET "search_path" TO 'public', 'extensions', 'pg_temp'
|
||||
AS $$
|
||||
declare
|
||||
v_already bool;
|
||||
v_counts jsonb := '{}'::jsonb;
|
||||
v_media_paths text[] := '{}';
|
||||
v_anon_label text;
|
||||
v_count int;
|
||||
begin
|
||||
perform public.fn_service_lock(p_organization_id,p_contact_id);
|
||||
select is_anonymized into v_already
|
||||
from contacts
|
||||
where id = p_contact_id and organization_id = p_organization_id;
|
||||
|
||||
if not found then
|
||||
raise exception 'contact not found' using errcode = 'P0002';
|
||||
end if;
|
||||
|
||||
if v_already then
|
||||
return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths);
|
||||
end if;
|
||||
|
||||
v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8);
|
||||
|
||||
-- Collect media storage paths (we only delete what we own — media_storage_path)
|
||||
select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}')
|
||||
into v_media_paths
|
||||
from messages
|
||||
where organization_id = p_organization_id
|
||||
and conversation_id in (
|
||||
select id from conversations
|
||||
where contact_id = p_contact_id and organization_id = p_organization_id
|
||||
);
|
||||
|
||||
-- 1. contacts (irreversible)
|
||||
update contacts set
|
||||
name = v_anon_label,
|
||||
display_name = v_anon_label,
|
||||
email = null,
|
||||
-- email_normalized NÃO entra: é GENERATED ALWAYS AS (lower(trim(email)))
|
||||
-- e o Postgres recusa escrita nela — a linha acima já a zera por derivação.
|
||||
-- Com a atribuição, o cascade INTEIRO abortava e nada era anonimizado.
|
||||
phone_number = null,
|
||||
cpf_encrypted = null,
|
||||
cpf_hash = null,
|
||||
birthdate = null,
|
||||
is_anonymized = true,
|
||||
anonymized_at = now(),
|
||||
consent = '{}'::jsonb,
|
||||
source_metadata = '{}'::jsonb,
|
||||
tags = '{}'::text[],
|
||||
updated_at = now()
|
||||
where id = p_contact_id and organization_id = p_organization_id;
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('contacts', v_count);
|
||||
|
||||
-- 2. conversations metadata + preview strip
|
||||
update conversations set
|
||||
metadata = '{}'::jsonb,
|
||||
last_message_preview = null,
|
||||
updated_at = now()
|
||||
where contact_id = p_contact_id and organization_id = p_organization_id;
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('conversations', v_count);
|
||||
|
||||
-- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id)
|
||||
update messages set
|
||||
body = '[mensagem anonimizada]',
|
||||
media_url = null,
|
||||
media_mime = null,
|
||||
media_size_bytes = null,
|
||||
media_storage_path = null,
|
||||
metadata = '{}'::jsonb,
|
||||
updated_at = now()
|
||||
where organization_id = p_organization_id
|
||||
and conversation_id in (
|
||||
select id from conversations
|
||||
where contact_id = p_contact_id and organization_id = p_organization_id
|
||||
);
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('messages', v_count);
|
||||
|
||||
-- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071).
|
||||
-- `reason` é texto livre escrito por LLM sobre a conversa do lead: supor que
|
||||
-- nunca conterá um nome é a suposição que falha. `evidence` NÃO é limpa —
|
||||
-- guarda só ids, e as linhas apontadas são redigidas por conta própria.
|
||||
update crm_lead_activities set
|
||||
payload = '{}'::jsonb,
|
||||
metadata = '{}'::jsonb,
|
||||
reason = null
|
||||
where organization_id = p_organization_id
|
||||
and (
|
||||
contact_id = p_contact_id
|
||||
or lead_id in (
|
||||
select lead_id from crm_lead_links
|
||||
where target_kind = 'contact'
|
||||
and target_id = p_contact_id
|
||||
and organization_id = p_organization_id
|
||||
)
|
||||
or lead_id in (
|
||||
select id from crm_leads
|
||||
where contact_id = p_contact_id and organization_id = p_organization_id
|
||||
)
|
||||
);
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('activities', v_count);
|
||||
|
||||
-- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value
|
||||
update crm_leads set
|
||||
title = v_anon_label,
|
||||
description = null,
|
||||
custom_fields = '{}'::jsonb,
|
||||
source_metadata = '{}'::jsonb,
|
||||
tags = '{}'::text[],
|
||||
updated_at = now()
|
||||
where organization_id = p_organization_id
|
||||
and (
|
||||
contact_id = p_contact_id
|
||||
or id in (
|
||||
select lead_id from crm_lead_links
|
||||
where target_kind = 'contact'
|
||||
and target_id = p_contact_id
|
||||
and organization_id = p_organization_id
|
||||
)
|
||||
);
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('leads', v_count);
|
||||
|
||||
-- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb
|
||||
-- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null.
|
||||
update orders set
|
||||
payload = (coalesce(payload, '{}'::jsonb))
|
||||
- 'customer'
|
||||
- 'customer_name'
|
||||
- 'customer_email'
|
||||
- 'customer_phone'
|
||||
- 'shipping_address'
|
||||
- 'billing_address'
|
||||
- 'contact_identification',
|
||||
customer_external_id = null,
|
||||
contact_id = null,
|
||||
is_anonymized = true,
|
||||
updated_at = now()
|
||||
where organization_id = p_organization_id
|
||||
and contact_id = p_contact_id;
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('orders', v_count);
|
||||
|
||||
-- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path))
|
||||
if array_length(v_media_paths, 1) > 0 then
|
||||
insert into storage_redaction_queue (organization_id, request_id, bucket, object_path)
|
||||
select p_organization_id, p_request_id, 'whatsapp-media', path
|
||||
from unnest(v_media_paths) as path
|
||||
where path is not null and length(path) > 0
|
||||
on conflict (bucket, object_path) do nothing;
|
||||
end if;
|
||||
|
||||
-- 7b. voice_calls — o TELEFONE de quem falou ao telefone (migration 0235).
|
||||
--
|
||||
-- `peer_phone` é `not null` e guarda o número da outra ponta: depois de
|
||||
-- anonimizar o contato, ele sobrevivia ligado ao `contact_id` e reidentificava
|
||||
-- a pessoa que pediu para ser esquecida. É o mesmo argumento que a foto de
|
||||
-- perfil já tinha (ver o bloco do avatar em `lib/lgpd/redact-cascade.ts`):
|
||||
-- anonimizar em toda parte menos numa é não ter anonimizado.
|
||||
--
|
||||
-- O que fica: direção, status, motivo do fim, marcas de tempo e duração. Um
|
||||
-- registro de "houve uma chamada de 12 minutos" sem número e sem dono não
|
||||
-- identifica ninguém e é o que sustenta a métrica do atendente e a fatura.
|
||||
-- `peer_phone` é NOT NULL, então recebe o rótulo, não `null`.
|
||||
update voice_calls set
|
||||
peer_phone = v_anon_label,
|
||||
owner_user_id = null,
|
||||
created_by = null,
|
||||
updated_at = now()
|
||||
where organization_id = p_organization_id
|
||||
and contact_id = p_contact_id;
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('voice_calls', v_count);
|
||||
|
||||
-- Native discovery stores commercial/person data before the Inbox exists.
|
||||
-- Keep only keyed suppression tokens, restricted to the server, to prevent
|
||||
-- another extraction from reintroducing this erased candidate.
|
||||
update prospecting_candidates set suppression_salt = gen_random_bytes(32)
|
||||
where organization_id = p_organization_id and contact_id = p_contact_id
|
||||
and suppression_salt is null;
|
||||
update prospecting_candidates set
|
||||
suppression_place = hmac(convert_to(place_id, 'UTF8'), suppression_salt, 'sha256'),
|
||||
suppression_phone = case when phone is null then null
|
||||
else hmac(convert_to(phone, 'UTF8'), suppression_salt, 'sha256') end,
|
||||
place_id = 'redacted:' || id::text,
|
||||
phone = null,
|
||||
data = jsonb_build_object('key', 'redacted:' || id::text,
|
||||
'name', v_anon_label, 'phone', null, 'website', null,
|
||||
'category', null, 'address', null, 'maps_url', null,
|
||||
'rating', null, 'reviews', null, 'emails', '[]'::jsonb, 'socials', '[]'::jsonb),
|
||||
status = 'skipped', service_boundary = null, error = null, updated_at = now()
|
||||
where organization_id = p_organization_id and contact_id = p_contact_id;
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('prospecting_candidates', v_count);
|
||||
|
||||
-- 8. dense audit row
|
||||
insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls)
|
||||
values (
|
||||
p_organization_id,
|
||||
'lgpd.redact_executed',
|
||||
null,
|
||||
'contact',
|
||||
p_contact_id,
|
||||
jsonb_build_object(
|
||||
'cascaded_to', v_counts,
|
||||
'media_queued', coalesce(array_length(v_media_paths, 1), 0),
|
||||
'request_id', p_request_id
|
||||
),
|
||||
true
|
||||
);
|
||||
|
||||
return jsonb_build_object(
|
||||
'already_anonymized', false,
|
||||
'counts', v_counts,
|
||||
'media_paths', v_media_paths
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
revoke all on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) from public,anon,authenticated;
|
||||
grant execute on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) to service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
-- 0263: Redact discovery data through the canonical contact cascade.
|
||||
-- Suppression tokens are pseudonymous, server-only and used exclusively to
|
||||
-- refuse re-import. The API explicitly selects public fields and never exposes them.
|
||||
alter table public.prospecting_candidates add column if not exists suppression_salt bytea;
|
||||
alter table public.prospecting_candidates add column if not exists suppression_place bytea;
|
||||
alter table public.prospecting_candidates add column if not exists suppression_phone bytea;
|
||||
create index if not exists prospecting_suppressed_org
|
||||
on public.prospecting_candidates(organization_id) where suppression_salt is not null;
|
||||
|
||||
create or replace function public.fn_prospecting_refuse_erased_candidate()
|
||||
returns trigger language plpgsql security definer
|
||||
set search_path = public, extensions, pg_temp as $$
|
||||
begin
|
||||
if exists (
|
||||
select 1 from public.prospecting_candidates p
|
||||
where p.organization_id = new.organization_id and p.suppression_salt is not null
|
||||
and (p.suppression_place = hmac(convert_to(new.place_id, 'UTF8'), p.suppression_salt, 'sha256')
|
||||
or (new.phone is not null and p.suppression_phone = hmac(convert_to(new.phone, 'UTF8'), p.suppression_salt, 'sha256')))
|
||||
) then
|
||||
return null;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
revoke all on function public.fn_prospecting_refuse_erased_candidate() from public, anon, authenticated;
|
||||
grant execute on function public.fn_prospecting_refuse_erased_candidate() to service_role;
|
||||
drop trigger if exists prospecting_refuse_erased on public.prospecting_candidates;
|
||||
create trigger prospecting_refuse_erased before insert on public.prospecting_candidates
|
||||
for each row execute function public.fn_prospecting_refuse_erased_candidate();
|
||||
|
||||
CREATE OR REPLACE FUNCTION "public"."fn_lgpd_cascade_redact_contact"("p_organization_id" "uuid", "p_contact_id" "uuid", "p_request_id" "uuid") RETURNS "jsonb"
|
||||
LANGUAGE "plpgsql" SECURITY DEFINER
|
||||
SET "search_path" TO 'public', 'extensions', 'pg_temp'
|
||||
AS $$
|
||||
declare
|
||||
v_already bool;
|
||||
v_counts jsonb := '{}'::jsonb;
|
||||
v_media_paths text[] := '{}';
|
||||
v_anon_label text;
|
||||
v_count int;
|
||||
begin
|
||||
perform public.fn_service_lock(p_organization_id,p_contact_id);
|
||||
select is_anonymized into v_already
|
||||
from contacts
|
||||
where id = p_contact_id and organization_id = p_organization_id;
|
||||
|
||||
if not found then
|
||||
raise exception 'contact not found' using errcode = 'P0002';
|
||||
end if;
|
||||
|
||||
if v_already then
|
||||
return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths);
|
||||
end if;
|
||||
|
||||
v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8);
|
||||
|
||||
-- Collect media storage paths (we only delete what we own — media_storage_path)
|
||||
select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}')
|
||||
into v_media_paths
|
||||
from messages
|
||||
where organization_id = p_organization_id
|
||||
and conversation_id in (
|
||||
select id from conversations
|
||||
where contact_id = p_contact_id and organization_id = p_organization_id
|
||||
);
|
||||
|
||||
-- 1. contacts (irreversible)
|
||||
update contacts set
|
||||
name = v_anon_label,
|
||||
display_name = v_anon_label,
|
||||
email = null,
|
||||
-- email_normalized NÃO entra: é GENERATED ALWAYS AS (lower(trim(email)))
|
||||
-- e o Postgres recusa escrita nela — a linha acima já a zera por derivação.
|
||||
-- Com a atribuição, o cascade INTEIRO abortava e nada era anonimizado.
|
||||
phone_number = null,
|
||||
cpf_encrypted = null,
|
||||
cpf_hash = null,
|
||||
birthdate = null,
|
||||
is_anonymized = true,
|
||||
anonymized_at = now(),
|
||||
consent = '{}'::jsonb,
|
||||
source_metadata = '{}'::jsonb,
|
||||
tags = '{}'::text[],
|
||||
updated_at = now()
|
||||
where id = p_contact_id and organization_id = p_organization_id;
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('contacts', v_count);
|
||||
|
||||
-- 2. conversations metadata + preview strip
|
||||
update conversations set
|
||||
metadata = '{}'::jsonb,
|
||||
last_message_preview = null,
|
||||
updated_at = now()
|
||||
where contact_id = p_contact_id and organization_id = p_organization_id;
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('conversations', v_count);
|
||||
|
||||
-- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id)
|
||||
update messages set
|
||||
body = '[mensagem anonimizada]',
|
||||
media_url = null,
|
||||
media_mime = null,
|
||||
media_size_bytes = null,
|
||||
media_storage_path = null,
|
||||
metadata = '{}'::jsonb,
|
||||
updated_at = now()
|
||||
where organization_id = p_organization_id
|
||||
and conversation_id in (
|
||||
select id from conversations
|
||||
where contact_id = p_contact_id and organization_id = p_organization_id
|
||||
);
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('messages', v_count);
|
||||
|
||||
-- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071).
|
||||
-- `reason` é texto livre escrito por LLM sobre a conversa do lead: supor que
|
||||
-- nunca conterá um nome é a suposição que falha. `evidence` NÃO é limpa —
|
||||
-- guarda só ids, e as linhas apontadas são redigidas por conta própria.
|
||||
update crm_lead_activities set
|
||||
payload = '{}'::jsonb,
|
||||
metadata = '{}'::jsonb,
|
||||
reason = null
|
||||
where organization_id = p_organization_id
|
||||
and (
|
||||
contact_id = p_contact_id
|
||||
or lead_id in (
|
||||
select lead_id from crm_lead_links
|
||||
where target_kind = 'contact'
|
||||
and target_id = p_contact_id
|
||||
and organization_id = p_organization_id
|
||||
)
|
||||
or lead_id in (
|
||||
select id from crm_leads
|
||||
where contact_id = p_contact_id and organization_id = p_organization_id
|
||||
)
|
||||
);
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('activities', v_count);
|
||||
|
||||
-- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value
|
||||
update crm_leads set
|
||||
title = v_anon_label,
|
||||
description = null,
|
||||
custom_fields = '{}'::jsonb,
|
||||
source_metadata = '{}'::jsonb,
|
||||
tags = '{}'::text[],
|
||||
updated_at = now()
|
||||
where organization_id = p_organization_id
|
||||
and (
|
||||
contact_id = p_contact_id
|
||||
or id in (
|
||||
select lead_id from crm_lead_links
|
||||
where target_kind = 'contact'
|
||||
and target_id = p_contact_id
|
||||
and organization_id = p_organization_id
|
||||
)
|
||||
);
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('leads', v_count);
|
||||
|
||||
-- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb
|
||||
-- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null.
|
||||
update orders set
|
||||
payload = (coalesce(payload, '{}'::jsonb))
|
||||
- 'customer'
|
||||
- 'customer_name'
|
||||
- 'customer_email'
|
||||
- 'customer_phone'
|
||||
- 'shipping_address'
|
||||
- 'billing_address'
|
||||
- 'contact_identification',
|
||||
customer_external_id = null,
|
||||
contact_id = null,
|
||||
is_anonymized = true,
|
||||
updated_at = now()
|
||||
where organization_id = p_organization_id
|
||||
and contact_id = p_contact_id;
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('orders', v_count);
|
||||
|
||||
-- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path))
|
||||
if array_length(v_media_paths, 1) > 0 then
|
||||
insert into storage_redaction_queue (organization_id, request_id, bucket, object_path)
|
||||
select p_organization_id, p_request_id, 'whatsapp-media', path
|
||||
from unnest(v_media_paths) as path
|
||||
where path is not null and length(path) > 0
|
||||
on conflict (bucket, object_path) do nothing;
|
||||
end if;
|
||||
|
||||
-- 7b. voice_calls — o TELEFONE de quem falou ao telefone (migration 0235).
|
||||
--
|
||||
-- `peer_phone` é `not null` e guarda o número da outra ponta: depois de
|
||||
-- anonimizar o contato, ele sobrevivia ligado ao `contact_id` e reidentificava
|
||||
-- a pessoa que pediu para ser esquecida. É o mesmo argumento que a foto de
|
||||
-- perfil já tinha (ver o bloco do avatar em `lib/lgpd/redact-cascade.ts`):
|
||||
-- anonimizar em toda parte menos numa é não ter anonimizado.
|
||||
--
|
||||
-- O que fica: direção, status, motivo do fim, marcas de tempo e duração. Um
|
||||
-- registro de "houve uma chamada de 12 minutos" sem número e sem dono não
|
||||
-- identifica ninguém e é o que sustenta a métrica do atendente e a fatura.
|
||||
-- `peer_phone` é NOT NULL, então recebe o rótulo, não `null`.
|
||||
update voice_calls set
|
||||
peer_phone = v_anon_label,
|
||||
owner_user_id = null,
|
||||
created_by = null,
|
||||
updated_at = now()
|
||||
where organization_id = p_organization_id
|
||||
and contact_id = p_contact_id;
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('voice_calls', v_count);
|
||||
|
||||
-- Native discovery stores commercial/person data before the Inbox exists.
|
||||
-- Keep only keyed suppression tokens, restricted to the server, to prevent
|
||||
-- another extraction from reintroducing this erased candidate.
|
||||
update prospecting_candidates set suppression_salt = gen_random_bytes(32)
|
||||
where organization_id = p_organization_id and contact_id = p_contact_id
|
||||
and suppression_salt is null;
|
||||
update prospecting_candidates set
|
||||
suppression_place = hmac(convert_to(place_id, 'UTF8'), suppression_salt, 'sha256'),
|
||||
suppression_phone = case when phone is null then null
|
||||
else hmac(convert_to(phone, 'UTF8'), suppression_salt, 'sha256') end,
|
||||
place_id = 'redacted:' || id::text,
|
||||
phone = null,
|
||||
data = jsonb_build_object('key', 'redacted:' || id::text,
|
||||
'name', v_anon_label, 'phone', null, 'website', null,
|
||||
'category', null, 'address', null, 'maps_url', null,
|
||||
'rating', null, 'reviews', null, 'emails', '[]'::jsonb, 'socials', '[]'::jsonb),
|
||||
status = 'skipped', service_boundary = null, error = null, updated_at = now()
|
||||
where organization_id = p_organization_id and contact_id = p_contact_id;
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('prospecting_candidates', v_count);
|
||||
|
||||
-- 8. dense audit row
|
||||
insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls)
|
||||
values (
|
||||
p_organization_id,
|
||||
'lgpd.redact_executed',
|
||||
null,
|
||||
'contact',
|
||||
p_contact_id,
|
||||
jsonb_build_object(
|
||||
'cascaded_to', v_counts,
|
||||
'media_queued', coalesce(array_length(v_media_paths, 1), 0),
|
||||
'request_id', p_request_id
|
||||
),
|
||||
true
|
||||
);
|
||||
|
||||
return jsonb_build_object(
|
||||
'already_anonymized', false,
|
||||
'counts', v_counts,
|
||||
'media_paths', v_media_paths
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
revoke all on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) from public,anon,authenticated;
|
||||
grant execute on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) to service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -321,3 +321,4 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
|
||||
| `20260915150000` | `0259_indices_redundantes_saem` | Três índices cujo trabalho já é feito por outro, integralmente. (1) `ai_models_provider_model_unique (provider, model_id)`, criado pela 0127, contra a constraint `ai_models_unique (provider, model_id)` do schema original — mesmas colunas, mesma ordem, os dois UNIQUE: é o "índice duplicado em ai_models" que o advisor de desempenho apontou numa VPS de cliente. Fica a constraint (dá nome à violação, aparece em `pg_constraint`, não cai com um `drop index` por engano); o índice sai, dentro de um guard que confere que a constraint existe — num clone onde ela tenha sido removida à mão, o índice da 0127 é a ÚNICA coisa impedindo cadastro duplicado de modelo. (2) `idx_crm_lead_links_lead (lead_id)` contra `uniq_crm_lead_links_lead_target_link (lead_id, target_kind, target_id, link_kind)` e (3) `calendar_connections_org_pessoa_idx (organization_id, user_id)` contra `calendar_connections_conta_key (organization_id, user_id, provider, account_email)`: um btree responde por qualquer PREFIXO das suas colunas, então o de quatro atende tudo que o de um ou dois atendia. Índice redundante custa em todo insert/update e ocupa disco. **O planner não os ignorava**: medido em pg17 (20 000 vínculos, 2 000 leads), a busca por `lead_id` usava o de uma coluna quando ele existia (216 kB, custo 4,36) e passa a usar o de quatro (1464 kB, custo 4,49) — segue servida por índice; troca-se índice menor na leitura por um índice a menos em toda escrita. **Os três drops têm guard**, e não só o 1: o `update.sh` roda sem `ON_ERROR_STOP`, e "o largo é declarado no mesmo baseline" não prova que ele existe num clone onde a criação falhou em silêncio. **O baseline deixou de criá-los para derrubar no fim**: saiu a linha do dump (`idx_crm_lead_links_lead`) e a do bloco do calendário, e o bloco da 0127 só cria o seu índice onde a constraint `ai_models_unique` falta. Antes, toda instalação e todo `update.sh` construía os três (`CREATE INDEX` não concorrente, que trava escrita enquanto constrói) e os jogava fora no apêndice. Estado final igual ao da cadeia (0127 cria, 0259 derruba). |
|
||||
|
||||
| `20260916010000` | `0262_prospeccao_nativa` | Busca com orçamento, candidatos únicos por organização e campanhas graduais com pausa e histórico; acesso exclusivo pelo servidor autenticado. |
|
||||
| `20260916020000` | `0263_prospeccao_anonimizacao` | Cascata canônica apaga telefone e enriquecimento; tokens pseudônimos restritos ao servidor impedem reimportar a mesma origem ou telefone após anonimização. |
|
||||
|
||||
@@ -55,3 +55,53 @@ describe("prospecting tenant boundary and durable deduplication", () => {
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("prospecting contact erasure", () => {
|
||||
it("redacts discovery data, stops outreach, isolates tenants and refuses re-import", () => {
|
||||
const contact = "30000000-0000-4000-8000-000000000011";
|
||||
sql(`insert into contacts(id,organization_id,name,phone_number) values ('${contact}','${a}','Pessoa Teste','+5511988880000');
|
||||
insert into prospecting_candidates(organization_id,campaign_id,place_id,phone,data,status,contact_id,error)
|
||||
values ('${a}','${campaignA}','erase-place','+5511988880000','{"name":"Pessoa Teste","emails":["pessoa@example.test"],"address":"Rua Teste"}','queued','${contact}','Pessoa Teste');
|
||||
select fn_lgpd_cascade_redact_contact('${a}','${contact}',gen_random_uuid());`);
|
||||
const erased = JSON.parse(
|
||||
sql(
|
||||
`select json_build_object('phone',phone,'data',data,'status',status,'error',error,'place_id',place_id,'salt_bytes',octet_length(suppression_salt)) from prospecting_candidates where contact_id='${contact}' and organization_id='${a}'`,
|
||||
),
|
||||
);
|
||||
expect(erased).toMatchObject({
|
||||
phone: null,
|
||||
status: "skipped",
|
||||
error: null,
|
||||
salt_bytes: 32,
|
||||
data: { emails: [], socials: [], address: null, phone: null },
|
||||
});
|
||||
expect(erased.place_id).toMatch(/^redacted:/);
|
||||
expect(JSON.stringify(erased)).not.toMatch(
|
||||
/Pessoa Teste|pessoa@example|Rua Teste|5511988880000/,
|
||||
);
|
||||
expect(
|
||||
sql(
|
||||
`select fn_lgpd_cascade_redact_contact('${a}','${contact}',gen_random_uuid())->>'already_anonymized'`,
|
||||
),
|
||||
).toBe("true");
|
||||
sql(`insert into prospecting_candidates(organization_id,campaign_id,place_id,phone,data) values
|
||||
('${a}','${campaignA}','erase-place','+5511977770000','{}'),
|
||||
('${a}','${campaignA}','different-erase-place','+5511988880000','{}'),
|
||||
('${b}','${campaignB}','erase-place','+5511988880000','{}');`);
|
||||
expect(
|
||||
sql(
|
||||
`select count(*) from prospecting_candidates where organization_id='${a}' and (place_id in ('erase-place','different-erase-place') or phone='+5511988880000')`,
|
||||
),
|
||||
).toBe("0");
|
||||
expect(
|
||||
sql(
|
||||
`select count(*) from prospecting_candidates where organization_id='${b}' and place_id='erase-place' and phone='+5511988880000'`,
|
||||
),
|
||||
).toBe("1");
|
||||
expect(
|
||||
sql(
|
||||
`select data->>'name' from prospecting_candidates where organization_id='${a}' and contact_id='${contact}'`,
|
||||
),
|
||||
).toMatch(/^Cliente Anonimizado/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user