fix(prospecting): redact discovery data and suppress erased candidates

This commit is contained in:
Saraiva
2026-09-15 22:16:21 -03:00
parent 99245572f3
commit f8e4cbdab3
6 changed files with 584 additions and 0 deletions
+5
View File
@@ -25,3 +25,8 @@ scratchpad
.opencode
.specs
.changes
# Artefatos locais de QA não fazem parte do runtime.
evidence
.superpowers
*.tsbuildinfo
+8
View File
@@ -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.
+261
View File
@@ -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';
+1
View File
@@ -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. |
+50
View File
@@ -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/);
});
});