mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
Merge remote-tracking branch 'origin/main' into feat/org-operante
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
impacto: nada_mudou
|
||||
secao: corrigido
|
||||
titulo: O agente respeita os funis autorizados ao atualizar a etapa de um negócio
|
||||
---
|
||||
|
||||
Ao confirmar uma etapa durante a conversa, o agente agora passa ao espelho do CRM os funis autorizados em sua versão publicada. Um agente sem acesso ao funil não move mais o cartão, inclusive quando retoma um caso interno; agentes com o funil autorizado continuam podendo atualizar a etapa. O estado da conversa e a resposta ao cliente seguem seu fluxo normal.
|
||||
+25
-1
@@ -8,6 +8,28 @@ Se você roda o DeskcommCRM numa VPS, **leia a seção da versão para a qual es
|
||||
|
||||
## [Não lançado]
|
||||
|
||||
## [1.67.0] — 2026-09-30
|
||||
|
||||
### Adicionado
|
||||
|
||||
- **O tempo que o agente espera antes de responder passa a ser ajustável por número** A proteção de envio de cada número (em Conexões › Proteção de envio) ganhou quatro campos para o "tempo de pensar" do agente antes da primeira mensagem da resposta: o tempo para ver a notificação, o tempo por caractere digitado, o mínimo e o máximo. Campo vazio mantém exatamente o ritmo de antes (0,9 s + 22 ms por caractere, entre 1,2 s e 7,5 s). Um mínimo acima do máximo é recusado na hora, com a explicação na tela.
|
||||
|
||||
Quando a resposta sai em várias mensagens, o intervalo entre elas passa a seguir o "intervalo entre mensagens" configurado para o número, em vez de um valor fixo de 1,2 s a 2 s. Quem nunca mudou esse intervalo não nota diferença; quem mudou vê as mensagens da resposta seguindo o valor escolhido.
|
||||
|
||||
Não é preciso fazer nada na instalação: a atualização acrescenta as colunas sozinha.
|
||||
|
||||
Contribuição de @webtecnica (#1996), fechando a #653.
|
||||
|
||||
- **Espera antes de responder (janela de rajada) configurável por agente** No cadastro do agente, em "Freios de segurança", agora dá para ajustar quanto tempo o agente espera antes de responder mensagens do mesmo contato que chegam em partes (ex.: "Obrigada" e, 14 s depois, "Tem piscina?"). Deixar o campo vazio usa a janela padrão da instalação (8 s), e o valor máximo é 60 segundos — nada de travar o atendimento sem querer. Não muda nada para quem não mexer: o campo é opcional e o comportamento de antes (janela padrão da instalação) segue valendo quando ele está vazio.
|
||||
|
||||
Contribuição de @webtecnica (#1997).
|
||||
|
||||
## [1.66.1] — 2026-09-30
|
||||
|
||||
### Corrigido
|
||||
|
||||
- **Anonimizar um contato passa a apagar também a transcrição dos áudios e o texto lido das imagens dele** Quando um contato era anonimizado (LGPD), o texto das mensagens virava "[mensagem anonimizada]", mas a transcrição automática dos áudios e o texto lido das imagens e documentos continuavam guardados e legíveis, inclusive para o agente de IA. Agora a anonimização apaga esse texto junto, pelo pedido formal e pelo botão da ficha, e a atualização limpa também os contatos que já tinham sido anonimizados antes. Não é preciso fazer nada na instalação. Achado na triagem do #1988 (@AlecLimaDev).
|
||||
|
||||
## [1.66.0] — 2026-09-30
|
||||
|
||||
### Adicionado
|
||||
@@ -9639,7 +9661,9 @@ Primeira versão marcada do DeskcommCRM. O projeto vinha sendo desenvolvido publ
|
||||
|
||||
- **Node 22 é obrigatório para desenvolvimento.** A suíte de invariantes instancia o cliente do Supabase, que exige o `WebSocket` global — nativo apenas a partir do Node 22. Isso não afeta quem apenas hospeda: a VPS roda a imagem pronta.
|
||||
|
||||
[Não lançado]: https://github.com/melgarafael/DeskcommCRM/compare/v1.66.0...HEAD
|
||||
[Não lançado]: https://github.com/melgarafael/DeskcommCRM/compare/v1.67.0...HEAD
|
||||
[1.67.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.66.1...v1.67.0
|
||||
[1.66.1]: https://github.com/melgarafael/DeskcommCRM/compare/v1.66.0...v1.66.1
|
||||
[1.66.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.65.0...v1.66.0
|
||||
[1.65.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.64.1...v1.65.0
|
||||
[1.64.1]: https://github.com/melgarafael/DeskcommCRM/compare/v1.64.0...v1.64.1
|
||||
|
||||
@@ -19,7 +19,7 @@ import { traduzir } from "@/lib/i18n/dicionario";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VERSION_COLUMNS =
|
||||
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
|
||||
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
|
||||
|
||||
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
@@ -124,6 +124,7 @@ export async function PATCH(req: NextRequest, ctx: Ctx): Promise<Response> {
|
||||
if (patch.cases_enabled !== undefined) update.cases_enabled = patch.cases_enabled;
|
||||
if (patch.split_messages !== undefined) update.split_messages = patch.split_messages;
|
||||
if (patch.split_max_chars !== undefined) update.split_max_chars = patch.split_max_chars;
|
||||
if (patch.inbound_debounce_ms !== undefined) update.inbound_debounce_ms = patch.inbound_debounce_ms;
|
||||
if (patch.followup !== undefined) {
|
||||
const existingFollowup =
|
||||
existing.followup !== null &&
|
||||
|
||||
@@ -23,7 +23,7 @@ import { traduzir } from "@/lib/i18n/dicionario";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VERSION_COLUMNS =
|
||||
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
|
||||
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
|
||||
|
||||
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
@@ -163,6 +163,7 @@ export async function POST(req: NextRequest, ctx: Ctx): Promise<Response> {
|
||||
cases_enabled: v.cases_enabled,
|
||||
split_messages: v.split_messages,
|
||||
split_max_chars: v.split_max_chars,
|
||||
inbound_debounce_ms: v.inbound_debounce_ms,
|
||||
followup: v.followup,
|
||||
// Mesmo descarte silencioso da rota de criação de agente: o corpo aceita
|
||||
// e o INSERT ignorava. Criar uma versão nova pela API com escopo ou
|
||||
|
||||
@@ -48,7 +48,7 @@ const AGENT_COLUMNS_COM_VERSAO =
|
||||
", versao_publicada:ai_agent_versions!ai_agents_published_version_id_fkey(provider, model)";
|
||||
|
||||
const VERSION_COLUMNS =
|
||||
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
|
||||
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET — list
|
||||
|
||||
@@ -31,7 +31,7 @@ export const dynamic = "force-dynamic";
|
||||
const KNOB_COLUMNS =
|
||||
// As duas janelas entram no SELECT: sem `resposta_*` aqui, a ficha Anti-ban
|
||||
// mostraria 7h-22h como se fosse a janela da resposta — e é a de DISPARO.
|
||||
"throttle_ms, jitter_max_ms, window_start_hour, window_end_hour, resposta_start_hour, resposta_end_hour, allow_sunday, timezone, warmup_daily_caps, number_activated_at";
|
||||
"throttle_ms, jitter_max_ms, window_start_hour, window_end_hour, resposta_start_hour, resposta_end_hour, allow_sunday, timezone, warmup_daily_caps, number_activated_at, atraso_notar_ms, ms_por_caractere, atraso_minimo_ms, atraso_maximo_ms";
|
||||
|
||||
/**
|
||||
* `organizations.timezone`, para a tela mostrar o fuso em que o motor avalia a
|
||||
@@ -167,6 +167,10 @@ export async function PUT(req: NextRequest): Promise<Response> {
|
||||
window_end_hour: null,
|
||||
resposta_start_hour: null,
|
||||
resposta_end_hour: null,
|
||||
atraso_notar_ms: null,
|
||||
ms_por_caractere: null,
|
||||
atraso_minimo_ms: null,
|
||||
atraso_maximo_ms: null,
|
||||
allow_sunday: null,
|
||||
timezone: null,
|
||||
warmup_daily_caps: null,
|
||||
@@ -195,6 +199,17 @@ export async function PUT(req: NextRequest): Promise<Response> {
|
||||
{ requestId },
|
||||
);
|
||||
}
|
||||
// Atraso humano (0499), pelo mesmo par-resultante: com os dois gravados o
|
||||
// CHECK do banco recusaria e a tela leria um 500 genérico; com só o mínimo
|
||||
// acima do teto padrão, o clamp ignoraria o mínimo sem aviso.
|
||||
if (eff.atrasoMinimoMs > eff.atrasoMaximoMs) {
|
||||
return fail(
|
||||
"validation_failed",
|
||||
`Atraso humano inválido: o mínimo (${eff.atrasoMinimoMs} ms) precisa ser menor ou igual ao máximo (${eff.atrasoMaximoMs} ms).`,
|
||||
422,
|
||||
{ requestId },
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(knobFields).length > 0) {
|
||||
const { error: upErr } = await admin.from("channel_knobs").upsert(
|
||||
|
||||
@@ -38,7 +38,7 @@ import { VALID_TOOL_IDS } from "@/lib/mcp/tools";
|
||||
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
const VERSION_COLUMNS =
|
||||
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
|
||||
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
|
||||
|
||||
type ActionResult<T = void> =
|
||||
| { ok: true; data?: T }
|
||||
@@ -320,6 +320,7 @@ export async function saveAgentDraftAction(
|
||||
knowledge_source_ids: v.knowledge_source_ids,
|
||||
split_messages: v.split_messages,
|
||||
split_max_chars: v.split_max_chars,
|
||||
inbound_debounce_ms: v.inbound_debounce_ms ?? null,
|
||||
followup: v.followup,
|
||||
status: "draft",
|
||||
created_by: authUser.id,
|
||||
@@ -537,6 +538,7 @@ export async function revertToVersionAction(
|
||||
knowledge_source_ids: string[];
|
||||
split_messages: boolean;
|
||||
split_max_chars: number;
|
||||
inbound_debounce_ms: number | null;
|
||||
};
|
||||
const src = source as unknown as SourceRow;
|
||||
|
||||
@@ -586,6 +588,7 @@ export async function revertToVersionAction(
|
||||
knowledge_source_ids: src.knowledge_source_ids ?? [],
|
||||
split_messages: src.split_messages,
|
||||
split_max_chars: src.split_max_chars,
|
||||
inbound_debounce_ms: src.inbound_debounce_ms ?? null,
|
||||
status: "draft",
|
||||
created_by: authUser.id,
|
||||
})
|
||||
@@ -739,6 +742,7 @@ export async function createMcpAgentAction(
|
||||
cases_enabled: v.cases_enabled,
|
||||
split_messages: v.split_messages,
|
||||
split_max_chars: v.split_max_chars,
|
||||
inbound_debounce_ms: v.inbound_debounce_ms ?? null,
|
||||
// O corpo ACEITAVA estes cinco e o INSERT os descartava: criar o assistente
|
||||
// pela tela com papel Operador, escopo de funil ou material marcado produzia
|
||||
// uma versão com tudo no default do banco — desligado e vazio.
|
||||
|
||||
@@ -184,6 +184,8 @@ interface FormState {
|
||||
cases_enabled: boolean;
|
||||
split_messages: boolean;
|
||||
split_max_chars: number;
|
||||
/** Janela de rajada (ms) do agente. `null` = usa a env da instalação. */
|
||||
inbound_debounce_ms: number | null;
|
||||
followup: FollowupValue;
|
||||
// Papel OPERADOR (spec 16 §3.2) — o que mexe no sistema depois da conversa.
|
||||
operator_enabled: boolean;
|
||||
@@ -284,6 +286,7 @@ export function buildState(args: {
|
||||
cases_enabled: version?.cases_enabled ?? false,
|
||||
split_messages: version?.split_messages ?? false,
|
||||
split_max_chars: version?.split_max_chars ?? 600,
|
||||
inbound_debounce_ms: version?.inbound_debounce_ms ?? null,
|
||||
followup: version?.followup
|
||||
? {
|
||||
...DEFAULT_FOLLOWUP,
|
||||
@@ -347,6 +350,7 @@ function toVersionPayload(s: FormState) {
|
||||
cases_enabled: s.cases_enabled,
|
||||
split_messages: s.split_messages,
|
||||
split_max_chars: s.split_max_chars,
|
||||
inbound_debounce_ms: s.inbound_debounce_ms,
|
||||
followup: s.followup,
|
||||
operator_enabled: s.operator_enabled,
|
||||
// "" (não escolheu) → null (herda o do Conversador). São o mesmo conceito em
|
||||
@@ -1060,6 +1064,42 @@ export function AgentForm(props: Props) {
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label htmlFor="inbound_debounce_ms">
|
||||
{t("Esperar antes de responder (segundos)")}
|
||||
</Label>
|
||||
<Input
|
||||
id="inbound_debounce_ms"
|
||||
type="number"
|
||||
min={0}
|
||||
max={60}
|
||||
step={1}
|
||||
placeholder={t("Vazio = padrão da instalação")}
|
||||
value={
|
||||
form.inbound_debounce_ms === null
|
||||
? ""
|
||||
: String(Math.round(form.inbound_debounce_ms / 1000))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
// Vazio = usa a env da instalação (campo null). A UI fala em
|
||||
// SEGUNDOS; o banco e o worker falam em ms (conversão aqui,
|
||||
// num ponto só). Teto de 60s no campo espelha o do worker.
|
||||
patch({
|
||||
inbound_debounce_ms:
|
||||
raw === ""
|
||||
? null
|
||||
: Math.round(Math.max(0, Math.min(60, Number(raw))) * 1000),
|
||||
});
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Mensagens do mesmo contato dentro desse tempo viram uma resposta só. Vazio usa a janela padrão da instalação (máximo 60 segundos).",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -91,6 +91,7 @@ function buildFieldChanges(a: AgentVersionRow, b: AgentVersionRow): FieldChange[
|
||||
["cases_enabled", "cases_enabled"],
|
||||
["split_messages", "split_messages"],
|
||||
["split_max_chars", "split_max_chars"],
|
||||
["inbound_debounce_ms", "inbound_debounce_ms"],
|
||||
];
|
||||
return fields
|
||||
.filter(([k]) => a[k] !== b[k])
|
||||
|
||||
@@ -26,7 +26,7 @@ const AGENT_COLUMNS =
|
||||
"id, organization_id, name, description, model, system_prompt, is_active, is_default, kind, channel, priority, published_version_id, paused_at, operation_mode, operation_revision, archived_at, config, guardrails, active_kb_version_id, created_at, updated_at";
|
||||
|
||||
const VERSION_COLUMNS =
|
||||
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
|
||||
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
|
||||
|
||||
const CREDENTIAL_COLUMNS =
|
||||
"id, organization_id, provider, label, api_key_last4, validated_at, validation_error, models_available, is_active, created_by, created_at, updated_at";
|
||||
|
||||
@@ -42,6 +42,11 @@ interface FormState {
|
||||
resposta_end_hour: string;
|
||||
throttle_s: string;
|
||||
jitter_s: string;
|
||||
/** Atraso humano antes da 1ª bolha (0499). '' = usar o padrão. */
|
||||
atraso_notar_ms: string;
|
||||
ms_por_caractere: string;
|
||||
atraso_minimo_ms: string;
|
||||
atraso_maximo_ms: string;
|
||||
daily_message_limit: string;
|
||||
allow_sunday: boolean;
|
||||
timezone: string;
|
||||
@@ -59,6 +64,10 @@ function fromItem(item: PacingKnobsItem): FormState {
|
||||
resposta_end_hour: o?.resposta_end_hour != null ? String(o.resposta_end_hour) : "",
|
||||
throttle_s: o?.throttle_ms != null ? String(o.throttle_ms / 1000) : "",
|
||||
jitter_s: o?.jitter_max_ms != null ? String(o.jitter_max_ms / 1000) : "",
|
||||
atraso_notar_ms: o?.atraso_notar_ms != null ? String(o.atraso_notar_ms) : "",
|
||||
ms_por_caractere: o?.ms_por_caractere != null ? String(o.ms_por_caractere) : "",
|
||||
atraso_minimo_ms: o?.atraso_minimo_ms != null ? String(o.atraso_minimo_ms) : "",
|
||||
atraso_maximo_ms: o?.atraso_maximo_ms != null ? String(o.atraso_maximo_ms) : "",
|
||||
daily_message_limit:
|
||||
item.channel_session.daily_message_limit != null
|
||||
? String(item.channel_session.daily_message_limit)
|
||||
@@ -147,6 +156,10 @@ export function AntiBanSheet({ item, canWrite, onClose }: Props) {
|
||||
resposta_end_hour: intOrNull(form.resposta_end_hour),
|
||||
throttle_ms: msOrNull(form.throttle_s),
|
||||
jitter_max_ms: msOrNull(form.jitter_s),
|
||||
atraso_notar_ms: intOrNull(form.atraso_notar_ms),
|
||||
ms_por_caractere: intOrNull(form.ms_por_caractere),
|
||||
atraso_minimo_ms: intOrNull(form.atraso_minimo_ms),
|
||||
atraso_maximo_ms: intOrNull(form.atraso_maximo_ms),
|
||||
// `null` quando o Switch está no default: salvar esta ficha por outro
|
||||
// motivo (aquecimento, throttle) não pode congelar o padrão do dia como
|
||||
// escolha permanente — foi assim que uma instalação ficou muda todo
|
||||
@@ -356,6 +369,77 @@ export function AntiBanSheet({ item, canWrite, onClose }: Props) {
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<Label>{t("Atraso humano antes da primeira resposta (ms)")}</Label>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<label className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{t("Ver a notificação")}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={item.bounds.intervalMaxMs}
|
||||
inputMode="numeric"
|
||||
placeholder={String(eff.atrasoNotarMs)}
|
||||
value={form.atraso_notar_ms}
|
||||
onChange={(e) => set({ atraso_notar_ms: e.target.value })}
|
||||
disabled={!canWrite}
|
||||
aria-label={t("Tempo para ver a notificação em ms")}
|
||||
className="w-24"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{t("Por caractere")}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={item.bounds.msPorCaractereMax}
|
||||
inputMode="numeric"
|
||||
placeholder={String(eff.msPorCaractere)}
|
||||
value={form.ms_por_caractere}
|
||||
onChange={(e) => set({ ms_por_caractere: e.target.value })}
|
||||
disabled={!canWrite}
|
||||
aria-label={t("Milissegundos por caractere da resposta")}
|
||||
className="w-24"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{t("Mínimo")}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={item.bounds.intervalMaxMs}
|
||||
inputMode="numeric"
|
||||
placeholder={String(eff.atrasoMinimoMs)}
|
||||
value={form.atraso_minimo_ms}
|
||||
onChange={(e) => set({ atraso_minimo_ms: e.target.value })}
|
||||
disabled={!canWrite}
|
||||
aria-label={t("Atraso humano mínimo em ms")}
|
||||
className="w-24"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{t("Máximo")}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={item.bounds.atrasoMaximoMsMax}
|
||||
inputMode="numeric"
|
||||
placeholder={String(eff.atrasoMaximoMs)}
|
||||
value={form.atraso_maximo_ms}
|
||||
onChange={(e) => set({ atraso_maximo_ms: e.target.value })}
|
||||
disabled={!canWrite}
|
||||
aria-label={t("Atraso humano máximo em ms")}
|
||||
className="w-24"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Quanto o agente \"pensa\" antes de mandar a primeira resposta (ver a notificação + digitação por caractere, limitado entre o mínimo e o máximo). Campo vazio usa o padrão — rápido demais parece robô; lento demais parece que caiu. Não mexe no intervalo entre mensagens.",
|
||||
)}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<Label>{t("Teto diário de envios")}</Label>
|
||||
<Input
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface AgentVersionRow {
|
||||
knowledge_source_ids: string[];
|
||||
split_messages: boolean;
|
||||
split_max_chars: number;
|
||||
inbound_debounce_ms: number | null;
|
||||
followup: {
|
||||
enabled: boolean;
|
||||
flow_pointer_ids: string[];
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface PacingKnobsItem {
|
||||
defaults: PacingKnobs;
|
||||
bounds: {
|
||||
intervalMaxMs: number;
|
||||
msPorCaractereMax: number;
|
||||
atrasoMaximoMsMax: number;
|
||||
hourLastStart: number;
|
||||
hourEnd: number;
|
||||
daily_limit: { min: number; max: number };
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface PublishedAgentConfig {
|
||||
proposalAiDraftEnabled: boolean;
|
||||
splitMessages: boolean;
|
||||
splitMaxChars: number;
|
||||
/** Janela de rajada inbound (ms) configurada na versão. `null` = usa a env. */
|
||||
inboundDebounceMs: number | null;
|
||||
/** input multimodal (imagem/áudio/pdf) habilitado no turno (Onda 3). */
|
||||
multimodalInput: boolean;
|
||||
/** tools open_human_case/provide_case_update habilitadas no turno (spec 15). */
|
||||
@@ -112,6 +114,7 @@ interface Row {
|
||||
proposal_ai_draft_enabled: boolean;
|
||||
split_messages: boolean;
|
||||
split_max_chars: number;
|
||||
inbound_debounce_ms: number | null;
|
||||
multimodal_input: boolean;
|
||||
cases_enabled: boolean;
|
||||
followup: unknown;
|
||||
@@ -143,6 +146,7 @@ const SELECT_AGENT_CONFIG_COLUMNS = `a.operation_mode,a.paused_at,a.operation_re
|
||||
v.proposal_ai_draft_enabled,
|
||||
v.split_messages,
|
||||
v.split_max_chars,
|
||||
v.inbound_debounce_ms,
|
||||
v.multimodal_input,
|
||||
v.cases_enabled,
|
||||
v.followup,
|
||||
@@ -200,6 +204,7 @@ function mapAgentConfigRow(r: Row): PublishedAgentConfig {
|
||||
proposalAiDraftEnabled: r.proposal_ai_draft_enabled,
|
||||
splitMessages: r.split_messages,
|
||||
splitMaxChars: r.split_max_chars,
|
||||
inboundDebounceMs: r.inbound_debounce_ms ?? null,
|
||||
multimodalInput: r.multimodal_input,
|
||||
casesEnabled: r.cases_enabled,
|
||||
followup: r.followup,
|
||||
|
||||
@@ -49,7 +49,10 @@
|
||||
*/
|
||||
import type { Logger } from '../obs/logger';
|
||||
|
||||
/** Ver o cabeçalho: a parcela que não depende do tamanho do texto. */
|
||||
/**
|
||||
* Ver o cabeçalho: a parcela que não depende do tamanho do texto.
|
||||
* É o DEFAULT — quem configura por conexão (channel_knobs 0499) recebe outro.
|
||||
*/
|
||||
export const ATRASO_NOTAR_MS = 900;
|
||||
|
||||
/** ≈45 caracteres/s — rápido de propósito; ver o cabeçalho. */
|
||||
@@ -61,14 +64,34 @@ export const ATRASO_MINIMO_MS = 1200;
|
||||
/** Acima disto o silêncio lê como queda, não como digitação. */
|
||||
export const ATRASO_MAXIMO_MS = 7500;
|
||||
|
||||
/** Os quatro números do atraso humano — o default é o do código de antes. */
|
||||
export interface AtrasoHumanoKnobs {
|
||||
atrasoNotarMs?: number;
|
||||
msPorCaractere?: number;
|
||||
atrasoMinimoMs?: number;
|
||||
atrasoMaximoMs?: number;
|
||||
}
|
||||
|
||||
/** Os quatro números com o default do módulo preenchido (NULL/ausente = valor de antes). */
|
||||
export function atrasoHumanoEfetivo(knobs: AtrasoHumanoKnobs | undefined): Required<AtrasoHumanoKnobs> {
|
||||
return {
|
||||
atrasoNotarMs: knobs?.atrasoNotarMs ?? ATRASO_NOTAR_MS,
|
||||
msPorCaractere: knobs?.msPorCaractere ?? MS_POR_CARACTERE,
|
||||
atrasoMinimoMs: knobs?.atrasoMinimoMs ?? ATRASO_MINIMO_MS,
|
||||
atrasoMaximoMs: knobs?.atrasoMaximoMs ?? ATRASO_MAXIMO_MS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quanto esperar antes de mandar `texto`, em ms. Pura — é o que a torna
|
||||
* testável sem relógio e sem canal.
|
||||
* testável sem relógio e sem canal. `knobs` opcional: sem eles vale
|
||||
* EXATAMENTE o comportamento histórico (regressão zero).
|
||||
*/
|
||||
export function calcularAtrasoHumano(texto: string): number {
|
||||
export function calcularAtrasoHumano(texto: string, knobs?: AtrasoHumanoKnobs): number {
|
||||
const ef = atrasoHumanoEfetivo(knobs);
|
||||
const comprimento = (texto ?? '').trim().length;
|
||||
const bruto = ATRASO_NOTAR_MS + MS_POR_CARACTERE * comprimento;
|
||||
return Math.min(ATRASO_MAXIMO_MS, Math.max(ATRASO_MINIMO_MS, bruto));
|
||||
const bruto = ef.atrasoNotarMs + ef.msPorCaractere * comprimento;
|
||||
return Math.min(ef.atrasoMaximoMs, Math.max(ef.atrasoMinimoMs, bruto));
|
||||
}
|
||||
|
||||
export interface EsperaHumanaArgs {
|
||||
@@ -78,6 +101,11 @@ export interface EsperaHumanaArgs {
|
||||
processamentoMs?: number;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
log: Logger;
|
||||
/**
|
||||
* Os quatro números do atraso por conexão (channel_knobs 0499). Omitir =
|
||||
* comportamento histórico (defaults de atraso-humano.ts).
|
||||
*/
|
||||
knobs?: AtrasoHumanoKnobs;
|
||||
/**
|
||||
* Acende o "digitando…" no aparelho do cliente. OPCIONAL: canal que não sabe
|
||||
* sinalizar presença simplesmente espera, e o ganho principal (não responder
|
||||
@@ -101,7 +129,7 @@ export async function esperarComoHumano(args: EsperaHumanaArgs): Promise<number>
|
||||
const gasto = args.processamentoMs ?? 0;
|
||||
const ms = Math.max(
|
||||
0,
|
||||
calcularAtrasoHumano(args.texto) - (Number.isFinite(gasto) ? Math.max(0, gasto) : 0),
|
||||
calcularAtrasoHumano(args.texto, args.knobs) - (Number.isFinite(gasto) ? Math.max(0, gasto) : 0),
|
||||
);
|
||||
|
||||
// O cliente já esperou o alvo: não acrescentar presença decorativa nem sleep.
|
||||
|
||||
@@ -40,6 +40,7 @@ const AGENT: PublishedAgentConfig = {
|
||||
proposalAiDraftEnabled: true,
|
||||
splitMessages: false,
|
||||
splitMaxChars: 400,
|
||||
inboundDebounceMs: null,
|
||||
multimodalInput: false,
|
||||
casesEnabled: false,
|
||||
toolIds: [],
|
||||
|
||||
@@ -2620,6 +2620,19 @@ async function executarTurnoDoAgente(
|
||||
// turno com dois vetos ficaria mudo por mais de 20 segundos: o conserto do
|
||||
// "rápido demais" viraria o defeito simétrico, mais caro que o original.
|
||||
let jaEsperouComoHumano = false;
|
||||
// Knobs de atraso humano por conexão (0499) — lidos UMA vez por turno para a
|
||||
// pausa antes da 1ª bolha (`esperarComoHumano`) e para o jitter entre bolhas
|
||||
// (`throttle_ms + jitter_max_ms`). Sem linha em channel_knobs caem nos defaults
|
||||
// de defaults.ts — que espelham os valores históricos (regressão zero).
|
||||
const pacingDoTurno = !preview
|
||||
? await loadChannelKnobs(pool, tenantId, input.channelSessionId, runLog)
|
||||
: null;
|
||||
const knobsDeAtrasoHumano = {
|
||||
atrasoNotarMs: pacingDoTurno?.knobs.atrasoNotarMs,
|
||||
msPorCaractere: pacingDoTurno?.knobs.msPorCaractere,
|
||||
atrasoMinimoMs: pacingDoTurno?.knobs.atrasoMinimoMs,
|
||||
atrasoMaximoMs: pacingDoTurno?.knobs.atrasoMaximoMs,
|
||||
};
|
||||
// Cap de envio (warm-up/diário) vetado neste turno — capturado aqui porque o veto
|
||||
// não empurra outcome nenhum a `outcomes` (ver comentário no ponto de captura, mais
|
||||
// abaixo). Diferente da janela horária (checada ANTES do modelo rodar, linha ~1233):
|
||||
@@ -3169,6 +3182,9 @@ async function executarTurnoDoAgente(
|
||||
agentConfig?.splitMaxChars ?? 600,
|
||||
Math.max(1, maxSendsPerTurn - seq),
|
||||
)[0] ?? body,
|
||||
// Os quatro números do atraso por conexão (0499). Vazios (sem
|
||||
// linha em channel_knobs / preview) = defaults históricos.
|
||||
knobs: knobsDeAtrasoHumano,
|
||||
// `processamentoMs` é a contribuição do #849 (@Teowfb): a pausa humana desconta o
|
||||
// tempo que o turno JÁ gastou pensando, em vez de somar em cima dele. Sem este
|
||||
// argumento o `gasto` de `atraso-humano.ts` cai no `?? 0` e o desconto não acontece —
|
||||
@@ -3192,7 +3208,13 @@ async function executarTurnoDoAgente(
|
||||
corposEnviados.push(finalBody);
|
||||
const sleep =
|
||||
deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
||||
const jitter = () => 1200 + Math.floor(Math.random() * 800); // piso no throttle anti-ban (1.2s) — bolhas são mensagens físicas
|
||||
const jitter = () =>
|
||||
// Piso no throttle anti-ban do número (1.2s default) — bolhas são
|
||||
// mensagens físicas. Lê os knobs da CONEXÃO (throttle_ms + jitter_max_ms,
|
||||
// 0499) e não o literal: desde a 0010 os dois já são configuração por
|
||||
// número, e o call site estava cravando `1200 + rand*800` ignorando-a.
|
||||
(pacingDoTurno?.knobs.throttleMs ?? 1200) +
|
||||
Math.floor(Math.random() * (pacingDoTurno?.knobs.jitterMaxMs ?? 800));
|
||||
const enviar = (
|
||||
corpo: string,
|
||||
media?: FotoParaEnvio,
|
||||
@@ -3441,6 +3463,7 @@ async function executarTurnoDoAgente(
|
||||
tenantId,
|
||||
leadId,
|
||||
toStage: update.transition.to,
|
||||
...(agentConfig !== null ? { pipelineIds: agentConfig.pipelineIds } : {}),
|
||||
...(update.transition.reason !== undefined
|
||||
? { reason: update.transition.reason }
|
||||
: {}),
|
||||
|
||||
@@ -26,6 +26,7 @@ function fakeConfig(agentId: string): PublishedAgentConfig {
|
||||
proposalAiDraftEnabled: true,
|
||||
splitMessages: false,
|
||||
splitMaxChars: 900,
|
||||
inboundDebounceMs: null,
|
||||
multimodalInput: false,
|
||||
casesEnabled: false,
|
||||
toolIds: [],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import type pg from 'pg';
|
||||
|
||||
import { decidirRajada, janelaDeRajada } from './debounce';
|
||||
import { decidirRajada, janelaDeRajada, debounceEfetivo, TETO_DEBOUNCE_MS } from './debounce';
|
||||
|
||||
const alvo = { organizationId: 'org1', contactId: 'contato1' };
|
||||
/** Instante fixo: a janela é aritmética e o teste não depende do relógio. */
|
||||
@@ -70,3 +70,29 @@ it('janelaDeRajada: sem debounce não há janela; com debounce a janela é agora
|
||||
expect(janelaDeRajada(0, AGORA)).toBeUndefined();
|
||||
expect(janelaDeRajada(750, AGORA)?.getTime()).toBe(AGORA + 750);
|
||||
});
|
||||
|
||||
// ── Leitor de debounce configurável por agente (issue #1856) ────────────
|
||||
const ENV = 8_000;
|
||||
|
||||
it('debounceEfetivo: campo vazio (null) usa o default da instalação — regressão zero', () => {
|
||||
expect(debounceEfetivo(null, ENV)).toBe(ENV);
|
||||
expect(debounceEfetivo(undefined, ENV)).toBe(ENV);
|
||||
});
|
||||
|
||||
it('debounceEfetivo: campo preenchido vence o default', () => {
|
||||
expect(debounceEfetivo(20_000, ENV)).toBe(20_000);
|
||||
});
|
||||
|
||||
it('debounceEfetivo: 0 desliga a coalescência — não herda nem clampa', () => {
|
||||
expect(debounceEfetivo(0, ENV)).toBe(0);
|
||||
});
|
||||
|
||||
it('debounceEfetivo: teto de 60s — valor ACIMA do teto é clamado no limite', () => {
|
||||
expect(TETO_DEBOUNCE_MS).toBe(60_000);
|
||||
expect(debounceEfetivo(60_000, ENV)).toBe(60_000);
|
||||
expect(debounceEfetivo(90_000, ENV)).toBe(60_000);
|
||||
});
|
||||
|
||||
it('debounceEfetivo: valor NEGATIVO (dado sujo) clampa para 0, não quebra', () => {
|
||||
expect(debounceEfetivo(-5, ENV)).toBe(0);
|
||||
});
|
||||
|
||||
@@ -69,6 +69,29 @@ export function janelaDeRajada(debounceMs: number, agora: number = Date.now()):
|
||||
return debounceMs > 0 ? new Date(agora + debounceMs) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Teto da janela de rajada configurável por agente (#1856): não deixar ninguém
|
||||
* travar o atendimento sem querer. Vale também como default de tela/UX.
|
||||
*/
|
||||
export const TETO_DEBOUNCE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Janela de rajada EFETIVA de um agente.
|
||||
*
|
||||
* `null`/`undefined` (campo vazio na versão) = o `INBOUND_DEBOUNCE_MS` da
|
||||
* instalação — regressão zero para quem nunca mexeu no campo. O valor
|
||||
* configurado é CLAMPADO em [0, TETO_DEBOUNCE_MS]: 0 desliga a coalescência e
|
||||
* valores acima do teto (dado sujo que entrou por fora da validação) caem para
|
||||
* 60s em vez de travar o atendimento.
|
||||
*/
|
||||
export function debounceEfetivo(
|
||||
configurado: number | null | undefined,
|
||||
padraoInstalacao: number,
|
||||
): number {
|
||||
if (configurado === null || configurado === undefined) return padraoInstalacao;
|
||||
return Math.min(Math.max(0, configurado), TETO_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/** Decide entre carona em job existente e janela nova. */
|
||||
export async function decidirRajada(
|
||||
pool: pg.Pool,
|
||||
|
||||
@@ -15,10 +15,11 @@
|
||||
import { z } from 'zod';
|
||||
import type pg from 'pg';
|
||||
|
||||
import { loadConversationAgentConfig } from '../../agent/agent-config';
|
||||
import { insertInboxItem } from '../../db/repository';
|
||||
import type { Logger } from '../../obs/logger';
|
||||
import { enqueueJob } from '../../queue/queue';
|
||||
import { decidirRajada } from './debounce';
|
||||
import { decidirRajada, debounceEfetivo } from './debounce';
|
||||
import { avisoDeEventoMorto, IA_QUE_NAO_RESPONDEU } from '@/lib/event-log/aviso-de-evento-morto';
|
||||
import { TIPOS_DERIVAVEIS, DERIVACAO_TERMINADA } from '@/lib/messaging/media/derivable';
|
||||
import { decidirElegibilidadeDaConversa } from '@/lib/ai/elegibilidade/consulta-pg';
|
||||
@@ -198,6 +199,45 @@ const TETO_ESPERA_DERIVACAO_MS = 120_000;
|
||||
|
||||
type DesfechoEvento = 'processado' | 'adiar';
|
||||
|
||||
/**
|
||||
* Janela de rajada EFETIVA para o evento: a configurada na versão do agente
|
||||
* desta conversa (#1856), com o `INBOUND_DEBOUNCE_MS` da instalação como
|
||||
* default e clamp no teto de 60s (`debounceEfetivo`).
|
||||
*
|
||||
* A resolução é a de `loadConversationAgentConfig` — o `active_ai_agent_id` da
|
||||
* conversa quando há dono explícito, senão o agente publicado da sessão. NÃO é
|
||||
* a resolução completa do turno (`resolveTurnAgent`: router, classificador,
|
||||
* campanha): numa conversa que o turno entregaria a outro agente sem torná-lo
|
||||
* dono, vale a janela do agente da sessão (ou a env). Com o campo vazio
|
||||
* (default de toda instalação) o valor vira o da env — regressão zero.
|
||||
*
|
||||
* Falha da consulta NÃO derruba o evento: degrada para a env, como a checagem
|
||||
* de elegibilidade acima. A janela é afinação, não motivo para retry.
|
||||
*/
|
||||
async function debounceDoEvento(
|
||||
pool: pg.Pool,
|
||||
event: EventRow,
|
||||
p: { conversation_id: string; channel_session_id: string },
|
||||
padraoInstalacao: number,
|
||||
log: Logger,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const agentConfig = await loadConversationAgentConfig(
|
||||
pool,
|
||||
event.organization_id,
|
||||
p.conversation_id,
|
||||
p.channel_session_id,
|
||||
);
|
||||
return debounceEfetivo(agentConfig?.inboundDebounceMs ?? null, padraoInstalacao);
|
||||
} catch (err) {
|
||||
log.warn('drain: janela de rajada do agente não resolveu — usando a da instalação', {
|
||||
event_id: event.id,
|
||||
error: (err instanceof Error ? err.message : String(err)).slice(0, 160),
|
||||
});
|
||||
return padraoInstalacao;
|
||||
}
|
||||
}
|
||||
|
||||
async function processEvent(
|
||||
pool: pg.Pool,
|
||||
event: EventRow,
|
||||
@@ -493,7 +533,7 @@ async function processEvent(
|
||||
const rajada = await decidirRajada(
|
||||
pool,
|
||||
{ organizationId: event.organization_id, contactId: p.contact_id },
|
||||
knobs.debounceMs,
|
||||
await debounceDoEvento(pool, event, p, knobs.debounceMs, log),
|
||||
);
|
||||
if (rajada.tipo === 'coalescido') {
|
||||
log.info('drain: rajada coalescida em job pendente', {
|
||||
|
||||
@@ -24,6 +24,39 @@ describe('mirrorLeadStageToCrm', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('respeita os funis da versão publicada — sem funil autorizado o card não se move', async () => {
|
||||
const sync = vi.fn().mockResolvedValue({
|
||||
moveu: false, motivo: 'fora_do_escopo', detalhe: 'nenhum funil liberado para este assistente',
|
||||
});
|
||||
|
||||
const r = await mirrorLeadStageToCrm(
|
||||
db, cfg as never,
|
||||
{ tenantId: 'org-1', leadId: 'contato-1', toStage: 'qualified', pipelineIds: [] },
|
||||
{ sync },
|
||||
);
|
||||
|
||||
expect(sync).toHaveBeenCalledWith(cfg.supabase, {
|
||||
organizationId: 'org-1', contactId: 'contato-1', passo: 'qualified', escopoDeFunis: [],
|
||||
});
|
||||
expect(r).toMatchObject({ ok: false, reason: 'fora_do_escopo' });
|
||||
});
|
||||
|
||||
it('leva ao sincronizador somente os funis que o agente publicado pode alterar', async () => {
|
||||
const sync = vi.fn().mockResolvedValue({ moveu: true, motivo: 'movido' });
|
||||
|
||||
const r = await mirrorLeadStageToCrm(
|
||||
db, cfg as never,
|
||||
{ tenantId: 'org-1', leadId: 'contato-1', toStage: 'qualified', pipelineIds: ['funil-comercial'] },
|
||||
{ sync },
|
||||
);
|
||||
|
||||
expect(r).toEqual({ ok: true });
|
||||
expect(sync).toHaveBeenCalledWith(cfg.supabase, {
|
||||
organizationId: 'org-1', contactId: 'contato-1', passo: 'qualified',
|
||||
escopoDeFunis: ['funil-comercial'],
|
||||
});
|
||||
});
|
||||
|
||||
it('estágio já ocupado é sucesso, não falha', async () => {
|
||||
const sync = vi.fn().mockResolvedValue({ moveu: false, motivo: 'ja_esta_la' });
|
||||
const r = await mirrorLeadStageToCrm(
|
||||
|
||||
@@ -237,7 +237,14 @@ interface Deps {
|
||||
export async function mirrorLeadStageToCrm(
|
||||
_db: Queryable,
|
||||
cfg: CrmEdgeConfig,
|
||||
input: { tenantId: string; leadId: string; toStage: LeadStage; reason?: string },
|
||||
input: {
|
||||
tenantId: string;
|
||||
leadId: string;
|
||||
toStage: LeadStage;
|
||||
reason?: string;
|
||||
/** Funis autorizados na versão publicada; ausente só no caminho legado. */
|
||||
pipelineIds?: readonly string[];
|
||||
},
|
||||
deps: Deps = {},
|
||||
): Promise<MirrorResult> {
|
||||
const sync = deps.sync ?? sincronizaEstagioDoAgente;
|
||||
@@ -249,6 +256,7 @@ export async function mirrorLeadStageToCrm(
|
||||
organizationId: input.tenantId,
|
||||
contactId: input.leadId,
|
||||
passo: input.toStage,
|
||||
...(input.pipelineIds !== undefined ? { escopoDeFunis: input.pipelineIds } : {}),
|
||||
});
|
||||
|
||||
if (r.moveu || r.motivo === 'ja_esta_la') return { ok: true };
|
||||
|
||||
@@ -21,6 +21,19 @@ export interface PacingKnobs {
|
||||
throttleMs: number;
|
||||
/** Teto do jitter randômico somado ao throttle e ao next_allowed_at (ms) — intervalo fixo é assinatura de bot. */
|
||||
jitterMaxMs: number;
|
||||
/**
|
||||
* Parcela FIXA do atraso humano antes da 1ª bolha (ms): ver a notificação,
|
||||
* abrir a conversa, ler o que o cliente escreveu. Existe separada do termo
|
||||
* proporcional porque mesmo um "Sim!" tem esse custo (atraso-humano.ts).
|
||||
* NULL em channel_knobs = este default.
|
||||
*/
|
||||
atrasoNotarMs: number;
|
||||
/** Taxa de digitação do atraso humano (ms por caractere) — default 22ms ≈ 45 c/s. NULL = default. */
|
||||
msPorCaractere: number;
|
||||
/** Piso do atraso humano (ms) — abaixo dele o atraso não significa nada. NULL = default. */
|
||||
atrasoMinimoMs: number;
|
||||
/** Teto do atraso humano (ms) — acima dele o silêncio lê como queda. NULL = default. */
|
||||
atrasoMaximoMs: number;
|
||||
/**
|
||||
* Janela horária de DISPARO [start, end) na hora local do tenant — vale para o
|
||||
* disparo em massa (`lib/prospecting/worker.ts`) e para a retomada de conversa
|
||||
@@ -74,6 +87,10 @@ export interface PacingKnobs {
|
||||
export const KNOB_BOUNDS = {
|
||||
/** teto de intervalo/jitter aceito na UI (ms). */
|
||||
intervalMaxMs: 600_000,
|
||||
/** teto de ms_por_caractere aceito na UI (ms) — 202ms ≈ 5 c/s é absurdamente lento; nada real precisa de mais. */
|
||||
msPorCaractereMax: 200,
|
||||
/** teto do atraso máximo aceito na UI (ms) — o piso é o default do anti-ban (1200). */
|
||||
atrasoMaximoMsMax: 60_000,
|
||||
/** maior hora aceita como INÍCIO de janela (fim vai até 24). */
|
||||
hourLastStart: 23,
|
||||
/** fim de janela é exclusivo e pode chegar à meia-noite seguinte. */
|
||||
@@ -83,6 +100,13 @@ export const KNOB_BOUNDS = {
|
||||
export const PACING_DEFAULTS: PacingKnobs = {
|
||||
throttleMs: 1200, // 1 msg / 1,2s
|
||||
jitterMaxMs: 800,
|
||||
// Números do atraso humano antes da 1ª bolha (atraso-humano.ts). Espelham os
|
||||
// valores que foram SEMPRE os literais do módulo — regressão zero para quem
|
||||
// nunca configurou. Coluna em channel_knobs NULL cai aqui.
|
||||
atrasoNotarMs: 900,
|
||||
msPorCaractere: 22,
|
||||
atrasoMinimoMs: 1200,
|
||||
atrasoMaximoMs: 7500,
|
||||
windowStartHour: 7, // janela 7h-22h
|
||||
windowEndHour: 22,
|
||||
// Espelha a janela de disparo: quem nunca gravou as colunas `resposta_*`
|
||||
|
||||
@@ -20,6 +20,11 @@ interface ChannelKnobsRow {
|
||||
/** Janela da RESPOSTA do agente (0495). NULL = usa `window_*` (comportamento anterior). */
|
||||
resposta_start_hour: number | null;
|
||||
resposta_end_hour: number | null;
|
||||
/** Números do atraso humano antes da 1ª bolha (0499). NULL = default (defaults.ts). */
|
||||
atraso_notar_ms: number | null;
|
||||
ms_por_caractere: number | null;
|
||||
atraso_minimo_ms: number | null;
|
||||
atraso_maximo_ms: number | null;
|
||||
allow_sunday: boolean | null;
|
||||
timezone: string | null;
|
||||
warmup_daily_caps: unknown; // jsonb — shape validado em parseWarmupCaps (nunca confiado)
|
||||
@@ -96,6 +101,7 @@ export async function loadChannelKnobs(
|
||||
const { rows } = await db.query<ChannelKnobsRow>(
|
||||
`select k.throttle_ms, k.jitter_max_ms, k.window_start_hour, k.window_end_hour,
|
||||
k.resposta_start_hour, k.resposta_end_hour,
|
||||
k.atraso_notar_ms, k.ms_por_caractere, k.atraso_minimo_ms, k.atraso_maximo_ms,
|
||||
k.allow_sunday, k.timezone, k.warmup_daily_caps, k.number_activated_at,
|
||||
o.timezone as org_timezone
|
||||
from organizations o
|
||||
@@ -126,6 +132,10 @@ export async function loadChannelKnobs(
|
||||
knobs: {
|
||||
throttleMs: row.throttle_ms ?? PACING_DEFAULTS.throttleMs,
|
||||
jitterMaxMs: row.jitter_max_ms ?? PACING_DEFAULTS.jitterMaxMs,
|
||||
atrasoNotarMs: row.atraso_notar_ms ?? PACING_DEFAULTS.atrasoNotarMs,
|
||||
msPorCaractere: row.ms_por_caractere ?? PACING_DEFAULTS.msPorCaractere,
|
||||
atrasoMinimoMs: row.atraso_minimo_ms ?? PACING_DEFAULTS.atrasoMinimoMs,
|
||||
atrasoMaximoMs: row.atraso_maximo_ms ?? PACING_DEFAULTS.atrasoMaximoMs,
|
||||
windowStartHour: row.window_start_hour ?? PACING_DEFAULTS.windowStartHour,
|
||||
windowEndHour: row.window_end_hour ?? PACING_DEFAULTS.windowEndHour,
|
||||
// `null` nestas duas = o número nunca foi configurado com janela de
|
||||
|
||||
@@ -24,7 +24,7 @@ export const DUPLICATE_AGENT_COLUMNS =
|
||||
* basta, se o INSERT não a escreve a cópia nasce com o default do banco.
|
||||
*/
|
||||
export const DUPLICATE_VERSION_COLUMNS =
|
||||
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
|
||||
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
|
||||
|
||||
export type DuplicateAgentError =
|
||||
| "not_found"
|
||||
@@ -78,6 +78,7 @@ function versionPayloadFrom(src: Record<string, unknown>) {
|
||||
operator_tool_ids: src.operator_tool_ids,
|
||||
split_messages: src.split_messages,
|
||||
split_max_chars: src.split_max_chars,
|
||||
inbound_debounce_ms: src.inbound_debounce_ms ?? null,
|
||||
followup: src.followup,
|
||||
// ESCOPO. As duas faltavam — `pipeline_ids` desde a 0125, e o cabeçalho
|
||||
// deste arquivo já mandava ("coluna nova entra aqui E em
|
||||
|
||||
@@ -169,6 +169,15 @@ const versionShapeSchema = z
|
||||
// pelo pacing anti-ban. Defaults espelham a migration 0059.
|
||||
split_messages: z.boolean().default(false),
|
||||
split_max_chars: z.number().int().min(80).max(4000).default(600),
|
||||
/**
|
||||
* Janela de coalescência de rajada inbound para ESTE agente (ms).
|
||||
*
|
||||
* `null`/ausente = usa o `INBOUND_DEBOUNCE_MS` da instalação (comportamento
|
||||
* de sempre — regressão zero). 0 desliga a coalescência de rajada para o
|
||||
* agente (job imediato). 1..60000 define a janela, com TETO de 60s para
|
||||
* ninguém travar o atendimento sem querer (#1856).
|
||||
*/
|
||||
inbound_debounce_ms: z.number().int().min(0).max(60000).nullable().optional(),
|
||||
followup: followupConfigSchema,
|
||||
// ── Papel OPERADOR (spec 16 §3.2) ───────────────────────────────────────
|
||||
// Todos com `.default(...)`, e é o que mantém retrocompatível: agent e
|
||||
|
||||
@@ -111,6 +111,11 @@ export const pacingKnobsUpdateSchema = z
|
||||
channel_session_id: z.string().uuid(),
|
||||
throttle_ms: z.number().int().min(0).max(KNOB_BOUNDS.intervalMaxMs).nullable().optional(),
|
||||
jitter_max_ms: z.number().int().min(0).max(KNOB_BOUNDS.intervalMaxMs).nullable().optional(),
|
||||
// Atraso humano antes da 1ª bolha (0499). null = voltar ao default (defaults.ts).
|
||||
atraso_notar_ms: z.number().int().min(0).max(KNOB_BOUNDS.intervalMaxMs).nullable().optional(),
|
||||
ms_por_caractere: z.number().int().min(0).max(KNOB_BOUNDS.msPorCaractereMax).nullable().optional(),
|
||||
atraso_minimo_ms: z.number().int().min(0).max(KNOB_BOUNDS.intervalMaxMs).nullable().optional(),
|
||||
atraso_maximo_ms: z.number().int().min(0).max(KNOB_BOUNDS.atrasoMaximoMsMax).nullable().optional(),
|
||||
window_start_hour: z.number().int().min(0).max(KNOB_BOUNDS.hourLastStart).nullable().optional(),
|
||||
window_end_hour: z.number().int().min(1).max(KNOB_BOUNDS.hourEnd).nullable().optional(),
|
||||
/**
|
||||
@@ -174,6 +179,11 @@ export interface ChannelKnobsRow {
|
||||
/** Janela da RESPOSTA (0495). Ausente/null = herda a janela de disparo. */
|
||||
resposta_start_hour?: number | null;
|
||||
resposta_end_hour?: number | null;
|
||||
/** Atraso humano antes da 1ª bolha (0499). Ausente/null = default. */
|
||||
atraso_notar_ms?: number | null;
|
||||
ms_por_caractere?: number | null;
|
||||
atraso_minimo_ms?: number | null;
|
||||
atraso_maximo_ms?: number | null;
|
||||
allow_sunday: boolean | null;
|
||||
timezone: string | null;
|
||||
warmup_daily_caps: unknown;
|
||||
@@ -200,6 +210,10 @@ export function effectiveKnobs(row: ChannelKnobsRow | null, fusoDaOrg?: string |
|
||||
return {
|
||||
throttleMs: row?.throttle_ms ?? PACING_DEFAULTS.throttleMs,
|
||||
jitterMaxMs: row?.jitter_max_ms ?? PACING_DEFAULTS.jitterMaxMs,
|
||||
atrasoNotarMs: row?.atraso_notar_ms ?? PACING_DEFAULTS.atrasoNotarMs,
|
||||
msPorCaractere: row?.ms_por_caractere ?? PACING_DEFAULTS.msPorCaractere,
|
||||
atrasoMinimoMs: row?.atraso_minimo_ms ?? PACING_DEFAULTS.atrasoMinimoMs,
|
||||
atrasoMaximoMs: row?.atraso_maximo_ms ?? PACING_DEFAULTS.atrasoMaximoMs,
|
||||
windowStartHour: row?.window_start_hour ?? PACING_DEFAULTS.windowStartHour,
|
||||
windowEndHour: row?.window_end_hour ?? PACING_DEFAULTS.windowEndHour,
|
||||
// Mesma regra do store do engine: coluna vazia herda a janela de DISPARO.
|
||||
|
||||
@@ -89,6 +89,12 @@ export async function knobsDoCanal(
|
||||
return {
|
||||
throttleMs: linha.throttle_ms ?? PACING_DEFAULTS.throttleMs,
|
||||
jitterMaxMs: linha.jitter_max_ms ?? PACING_DEFAULTS.jitterMaxMs,
|
||||
// Automação é disparo em massa — o atraso humano (0499) não se aplica, mas
|
||||
// o tipo PacingKnobs pede os quatro; herdam o default conservador.
|
||||
atrasoNotarMs: PACING_DEFAULTS.atrasoNotarMs,
|
||||
msPorCaractere: PACING_DEFAULTS.msPorCaractere,
|
||||
atrasoMinimoMs: PACING_DEFAULTS.atrasoMinimoMs,
|
||||
atrasoMaximoMs: PACING_DEFAULTS.atrasoMaximoMs,
|
||||
windowStartHour: linha.window_start_hour ?? PACING_DEFAULTS.windowStartHour,
|
||||
windowEndHour: linha.window_end_hour ?? PACING_DEFAULTS.windowEndHour,
|
||||
// Automação é disparo: nunca lê a janela de resposta (0495). O par espelha o
|
||||
|
||||
@@ -1820,6 +1820,7 @@ export type Database = {
|
||||
history_message_window: number
|
||||
history_token_window: number
|
||||
id: string
|
||||
inbound_debounce_ms: number | null
|
||||
knowledge_source_ids: string[]
|
||||
max_steps: number
|
||||
model: string
|
||||
@@ -1858,6 +1859,7 @@ export type Database = {
|
||||
history_message_window?: number
|
||||
history_token_window?: number
|
||||
id?: string
|
||||
inbound_debounce_ms?: number | null
|
||||
knowledge_source_ids?: string[]
|
||||
max_steps?: number
|
||||
model: string
|
||||
@@ -1896,6 +1898,7 @@ export type Database = {
|
||||
history_message_window?: number
|
||||
history_token_window?: number
|
||||
id?: string
|
||||
inbound_debounce_ms?: number | null
|
||||
knowledge_source_ids?: string[]
|
||||
max_steps?: number
|
||||
model?: string
|
||||
@@ -3951,6 +3954,10 @@ export type Database = {
|
||||
warmup_daily_caps: Json | null
|
||||
window_end_hour: number | null
|
||||
window_start_hour: number | null
|
||||
atraso_notar_ms: number | null
|
||||
ms_por_caractere: number | null
|
||||
atraso_minimo_ms: number | null
|
||||
atraso_maximo_ms: number | null
|
||||
}
|
||||
Insert: {
|
||||
allow_sunday?: boolean | null
|
||||
@@ -3967,6 +3974,10 @@ export type Database = {
|
||||
warmup_daily_caps?: Json | null
|
||||
window_end_hour?: number | null
|
||||
window_start_hour?: number | null
|
||||
atraso_notar_ms?: number | null
|
||||
ms_por_caractere?: number | null
|
||||
atraso_minimo_ms?: number | null
|
||||
atraso_maximo_ms?: number | null
|
||||
}
|
||||
Update: {
|
||||
allow_sunday?: boolean | null
|
||||
@@ -3983,6 +3994,10 @@ export type Database = {
|
||||
warmup_daily_caps?: Json | null
|
||||
window_end_hour?: number | null
|
||||
window_start_hour?: number | null
|
||||
atraso_notar_ms?: number | null
|
||||
ms_por_caractere?: number | null
|
||||
atraso_minimo_ms?: number | null
|
||||
atraso_maximo_ms?: number | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
|
||||
@@ -1723,6 +1723,11 @@ export const DICIONARIO: Traducoes = {
|
||||
"Volume de texto por atendimento": { es: "Volumen de texto por atención" },
|
||||
"Custo máximo por atendimento (centavos)": { es: "Costo máximo por atención (centavos)" },
|
||||
"Mensagens anteriores que ele lê": { es: "Mensajes anteriores que lee" },
|
||||
"Esperar antes de responder (segundos)": { es: "Esperar antes de responder (segundos)" },
|
||||
"Mensagens do mesmo contato dentro desse tempo viram uma resposta só. Vazio usa a janela padrão da instalação (máximo 60 segundos).": {
|
||||
es: "Los mensajes del mismo contacto dentro de este tiempo se convierten en una sola respuesta. Vacío usa la ventana predeterminada de la instalación (máximo 60 segundos).",
|
||||
},
|
||||
"Vazio = padrão da instalação": { es: "Vacío = predeterminado de la instalación" },
|
||||
"Tamanho máximo desse histórico": { es: "Tamaño máximo de ese historial" },
|
||||
"As instruções dele": { es: "Sus instrucciones" },
|
||||
"Estilo de resposta": { es: "Estilo de respuesta" },
|
||||
@@ -5260,6 +5265,20 @@ export const DICIONARIO: Traducoes = {
|
||||
"Intervalo mínimo entre mensagens do mesmo número, mais uma variação aleatória — ritmo cravado parece robô para o WhatsApp.": {
|
||||
es: "Intervalo mínimo entre mensajes del mismo número, más una variación aleatoria. Un ritmo siempre igual hace que WhatsApp lo detecte como un robot.",
|
||||
},
|
||||
"Atraso humano antes da primeira resposta (ms)": {
|
||||
es: "Retraso humano antes de la primera respuesta (ms)",
|
||||
},
|
||||
"Ver a notificação": { es: "Ver la notificación" },
|
||||
"Mínimo": { es: "Mínimo" },
|
||||
"Máximo": { es: "Máximo" },
|
||||
"Tempo para ver a notificação em ms": { es: "Tiempo para ver la notificación en ms" },
|
||||
"Por caractere": { es: "Por carácter" },
|
||||
"Milissegundos por caractere da resposta": { es: "Milisegundos por carácter de la respuesta" },
|
||||
"Atraso humano mínimo em ms": { es: "Retraso humano mínimo en ms" },
|
||||
"Atraso humano máximo em ms": { es: "Retraso humano máximo en ms" },
|
||||
"Quanto o agente \"pensa\" antes de mandar a primeira resposta (ver a notificação + digitação por caractere, limitado entre o mínimo e o máximo). Campo vazio usa o padrão — rápido demais parece robô; lento demais parece que caiu. Não mexe no intervalo entre mensagens.": {
|
||||
es: "Cuánto \"piensa\" el agente antes de enviar la primera respuesta (ver la notificación + escritura por carácter, limitado entre un mínimo y un máximo). Campo vacío usa el estándar: demasiado rápido parece robot; demasiado lento parece que se cayó. No cambia el intervalo entre mensajes.",
|
||||
},
|
||||
"Teto diário de envios": { es: "Tope diario de envíos" },
|
||||
"sem teto definido": { es: "sin tope definido" },
|
||||
"Teto diário de mensagens": { es: "Tope diario de mensajes" },
|
||||
|
||||
+72
-3
@@ -91,6 +91,7 @@ export function tituloRedigido(titulo: string | null): string {
|
||||
export interface Filtravel<T> extends PromiseLike<T> {
|
||||
eq(coluna: string, valor: string | boolean): Filtravel<T>;
|
||||
in(coluna: string, valores: string[]): Filtravel<T>;
|
||||
not(coluna: string, operador: "is", valor: null): Filtravel<T>;
|
||||
limit(n: number): Filtravel<T>;
|
||||
}
|
||||
|
||||
@@ -104,6 +105,15 @@ export interface ClienteDaCascata {
|
||||
/** O texto-sentinela que marca uma nota de memória do agente JÁ redigida. */
|
||||
export const NOTA_REDIGIDA = "(anonimizado)";
|
||||
|
||||
/**
|
||||
* O body que a anonimização do BANCO grava em toda mensagem redigida
|
||||
* (`fn_redigir_conversas_ao_anonimizar` e `fn_lgpd_cascade_redact_contact`).
|
||||
* Só ela o escreve — por isso ele é o marcador de "esta mensagem já passou pela
|
||||
* anonimização", e poupa quem voltou a escrever depois (mensagem nova tem body
|
||||
* de verdade).
|
||||
*/
|
||||
export const MENSAGEM_REDIGIDA = "[mensagem anonimizada]";
|
||||
|
||||
/**
|
||||
* Redação do `tool_calls` de uma run (issue #1957). Cada passo vira
|
||||
* `{ step?, tool_name?, redacted: true, tool_calls: [{ tool_name }] }`: fica
|
||||
@@ -154,6 +164,8 @@ export interface ResultadoDaRedacao {
|
||||
estadosRedigidos: number;
|
||||
/** Se o social_identity do contato foi removido AGORA (#1957). */
|
||||
socialIdentidadeRedigida: boolean;
|
||||
/** Quantas mensagens já anonimizadas tiveram a transcrição da mídia apagada AGORA (0497). */
|
||||
transcricoesRedigidas: number;
|
||||
/**
|
||||
* As tabelas que esta execução REALMENTE tocou.
|
||||
*
|
||||
@@ -177,7 +189,8 @@ export function houveRedacao(r: ResultadoDaRedacao): boolean {
|
||||
r.memoriasRedigidas > 0 ||
|
||||
r.runsRedigidas > 0 ||
|
||||
r.estadosRedigidos > 0 ||
|
||||
r.socialIdentidadeRedigida
|
||||
r.socialIdentidadeRedigida ||
|
||||
r.transcricoesRedigidas > 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -437,6 +450,44 @@ export async function completarRedacaoDoContato(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Passo 9 — TRANSCRIÇÃO DA MÍDIA (`messages.media_derived_text`) — 0497 ──
|
||||
//
|
||||
// Achado na triagem do #1988. O banco redige o body da mensagem na virada de
|
||||
// is_anonymized, e até a 0497 deixava a transcrição do áudio (e o OCR da
|
||||
// imagem) legível. A migration fecha o gatilho e cura o passado; este passo
|
||||
// fecha a janela que nenhum gatilho alcança: o `media-derive-worker` que leu
|
||||
// a mídia ANTES da anonimização e grava o texto DEPOIS dela.
|
||||
//
|
||||
// Só mensagem com body `MENSAGEM_REDIGIDA`: é a que o banco já anonimizou.
|
||||
// Mensagem com body de verdade é de quem voltou a escrever, e fica. O UPDATE
|
||||
// repete o predicado em vez de uma lista de ids — um contato pode ter
|
||||
// centenas de áudios, e `.in()` vira URL (ver `CONTATOS_POR_BLOCO`).
|
||||
let transcricoesRedigidas = 0;
|
||||
const { data: transcData, error: transcSelErr } = await db
|
||||
.from("messages")
|
||||
.select("id")
|
||||
.eq("organization_id", contato.organizationId)
|
||||
.eq("contact_id", contato.id)
|
||||
.eq("body", MENSAGEM_REDIGIDA)
|
||||
.not("media_derived_text", "is", null);
|
||||
if (transcSelErr) falhas.push(`messages media_derived_text select: ${transcSelErr.message}`);
|
||||
|
||||
const comTranscricao = ((transcData ?? []) as { id: string }[]).length;
|
||||
if (comTranscricao > 0) {
|
||||
const { error } = await db
|
||||
.from("messages")
|
||||
.update({ media_derived_text: null })
|
||||
.eq("organization_id", contato.organizationId)
|
||||
.eq("contact_id", contato.id)
|
||||
.eq("body", MENSAGEM_REDIGIDA)
|
||||
.not("media_derived_text", "is", null);
|
||||
if (error) falhas.push(`messages media_derived_text: ${error.message}`);
|
||||
else {
|
||||
transcricoesRedigidas = comTranscricao;
|
||||
tabelas.push("messages:media_derived_text");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
leadsRedigidas,
|
||||
atividadesRedigidas,
|
||||
@@ -444,6 +495,7 @@ export async function completarRedacaoDoContato(
|
||||
runsRedigidas,
|
||||
estadosRedigidos,
|
||||
socialIdentidadeRedigida,
|
||||
transcricoesRedigidas,
|
||||
tabelas,
|
||||
falhas,
|
||||
};
|
||||
@@ -495,7 +547,7 @@ export interface ResultadoDaVarredura {
|
||||
falhas: string[];
|
||||
}
|
||||
|
||||
/** Um contato tem resíduo se alguma lead, atividade, memória, run, estado ou identidade social dele ainda não foi redigida. */
|
||||
/** Um contato tem resíduo se alguma lead, atividade, memória, run, estado, identidade social ou transcrição dele ainda não foi redigida. */
|
||||
function idsComResiduo(argumentos: {
|
||||
leads: { contact_id: string | null; title: string | null }[];
|
||||
atividades: { contact_id: string | null; payload: unknown }[];
|
||||
@@ -503,8 +555,10 @@ function idsComResiduo(argumentos: {
|
||||
runs: { contact_id: string | null; tool_calls: unknown }[];
|
||||
estados: { contact_id: string | null; next_action: string | null; qualification: unknown }[];
|
||||
sociais: { id: string; social_identity: unknown }[];
|
||||
/** Já filtradas no banco: mensagem anonimizada que ainda guarda transcrição. */
|
||||
transcricoes: { contact_id: string | null }[];
|
||||
}): Set<string> {
|
||||
const { leads, atividades, notas, runs, estados, sociais } = argumentos;
|
||||
const { leads, atividades, notas, runs, estados, sociais, transcricoes } = argumentos;
|
||||
const comResiduo = new Set<string>();
|
||||
for (const l of leads) {
|
||||
if (l.contact_id && !jaRedigida(l.title)) comResiduo.add(l.contact_id);
|
||||
@@ -530,6 +584,9 @@ function idsComResiduo(argumentos: {
|
||||
for (const s of sociais) {
|
||||
if (s.social_identity !== null && s.social_identity !== undefined) comResiduo.add(s.id);
|
||||
}
|
||||
for (const m of transcricoes) {
|
||||
if (m.contact_id) comResiduo.add(m.contact_id);
|
||||
}
|
||||
return comResiduo;
|
||||
}
|
||||
|
||||
@@ -619,6 +676,17 @@ export async function varrerRedacoesIncompletas(
|
||||
.in("id", bloco);
|
||||
if (socialErr) falhas.push(`contacts social_identity varredura: ${socialErr.message}`);
|
||||
|
||||
// Filtrado no BANCO, ao contrário das consultas acima: um contato tem
|
||||
// milhares de mensagens, e trazer todas para decidir em memória custaria a
|
||||
// rodada saudável inteira. Em regime a resposta é vazia.
|
||||
const { data: transcricoes, error: transcErr } = await db
|
||||
.from("messages")
|
||||
.select("contact_id")
|
||||
.in("contact_id", bloco)
|
||||
.eq("body", MENSAGEM_REDIGIDA)
|
||||
.not("media_derived_text", "is", null);
|
||||
if (transcErr) falhas.push(`messages media_derived_text varredura: ${transcErr.message}`);
|
||||
|
||||
const achados = idsComResiduo({
|
||||
leads: (leads ?? []) as { contact_id: string | null; title: string | null }[],
|
||||
atividades: (atvs ?? []) as { contact_id: string | null; payload: unknown }[],
|
||||
@@ -630,6 +698,7 @@ export async function varrerRedacoesIncompletas(
|
||||
qualification: unknown;
|
||||
}[],
|
||||
sociais: (sociais ?? []) as { id: string; social_identity: unknown }[],
|
||||
transcricoes: (transcricoes ?? []) as { contact_id: string | null }[],
|
||||
});
|
||||
// A detecção não filtra org (ver o cabeçalho): um `contact_id` que não
|
||||
// saiu da lista de contatos anonimizados não vira visita.
|
||||
|
||||
@@ -180,15 +180,21 @@ interface SetupMetadata {
|
||||
proposal_hash?: string;
|
||||
prepared_only?: boolean;
|
||||
}
|
||||
// Colunas em que `null` na linha do banco é o mesmo que ausente na versão montada: o hash
|
||||
// gravado na criação parte do objeto parseado (sem a chave), e a conferência parte da
|
||||
// linha lida (com `null`). Sem esta lista os dois hashes divergem e o setup recusa com
|
||||
// "O rascunho foi alterado" — o que a 0498 (inbound_debounce_ms) fez nas 5 specs de
|
||||
// tests/invariants/prospecting-agent-setup.test.ts.
|
||||
// ponytail: lista explícita; coluna nullable nova em ai_agent_versions entra aqui (#2004).
|
||||
const NULO_E_AUSENTE = new Set(["trigger_config", "inbound_debounce_ms"]);
|
||||
|
||||
function versionHash(row: Record<string, unknown>) {
|
||||
const keys = Object.keys(versionCreateSchema.shape);
|
||||
return hash(
|
||||
versionCreateSchema.parse(
|
||||
Object.fromEntries(
|
||||
keys
|
||||
.filter(
|
||||
(key) => row[key] !== undefined && !(key === "trigger_config" && row[key] === null),
|
||||
)
|
||||
.filter((key) => row[key] !== undefined && !(NULO_E_AUSENTE.has(key) && row[key] === null))
|
||||
.map((key) => [key, row[key]]),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -43588,6 +43588,612 @@ $t$;
|
||||
|
||||
revoke execute on function public.fn_lgpd_redigir_tool_calls(jsonb) from public, anon, authenticated;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
-- ---- anonimizar apaga também a transcrição da mídia (migration 0497) ----
|
||||
-- Achado na triagem do #1988 (@AlecLimaDev). `messages.media_derived_text`
|
||||
-- (transcrição/OCR do media-derive-worker, 0058) sobrevivia a toda anonimização.
|
||||
-- Uma linha (`media_derived_text = null`) em cada UPDATE de messages dos dois
|
||||
-- corpos, que partem do corpo ATUAL: o gatilho da 0494 (bloco logo acima) e a
|
||||
-- cascata da 0483. Corpo IDÊNTICO ao da migration 0497 (gate
|
||||
-- `apendice-do-baseline-nao-diverge-da-cadeia`). Entra ANTES do bloco da
|
||||
-- VARREDURA anon (0116), que proíbe `create function` depois dela. A cura no fim
|
||||
-- é idempotente pelo `is not null` — o update.sh reaplica sem reescrever.
|
||||
create or replace function public.fn_redigir_conversas_ao_anonimizar()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public, pg_temp
|
||||
as $f$
|
||||
declare
|
||||
v_variantes text[];
|
||||
v_lid text;
|
||||
v_msgs_de_grupo uuid[];
|
||||
begin
|
||||
insert into public.storage_redaction_queue (organization_id, bucket, object_path)
|
||||
select distinct new.organization_id, 'whatsapp-media', m.media_storage_path
|
||||
from public.messages m
|
||||
where m.organization_id = new.organization_id
|
||||
and m.conversation_id in (
|
||||
select c.id from public.conversations c
|
||||
where c.contact_id = new.id and c.organization_id = new.organization_id)
|
||||
and m.media_storage_path is not null
|
||||
and length(m.media_storage_path) > 0
|
||||
on conflict (bucket, object_path) do nothing;
|
||||
|
||||
update public.messages set
|
||||
body = '[mensagem anonimizada]',
|
||||
media_url = null,
|
||||
media_mime = null,
|
||||
media_size_bytes = null,
|
||||
media_storage_path = null,
|
||||
media_derived_text = null,
|
||||
metadata = '{}'::jsonb,
|
||||
updated_at = now()
|
||||
where organization_id = new.organization_id
|
||||
and conversation_id in (
|
||||
select c.id from public.conversations c
|
||||
where c.contact_id = new.id and c.organization_id = new.organization_id);
|
||||
|
||||
update public.conversations set
|
||||
metadata = '{}'::jsonb,
|
||||
last_message_preview = null,
|
||||
last_handoff_reason = null,
|
||||
updated_at = now()
|
||||
where contact_id = new.id and organization_id = new.organization_id;
|
||||
|
||||
update public.lead_checkpoints set
|
||||
rolling_summary = '[resumo anonimizado]',
|
||||
commitments = '[]'::jsonb,
|
||||
objections = '[]'::jsonb,
|
||||
next_action = null,
|
||||
declaracao = null
|
||||
where contact_id = new.id and organization_id = new.organization_id;
|
||||
|
||||
-- ── issue #1964 — lead_notes: a memória do agente sobre o contato ─────────
|
||||
update public.lead_notes set
|
||||
headline = '(anonimizado)',
|
||||
body = '(anonimizado)',
|
||||
embedding = null,
|
||||
updated_at = now()
|
||||
where organization_id = new.organization_id
|
||||
and contact_id = new.id
|
||||
and (headline is distinct from '(anonimizado)'
|
||||
or body is distinct from '(anonimizado)'
|
||||
or embedding is not null);
|
||||
|
||||
-- ── issue #1964 — ai_agent_runs.tool_calls: preserva o nome e redige o resto
|
||||
-- A transformação espelha `redigirToolCalls` (lib/lgpd/cascata.ts): cada
|
||||
-- passo vira `{ step?, tool_name?, redacted: true, tool_calls: [{ tool_name }] }`.
|
||||
-- Fica QUAIS ferramentas rodaram e em que passo; sai o texto do modelo, os
|
||||
-- argumentos e os resultados. Guard no WHERE: só corre quando há passo sem
|
||||
-- `redacted`, então o `[]` de nascença e a run já redigida não são tocados.
|
||||
update public.ai_agent_runs set
|
||||
tool_calls = public.fn_lgpd_redigir_tool_calls(ai_agent_runs.tool_calls)
|
||||
where ai_agent_runs.organization_id = new.organization_id
|
||||
and ai_agent_runs.contact_id = new.id
|
||||
and exists (
|
||||
select 1 from jsonb_array_elements(ai_agent_runs.tool_calls) s
|
||||
where coalesce(s->>'redacted', 'false')::boolean is not true
|
||||
);
|
||||
|
||||
-- ── issue #1964 — lead_state: a próxima ação e a qualificação da lead ─────
|
||||
update public.lead_state set
|
||||
next_action = null,
|
||||
qualification = '{}'::jsonb,
|
||||
updated_at = now()
|
||||
where organization_id = new.organization_id
|
||||
and contact_id = new.id
|
||||
and (next_action is not null
|
||||
or coalesce(qualification, '{}'::jsonb) <> '{}'::jsonb);
|
||||
|
||||
-- ── issue #1964 — contacts.social_identity ───────────────────────────────
|
||||
-- O índice único parcial `where social_identity is not null` torna anular
|
||||
-- seguro (a linha sai do índice sem violar unicidade). Guard no WHERE para a
|
||||
-- varredura não reescrever o que já está anonimizado.
|
||||
update public.contacts set
|
||||
social_identity = null,
|
||||
updated_at = now()
|
||||
where id = new.id
|
||||
and organization_id = new.organization_id
|
||||
and social_identity is not null;
|
||||
|
||||
-- Mensagens de grupo escritas pelo titular (0482, ver o cabeçalho daquele bloco).
|
||||
v_variantes := coalesce(public.fn_telefone_variantes(coalesce(old.phone_number, new.phone_number)), '{}');
|
||||
v_lid := coalesce(old.wa_lid, new.wa_lid);
|
||||
|
||||
select coalesce(array_agg(m.id), '{}')
|
||||
into v_msgs_de_grupo
|
||||
from public.messages m
|
||||
where m.organization_id = new.organization_id
|
||||
and m.metadata ? 'group_sender'
|
||||
and (
|
||||
regexp_replace(coalesce(m.metadata->'group_sender'->>'phone', ''), '\D', '', 'g') = any(v_variantes)
|
||||
or (v_lid is not null and m.metadata->'group_sender'->>'lid' = v_lid)
|
||||
);
|
||||
|
||||
if cardinality(v_msgs_de_grupo) > 0 then
|
||||
insert into public.storage_redaction_queue (organization_id, bucket, object_path)
|
||||
select distinct new.organization_id, 'whatsapp-media', m.media_storage_path
|
||||
from public.messages m
|
||||
where m.organization_id = new.organization_id
|
||||
and m.id = any(v_msgs_de_grupo)
|
||||
and m.media_storage_path is not null
|
||||
and length(m.media_storage_path) > 0
|
||||
on conflict (bucket, object_path) do nothing;
|
||||
|
||||
update public.conversations set
|
||||
last_message_preview = null,
|
||||
updated_at = now()
|
||||
where organization_id = new.organization_id
|
||||
and id in (select m.conversation_id from public.messages m
|
||||
where m.organization_id = new.organization_id and m.id = any(v_msgs_de_grupo));
|
||||
|
||||
update public.messages set
|
||||
body = '[mensagem anonimizada]',
|
||||
media_url = null,
|
||||
media_mime = null,
|
||||
media_size_bytes = null,
|
||||
media_storage_path = null,
|
||||
media_derived_text = null,
|
||||
metadata = '{}'::jsonb,
|
||||
updated_at = now()
|
||||
where organization_id = new.organization_id
|
||||
and id = any(v_msgs_de_grupo);
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end
|
||||
$f$;
|
||||
|
||||
-- As DUAS origens de EXECUTE (item 9 do CLAUDE.md): o grant a PUBLIC da criação
|
||||
-- e o grant nominal a anon do ALTER DEFAULT PRIVILEGES do baseline.
|
||||
revoke all on function public.fn_redigir_conversas_ao_anonimizar() from public;
|
||||
revoke execute on function public.fn_redigir_conversas_ao_anonimizar() from anon;
|
||||
revoke execute on function public.fn_redigir_conversas_ao_anonimizar() from authenticated;
|
||||
|
||||
|
||||
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;
|
||||
v_variantes text[] := '{}';
|
||||
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);
|
||||
|
||||
select coalesce(public.fn_telefone_variantes(phone_number), '{}')
|
||||
into v_variantes
|
||||
from contacts
|
||||
where id = p_contact_id and organization_id = p_organization_id;
|
||||
|
||||
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,
|
||||
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,
|
||||
last_handoff_reason = 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,
|
||||
media_derived_text = 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).
|
||||
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);
|
||||
|
||||
-- 6b. crm_proposals (migration 0477, #1504) — PRESERVA número, valores,
|
||||
-- itens, datas e status; redige só o que identifica a PESSOA. Ver o
|
||||
-- cabeçalho desta migration para o porquê de cada coluna.
|
||||
-- O PDF que o cliente recebeu (bucket `propostas`, `<org>/<proposta>.pdf`)
|
||||
-- tem o nome dele impresso: redigir as colunas e deixar o arquivo seria
|
||||
-- anonimizar a linha e manter o documento. Vai para a mesma fila de expurgo
|
||||
-- da mídia (passo 7), com o bucket CERTO — a mensagem que levou o PDF
|
||||
-- aponta para o mesmo caminho, mas o passo 7 só enfileira `whatsapp-media`.
|
||||
-- Lido ANTES de o passo seguinte zerar `pdf_path`.
|
||||
insert into storage_redaction_queue (organization_id, request_id, bucket, object_path)
|
||||
select p_organization_id, p_request_id, 'propostas', pdf_path
|
||||
from crm_proposals
|
||||
where organization_id = p_organization_id
|
||||
and contact_id = p_contact_id
|
||||
and pdf_path is not null and length(pdf_path) > 0
|
||||
-- só arquivo DESTA organização: o expurgo nunca alcança o PDF de outra
|
||||
and pdf_path like p_organization_id::text || '/%'
|
||||
on conflict (bucket, object_path) do nothing;
|
||||
update crm_proposals set
|
||||
destinatario_nome = v_anon_label,
|
||||
briefing_json = '{}'::jsonb,
|
||||
resumo_comercial = null,
|
||||
-- o texto do documento como foi montado e como foi editado à mão: é o
|
||||
-- conteúdo do PDF, com o mesmo nome dentro.
|
||||
rendered_snapshot = null,
|
||||
secoes_editadas = null,
|
||||
pdf_path = 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('crm_proposals', v_count);
|
||||
|
||||
-- CAMPANHAS: o que foi DITO à pessoa e o endereço para onde foi.
|
||||
update campaign_recipients set
|
||||
rendered_body = null,
|
||||
recipient_address = null,
|
||||
variables = '{}'::jsonb,
|
||||
last_error_detail = 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('campaign_recipients', v_count);
|
||||
|
||||
-- LISTA DE EXCLUSÃO: solta o vínculo e apaga a cauda do telefone.
|
||||
update campaign_suppressions set
|
||||
address_tail = null,
|
||||
reason = null,
|
||||
contact_id = null
|
||||
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('campaign_suppressions', v_count);
|
||||
|
||||
-- 6c. sales — a comanda. PRESERVA valor, status e datas, e NÃO desliga o
|
||||
-- contato (ver racional completo no baseline, bloco desta função).
|
||||
update sales set
|
||||
notes = null,
|
||||
cancel_reason = case when cancel_reason is null then null else '[redigido]' end,
|
||||
reverse_reason = case when reverse_reason is null then null else '[redigido]' end,
|
||||
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('sales', v_count);
|
||||
|
||||
-- 6d. conversation_notes (migration 0483, F3 da #1863) — a nota interna é
|
||||
-- texto escrito SOBRE a pessoa durante o atendimento, e o anexo dela é mídia
|
||||
-- ancorada na conversa: os dois entram no alcance do titular. A 0477 já
|
||||
-- mostrou o desenho (arquivo vai para a fila ANTES de a coluna ser zerada).
|
||||
-- O bucket é `internal-media`, e não o do passo 7: a nota nunca sobe no
|
||||
-- `whatsapp-media` (é o bucket do canal do CLIENTE), e enfileirar o caminho
|
||||
-- num bucket onde ele não está deixaria a remoção apontando para o nada —
|
||||
-- a mesma falha de não ter anonimizado, um endereço mais para a direita.
|
||||
-- Por isso os caminhos de nota também NÃO entram em `v_media_paths`: essa
|
||||
-- lista só existe para o passo 7, que enfileira `whatsapp-media`.
|
||||
insert into storage_redaction_queue (organization_id, request_id, bucket, object_path)
|
||||
select p_organization_id, p_request_id, 'internal-media', n.media_storage_path
|
||||
from conversation_notes n
|
||||
where n.organization_id = p_organization_id
|
||||
and n.conversation_id in (
|
||||
select id from conversations
|
||||
where contact_id = p_contact_id and organization_id = p_organization_id
|
||||
)
|
||||
and n.media_storage_path is not null and length(n.media_storage_path) > 0
|
||||
and n.media_storage_path like p_organization_id::text || '/%'
|
||||
on conflict (bucket, object_path) do nothing;
|
||||
update conversation_notes set
|
||||
body = '[nota interna anonimizada]',
|
||||
media_storage_path = null,
|
||||
media_mime = null,
|
||||
media_size_bytes = null
|
||||
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('conversation_notes', 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).
|
||||
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);
|
||||
|
||||
update prospecting_candidates set suppression_salt = gen_random_bytes(32)
|
||||
where organization_id = p_organization_id
|
||||
and (contact_id = p_contact_id
|
||||
or (phone is not null
|
||||
and regexp_replace(phone, '\D', '', 'g') = any (v_variantes)))
|
||||
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
|
||||
or (phone is not null
|
||||
and regexp_replace(phone, '\D', '', 'g') = any (v_variantes)));
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('prospecting_candidates', v_count);
|
||||
|
||||
-- agent_cases — o que a IA escreveu SOBRE a pessoa quando travou (migration 0280).
|
||||
update agent_cases set
|
||||
title = v_anon_label,
|
||||
summary = '[resumo anonimizado]',
|
||||
blocker = '[bloqueio anonimizado]',
|
||||
context_snapshot = '{}'::jsonb
|
||||
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('agent_cases', v_count);
|
||||
|
||||
-- agent_case_events — a linha do tempo do caso (migration 0280).
|
||||
update agent_case_events set
|
||||
body = null,
|
||||
metadata = '{}'::jsonb
|
||||
where organization_id = p_organization_id
|
||||
and case_id in (
|
||||
select id from agent_cases
|
||||
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('agent_case_events', v_count);
|
||||
|
||||
-- demandas — o assunto do pedido (migration 0280).
|
||||
update demandas set
|
||||
assunto = null
|
||||
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('demandas', v_count);
|
||||
|
||||
-- agent_inbox_items — o aviso que leva o texto do caso para a Central (migration 0280/0292).
|
||||
update agent_inbox_items set
|
||||
status = 'resolved',
|
||||
resolved_at = now(),
|
||||
body = 'Contato anonimizado.',
|
||||
ref_id = null
|
||||
where organization_id = p_organization_id
|
||||
and kind in ('handoff', 'case_stale', 'aviso_de_caso_nao_entregue')
|
||||
and (
|
||||
(ref_kind = 'contact' and ref_id = p_contact_id)
|
||||
or (ref_kind = 'conversation' and ref_id in (
|
||||
select id from conversations
|
||||
where contact_id = p_contact_id and organization_id = p_organization_id
|
||||
))
|
||||
or (ref_kind = 'agent_case' and ref_id in (
|
||||
select id from agent_cases
|
||||
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('agent_inbox_items', v_count);
|
||||
|
||||
-- agent_case_chat_messages — a consulta interna da equipe à IA SOBRE o caso (migration 0281).
|
||||
update agent_case_chat_messages set
|
||||
body = null,
|
||||
redacted_at = now()
|
||||
where organization_id = p_organization_id
|
||||
and contact_id = p_contact_id
|
||||
and redacted_at is null;
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('agent_case_chat_messages', v_count);
|
||||
|
||||
-- passagens_de_atendimento — o BRIEFING é sobre a pessoa (migration 0291).
|
||||
update passagens_de_atendimento set
|
||||
body = v_anon_label,
|
||||
title = null,
|
||||
notes = null,
|
||||
content = null,
|
||||
tentativas = '[]'::jsonb
|
||||
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('passagens_de_atendimento', v_count);
|
||||
|
||||
-- entregas_de_aviso_de_caso — o registro do aviso ao suporte (migration 0292).
|
||||
update entregas_de_aviso_de_caso set
|
||||
erro_detalhe = null
|
||||
where organization_id = p_organization_id
|
||||
and case_id in (
|
||||
select id from agent_cases
|
||||
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('entregas_de_aviso_de_caso', v_count);
|
||||
|
||||
-- channel_session_groups.subject — o NOME do grupo, e a FK contact_id aponta
|
||||
-- para o placeholder do grupo (contacts.kind = 'whatsapp_group'), nunca para
|
||||
-- o titular real sendo anonimizado neste caminho — mas a FK para contacts e o
|
||||
-- nome da coluna casam o padrão automático do escopo (migration 0482), e
|
||||
-- nulificar não perde nada operacional: número, conversa e liga/desliga ficam.
|
||||
update public.channel_session_groups set
|
||||
subject = null
|
||||
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('channel_session_groups', 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;
|
||||
|
||||
-- Cura: mensagens já anonimizadas que guardaram a transcrição.
|
||||
update public.messages set
|
||||
media_derived_text = null,
|
||||
updated_at = now()
|
||||
where body = '[mensagem anonimizada]'
|
||||
and media_derived_text is not null;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
-- ---- VARREDURA anon: função nova nasce exposta em quem ATUALIZA (migration 0116) ----
|
||||
--
|
||||
@@ -45036,6 +45642,43 @@ alter table public.channel_knobs
|
||||
and (resposta_end_hour is null or resposta_end_hour between 1 and 24)
|
||||
);
|
||||
|
||||
-- ---- atraso humano configurável por conexão (migration 0499) ----
|
||||
-- 0499 — os quatro números do atraso humano ANTES da primeira bolha
|
||||
-- (`atraso-humano.ts`) viram knob por conexão (issue #653): NOTAR, POR_CARACTERE,
|
||||
-- MINIMO e MAXIMO, todos NULL = default em defaults.ts (900/22/1200/7500 — os
|
||||
-- valores de antes, regressão zero). Nascem em `channel_knobs`, a mesma tabela
|
||||
-- dos knobs de pacing por conexão (0010 e 0495); nada de tabela nova. O jitter
|
||||
-- entre bolhas não ganha coluna: ele já é `throttle_ms` + `jitter_max_ms`.
|
||||
-- Reaplicável: `add column if not exists` e `drop constraint if exists` antes
|
||||
-- do `add constraint` — o `update.sh` roda o apêndice inteiro a cada atualização.
|
||||
alter table public.channel_knobs
|
||||
add column if not exists atraso_notar_ms integer,
|
||||
add column if not exists ms_por_caractere integer,
|
||||
add column if not exists atraso_minimo_ms integer,
|
||||
add column if not exists atraso_maximo_ms integer;
|
||||
|
||||
comment on column public.channel_knobs.atraso_notar_ms is
|
||||
'Parcela fixa do atraso humano (ms): ver a notificação e abrir a conversa. NULL = default (900).';
|
||||
comment on column public.channel_knobs.ms_por_caractere is
|
||||
'Taxa de digitação do atraso humano (ms por caractere). NULL = default (22).';
|
||||
comment on column public.channel_knobs.atraso_minimo_ms is
|
||||
'Piso do atraso humano (ms). NULL = default (1200).';
|
||||
comment on column public.channel_knobs.atraso_maximo_ms is
|
||||
'Teto do atraso humano (ms). NULL = default (7500).';
|
||||
|
||||
alter table public.channel_knobs
|
||||
drop constraint if exists channel_knobs_atraso_humano_saneamento;
|
||||
|
||||
alter table public.channel_knobs
|
||||
add constraint channel_knobs_atraso_humano_saneamento
|
||||
check (
|
||||
(atraso_notar_ms is null or (atraso_notar_ms between 0 and 600000))
|
||||
and (ms_por_caractere is null or (ms_por_caractere between 0 and 1000))
|
||||
and (atraso_minimo_ms is null or (atraso_minimo_ms between 0 and 600000))
|
||||
and (atraso_maximo_ms is null or (atraso_maximo_ms between 0 and 600000))
|
||||
and (atraso_maximo_ms is null or atraso_minimo_ms is null or atraso_maximo_ms >= atraso_minimo_ms)
|
||||
);
|
||||
|
||||
-- ---- dedupe de event_dead atômico: índice único parcial (migration 0491) ----
|
||||
-- 0491 — o aviso `event_dead` não abre em dobro com dois drenos concorrentes
|
||||
-- (issue #880). O dedupe era uma pergunta seguida de uma escrita: `lib/event-log/
|
||||
@@ -45077,3 +45720,22 @@ update public.agent_inbox_items i
|
||||
create unique index if not exists agent_inbox_event_dead_aberto_unico
|
||||
on public.agent_inbox_items (organization_id, kind, title)
|
||||
where status = 'open' and kind = 'event_dead';
|
||||
|
||||
-- ---- janela de rajada configurável por agente (migration 0498) ----
|
||||
-- 0498 (#1856, de @webtecnica): a janela que junta mensagens do MESMO contato
|
||||
-- numa resposta sai da env global `INBOUND_DEBOUNCE_MS` e vira campo da versão
|
||||
-- do agente. NULL = usa a env da instalação (quem só atualiza não muda nada);
|
||||
-- 0 desliga; teto 60s. Par drop/add da CHECK para o `update.sh` reaplicar sem
|
||||
-- 'already exists'. Sem função nova (nada a revogar de anon).
|
||||
alter table public.ai_agent_versions
|
||||
drop constraint if exists ai_agent_versions_inbound_debounce_ms_check;
|
||||
|
||||
alter table public.ai_agent_versions
|
||||
add column if not exists inbound_debounce_ms integer;
|
||||
|
||||
comment on column public.ai_agent_versions.inbound_debounce_ms is
|
||||
'Janela de coalescência de rajada inbound em ms para ESTE agente. NULL = usa o INBOUND_DEBOUNCE_MS da instalação; 0 = desliga a coalescência; teto 60s.';
|
||||
|
||||
alter table public.ai_agent_versions
|
||||
add constraint ai_agent_versions_inbound_debounce_ms_check
|
||||
check (inbound_debounce_ms is null or (inbound_debounce_ms >= 0 and inbound_debounce_ms <= 60000));
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
-- 0497 — anonimizar apaga também a TRANSCRIÇÃO da mídia (messages.media_derived_text)
|
||||
--
|
||||
-- Achado na triagem do #1988 (@AlecLimaDev), que expõe esta coluna a
|
||||
-- integrações. `media_derived_text` é o texto que o `media-derive-worker` tira
|
||||
-- da mídia (transcrição do áudio, OCR/visão da imagem, texto do documento —
|
||||
-- 0058). Nenhum caminho de anonimização a zerava:
|
||||
--
|
||||
-- fn_lgpd_cascade_redact_contact (passo 3) redige body, mídia e metadata
|
||||
-- fn_redigir_conversas_ao_anonimizar (0391/0494) idem, nas duas passadas
|
||||
-- (conversas do contato e
|
||||
-- mensagens de grupo dele)
|
||||
--
|
||||
-- e `lib/lgpd/cascata.ts` não passa por `messages`. Resultado medido: o body do
|
||||
-- áudio vira '[mensagem anonimizada]' e a transcrição dele continua legível —
|
||||
-- e é lida pelo agente (get-lead-context, inbound-turn, conversa-do-caso,
|
||||
-- followup/atendimento, mcp/tools/propostas).
|
||||
--
|
||||
-- ── O conserto ───────────────────────────────────────────────────────────────
|
||||
-- Uma linha — `media_derived_text = null` — em cada UPDATE de messages que já
|
||||
-- redige o body. Os dois corpos partem do corpo ATUAL da main, byte a byte:
|
||||
-- fn_redigir_conversas_ao_anonimizar ← 0494 (#1973), 2 inserções
|
||||
-- fn_lgpd_cascade_redact_contact ← 0483 (a última redefinição), 1 inserção
|
||||
-- Nenhuma outra linha muda. `create or replace` troca o corpo INTEIRO, e um
|
||||
-- apêndice antigo já reverteu uma cascata de LGPD por partir de corpo velho.
|
||||
-- `media_derived_status` fica: é vocabulário ('ready'/'failed'/'skipped'), não
|
||||
-- conteúdo — e é ele que diz ao drain do turno que a derivação terminou.
|
||||
--
|
||||
-- ── A cura de quem JÁ foi anonimizado ───────────────────────────────────────
|
||||
-- O gatilho só dispara na virada de is_anonymized; para quem já virou, a virada
|
||||
-- passou. O marcador de "esta mensagem já foi anonimizada" é o próprio body
|
||||
-- '[mensagem anonimizada]' — só a anonimização o escreve. Ele alcança, com um
|
||||
-- predicado só, as conversas do contato E as mensagens de grupo que o titular
|
||||
-- escreveu (que não estão nas conversas dele), e poupa quem VOLTOU a escrever
|
||||
-- (mensagem nova tem body de verdade — a mesma regra que a cura da 0391 segue
|
||||
-- por `anonymized_at`). Idempotente pelo `is not null`: o update.sh reaplica
|
||||
-- sem reescrever nada.
|
||||
--
|
||||
-- O mesmo marcador é o que `lib/lgpd/cascata.ts` usa na varredura diária, e é
|
||||
-- ela que fecha a janela restante: um worker de derivação que leu a mídia ANTES
|
||||
-- da anonimização e grava o texto DEPOIS dela.
|
||||
|
||||
create or replace function public.fn_redigir_conversas_ao_anonimizar()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public, pg_temp
|
||||
as $f$
|
||||
declare
|
||||
v_variantes text[];
|
||||
v_lid text;
|
||||
v_msgs_de_grupo uuid[];
|
||||
begin
|
||||
insert into public.storage_redaction_queue (organization_id, bucket, object_path)
|
||||
select distinct new.organization_id, 'whatsapp-media', m.media_storage_path
|
||||
from public.messages m
|
||||
where m.organization_id = new.organization_id
|
||||
and m.conversation_id in (
|
||||
select c.id from public.conversations c
|
||||
where c.contact_id = new.id and c.organization_id = new.organization_id)
|
||||
and m.media_storage_path is not null
|
||||
and length(m.media_storage_path) > 0
|
||||
on conflict (bucket, object_path) do nothing;
|
||||
|
||||
update public.messages set
|
||||
body = '[mensagem anonimizada]',
|
||||
media_url = null,
|
||||
media_mime = null,
|
||||
media_size_bytes = null,
|
||||
media_storage_path = null,
|
||||
media_derived_text = null,
|
||||
metadata = '{}'::jsonb,
|
||||
updated_at = now()
|
||||
where organization_id = new.organization_id
|
||||
and conversation_id in (
|
||||
select c.id from public.conversations c
|
||||
where c.contact_id = new.id and c.organization_id = new.organization_id);
|
||||
|
||||
update public.conversations set
|
||||
metadata = '{}'::jsonb,
|
||||
last_message_preview = null,
|
||||
last_handoff_reason = null,
|
||||
updated_at = now()
|
||||
where contact_id = new.id and organization_id = new.organization_id;
|
||||
|
||||
update public.lead_checkpoints set
|
||||
rolling_summary = '[resumo anonimizado]',
|
||||
commitments = '[]'::jsonb,
|
||||
objections = '[]'::jsonb,
|
||||
next_action = null,
|
||||
declaracao = null
|
||||
where contact_id = new.id and organization_id = new.organization_id;
|
||||
|
||||
-- ── issue #1964 — lead_notes: a memória do agente sobre o contato ─────────
|
||||
update public.lead_notes set
|
||||
headline = '(anonimizado)',
|
||||
body = '(anonimizado)',
|
||||
embedding = null,
|
||||
updated_at = now()
|
||||
where organization_id = new.organization_id
|
||||
and contact_id = new.id
|
||||
and (headline is distinct from '(anonimizado)'
|
||||
or body is distinct from '(anonimizado)'
|
||||
or embedding is not null);
|
||||
|
||||
-- ── issue #1964 — ai_agent_runs.tool_calls: preserva o nome e redige o resto
|
||||
-- A transformação espelha `redigirToolCalls` (lib/lgpd/cascata.ts): cada
|
||||
-- passo vira `{ step?, tool_name?, redacted: true, tool_calls: [{ tool_name }] }`.
|
||||
-- Fica QUAIS ferramentas rodaram e em que passo; sai o texto do modelo, os
|
||||
-- argumentos e os resultados. Guard no WHERE: só corre quando há passo sem
|
||||
-- `redacted`, então o `[]` de nascença e a run já redigida não são tocados.
|
||||
update public.ai_agent_runs set
|
||||
tool_calls = public.fn_lgpd_redigir_tool_calls(ai_agent_runs.tool_calls)
|
||||
where ai_agent_runs.organization_id = new.organization_id
|
||||
and ai_agent_runs.contact_id = new.id
|
||||
and exists (
|
||||
select 1 from jsonb_array_elements(ai_agent_runs.tool_calls) s
|
||||
where coalesce(s->>'redacted', 'false')::boolean is not true
|
||||
);
|
||||
|
||||
-- ── issue #1964 — lead_state: a próxima ação e a qualificação da lead ─────
|
||||
update public.lead_state set
|
||||
next_action = null,
|
||||
qualification = '{}'::jsonb,
|
||||
updated_at = now()
|
||||
where organization_id = new.organization_id
|
||||
and contact_id = new.id
|
||||
and (next_action is not null
|
||||
or coalesce(qualification, '{}'::jsonb) <> '{}'::jsonb);
|
||||
|
||||
-- ── issue #1964 — contacts.social_identity ───────────────────────────────
|
||||
-- O índice único parcial `where social_identity is not null` torna anular
|
||||
-- seguro (a linha sai do índice sem violar unicidade). Guard no WHERE para a
|
||||
-- varredura não reescrever o que já está anonimizado.
|
||||
update public.contacts set
|
||||
social_identity = null,
|
||||
updated_at = now()
|
||||
where id = new.id
|
||||
and organization_id = new.organization_id
|
||||
and social_identity is not null;
|
||||
|
||||
-- Mensagens de grupo escritas pelo titular (0482, ver o cabeçalho daquele bloco).
|
||||
v_variantes := coalesce(public.fn_telefone_variantes(coalesce(old.phone_number, new.phone_number)), '{}');
|
||||
v_lid := coalesce(old.wa_lid, new.wa_lid);
|
||||
|
||||
select coalesce(array_agg(m.id), '{}')
|
||||
into v_msgs_de_grupo
|
||||
from public.messages m
|
||||
where m.organization_id = new.organization_id
|
||||
and m.metadata ? 'group_sender'
|
||||
and (
|
||||
regexp_replace(coalesce(m.metadata->'group_sender'->>'phone', ''), '\D', '', 'g') = any(v_variantes)
|
||||
or (v_lid is not null and m.metadata->'group_sender'->>'lid' = v_lid)
|
||||
);
|
||||
|
||||
if cardinality(v_msgs_de_grupo) > 0 then
|
||||
insert into public.storage_redaction_queue (organization_id, bucket, object_path)
|
||||
select distinct new.organization_id, 'whatsapp-media', m.media_storage_path
|
||||
from public.messages m
|
||||
where m.organization_id = new.organization_id
|
||||
and m.id = any(v_msgs_de_grupo)
|
||||
and m.media_storage_path is not null
|
||||
and length(m.media_storage_path) > 0
|
||||
on conflict (bucket, object_path) do nothing;
|
||||
|
||||
update public.conversations set
|
||||
last_message_preview = null,
|
||||
updated_at = now()
|
||||
where organization_id = new.organization_id
|
||||
and id in (select m.conversation_id from public.messages m
|
||||
where m.organization_id = new.organization_id and m.id = any(v_msgs_de_grupo));
|
||||
|
||||
update public.messages set
|
||||
body = '[mensagem anonimizada]',
|
||||
media_url = null,
|
||||
media_mime = null,
|
||||
media_size_bytes = null,
|
||||
media_storage_path = null,
|
||||
media_derived_text = null,
|
||||
metadata = '{}'::jsonb,
|
||||
updated_at = now()
|
||||
where organization_id = new.organization_id
|
||||
and id = any(v_msgs_de_grupo);
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end
|
||||
$f$;
|
||||
|
||||
-- As DUAS origens de EXECUTE (item 9 do CLAUDE.md): o grant a PUBLIC da criação
|
||||
-- e o grant nominal a anon do ALTER DEFAULT PRIVILEGES do baseline.
|
||||
revoke all on function public.fn_redigir_conversas_ao_anonimizar() from public;
|
||||
revoke execute on function public.fn_redigir_conversas_ao_anonimizar() from anon;
|
||||
revoke execute on function public.fn_redigir_conversas_ao_anonimizar() from authenticated;
|
||||
|
||||
|
||||
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;
|
||||
v_variantes text[] := '{}';
|
||||
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);
|
||||
|
||||
select coalesce(public.fn_telefone_variantes(phone_number), '{}')
|
||||
into v_variantes
|
||||
from contacts
|
||||
where id = p_contact_id and organization_id = p_organization_id;
|
||||
|
||||
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,
|
||||
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,
|
||||
last_handoff_reason = 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,
|
||||
media_derived_text = 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).
|
||||
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);
|
||||
|
||||
-- 6b. crm_proposals (migration 0477, #1504) — PRESERVA número, valores,
|
||||
-- itens, datas e status; redige só o que identifica a PESSOA. Ver o
|
||||
-- cabeçalho desta migration para o porquê de cada coluna.
|
||||
-- O PDF que o cliente recebeu (bucket `propostas`, `<org>/<proposta>.pdf`)
|
||||
-- tem o nome dele impresso: redigir as colunas e deixar o arquivo seria
|
||||
-- anonimizar a linha e manter o documento. Vai para a mesma fila de expurgo
|
||||
-- da mídia (passo 7), com o bucket CERTO — a mensagem que levou o PDF
|
||||
-- aponta para o mesmo caminho, mas o passo 7 só enfileira `whatsapp-media`.
|
||||
-- Lido ANTES de o passo seguinte zerar `pdf_path`.
|
||||
insert into storage_redaction_queue (organization_id, request_id, bucket, object_path)
|
||||
select p_organization_id, p_request_id, 'propostas', pdf_path
|
||||
from crm_proposals
|
||||
where organization_id = p_organization_id
|
||||
and contact_id = p_contact_id
|
||||
and pdf_path is not null and length(pdf_path) > 0
|
||||
-- só arquivo DESTA organização: o expurgo nunca alcança o PDF de outra
|
||||
and pdf_path like p_organization_id::text || '/%'
|
||||
on conflict (bucket, object_path) do nothing;
|
||||
update crm_proposals set
|
||||
destinatario_nome = v_anon_label,
|
||||
briefing_json = '{}'::jsonb,
|
||||
resumo_comercial = null,
|
||||
-- o texto do documento como foi montado e como foi editado à mão: é o
|
||||
-- conteúdo do PDF, com o mesmo nome dentro.
|
||||
rendered_snapshot = null,
|
||||
secoes_editadas = null,
|
||||
pdf_path = 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('crm_proposals', v_count);
|
||||
|
||||
-- CAMPANHAS: o que foi DITO à pessoa e o endereço para onde foi.
|
||||
update campaign_recipients set
|
||||
rendered_body = null,
|
||||
recipient_address = null,
|
||||
variables = '{}'::jsonb,
|
||||
last_error_detail = 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('campaign_recipients', v_count);
|
||||
|
||||
-- LISTA DE EXCLUSÃO: solta o vínculo e apaga a cauda do telefone.
|
||||
update campaign_suppressions set
|
||||
address_tail = null,
|
||||
reason = null,
|
||||
contact_id = null
|
||||
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('campaign_suppressions', v_count);
|
||||
|
||||
-- 6c. sales — a comanda. PRESERVA valor, status e datas, e NÃO desliga o
|
||||
-- contato (ver racional completo no baseline, bloco desta função).
|
||||
update sales set
|
||||
notes = null,
|
||||
cancel_reason = case when cancel_reason is null then null else '[redigido]' end,
|
||||
reverse_reason = case when reverse_reason is null then null else '[redigido]' end,
|
||||
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('sales', v_count);
|
||||
|
||||
-- 6d. conversation_notes (migration 0483, F3 da #1863) — a nota interna é
|
||||
-- texto escrito SOBRE a pessoa durante o atendimento, e o anexo dela é mídia
|
||||
-- ancorada na conversa: os dois entram no alcance do titular. A 0477 já
|
||||
-- mostrou o desenho (arquivo vai para a fila ANTES de a coluna ser zerada).
|
||||
-- O bucket é `internal-media`, e não o do passo 7: a nota nunca sobe no
|
||||
-- `whatsapp-media` (é o bucket do canal do CLIENTE), e enfileirar o caminho
|
||||
-- num bucket onde ele não está deixaria a remoção apontando para o nada —
|
||||
-- a mesma falha de não ter anonimizado, um endereço mais para a direita.
|
||||
-- Por isso os caminhos de nota também NÃO entram em `v_media_paths`: essa
|
||||
-- lista só existe para o passo 7, que enfileira `whatsapp-media`.
|
||||
insert into storage_redaction_queue (organization_id, request_id, bucket, object_path)
|
||||
select p_organization_id, p_request_id, 'internal-media', n.media_storage_path
|
||||
from conversation_notes n
|
||||
where n.organization_id = p_organization_id
|
||||
and n.conversation_id in (
|
||||
select id from conversations
|
||||
where contact_id = p_contact_id and organization_id = p_organization_id
|
||||
)
|
||||
and n.media_storage_path is not null and length(n.media_storage_path) > 0
|
||||
and n.media_storage_path like p_organization_id::text || '/%'
|
||||
on conflict (bucket, object_path) do nothing;
|
||||
update conversation_notes set
|
||||
body = '[nota interna anonimizada]',
|
||||
media_storage_path = null,
|
||||
media_mime = null,
|
||||
media_size_bytes = null
|
||||
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('conversation_notes', 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).
|
||||
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);
|
||||
|
||||
update prospecting_candidates set suppression_salt = gen_random_bytes(32)
|
||||
where organization_id = p_organization_id
|
||||
and (contact_id = p_contact_id
|
||||
or (phone is not null
|
||||
and regexp_replace(phone, '\D', '', 'g') = any (v_variantes)))
|
||||
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
|
||||
or (phone is not null
|
||||
and regexp_replace(phone, '\D', '', 'g') = any (v_variantes)));
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('prospecting_candidates', v_count);
|
||||
|
||||
-- agent_cases — o que a IA escreveu SOBRE a pessoa quando travou (migration 0280).
|
||||
update agent_cases set
|
||||
title = v_anon_label,
|
||||
summary = '[resumo anonimizado]',
|
||||
blocker = '[bloqueio anonimizado]',
|
||||
context_snapshot = '{}'::jsonb
|
||||
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('agent_cases', v_count);
|
||||
|
||||
-- agent_case_events — a linha do tempo do caso (migration 0280).
|
||||
update agent_case_events set
|
||||
body = null,
|
||||
metadata = '{}'::jsonb
|
||||
where organization_id = p_organization_id
|
||||
and case_id in (
|
||||
select id from agent_cases
|
||||
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('agent_case_events', v_count);
|
||||
|
||||
-- demandas — o assunto do pedido (migration 0280).
|
||||
update demandas set
|
||||
assunto = null
|
||||
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('demandas', v_count);
|
||||
|
||||
-- agent_inbox_items — o aviso que leva o texto do caso para a Central (migration 0280/0292).
|
||||
update agent_inbox_items set
|
||||
status = 'resolved',
|
||||
resolved_at = now(),
|
||||
body = 'Contato anonimizado.',
|
||||
ref_id = null
|
||||
where organization_id = p_organization_id
|
||||
and kind in ('handoff', 'case_stale', 'aviso_de_caso_nao_entregue')
|
||||
and (
|
||||
(ref_kind = 'contact' and ref_id = p_contact_id)
|
||||
or (ref_kind = 'conversation' and ref_id in (
|
||||
select id from conversations
|
||||
where contact_id = p_contact_id and organization_id = p_organization_id
|
||||
))
|
||||
or (ref_kind = 'agent_case' and ref_id in (
|
||||
select id from agent_cases
|
||||
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('agent_inbox_items', v_count);
|
||||
|
||||
-- agent_case_chat_messages — a consulta interna da equipe à IA SOBRE o caso (migration 0281).
|
||||
update agent_case_chat_messages set
|
||||
body = null,
|
||||
redacted_at = now()
|
||||
where organization_id = p_organization_id
|
||||
and contact_id = p_contact_id
|
||||
and redacted_at is null;
|
||||
get diagnostics v_count = row_count;
|
||||
v_counts := v_counts || jsonb_build_object('agent_case_chat_messages', v_count);
|
||||
|
||||
-- passagens_de_atendimento — o BRIEFING é sobre a pessoa (migration 0291).
|
||||
update passagens_de_atendimento set
|
||||
body = v_anon_label,
|
||||
title = null,
|
||||
notes = null,
|
||||
content = null,
|
||||
tentativas = '[]'::jsonb
|
||||
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('passagens_de_atendimento', v_count);
|
||||
|
||||
-- entregas_de_aviso_de_caso — o registro do aviso ao suporte (migration 0292).
|
||||
update entregas_de_aviso_de_caso set
|
||||
erro_detalhe = null
|
||||
where organization_id = p_organization_id
|
||||
and case_id in (
|
||||
select id from agent_cases
|
||||
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('entregas_de_aviso_de_caso', v_count);
|
||||
|
||||
-- channel_session_groups.subject — o NOME do grupo, e a FK contact_id aponta
|
||||
-- para o placeholder do grupo (contacts.kind = 'whatsapp_group'), nunca para
|
||||
-- o titular real sendo anonimizado neste caminho — mas a FK para contacts e o
|
||||
-- nome da coluna casam o padrão automático do escopo (migration 0482), e
|
||||
-- nulificar não perde nada operacional: número, conversa e liga/desliga ficam.
|
||||
update public.channel_session_groups set
|
||||
subject = null
|
||||
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('channel_session_groups', 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;
|
||||
|
||||
-- Cura: mensagens já anonimizadas que guardaram a transcrição.
|
||||
update public.messages set
|
||||
media_derived_text = null,
|
||||
updated_at = now()
|
||||
where body = '[mensagem anonimizada]'
|
||||
and media_derived_text is not null;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,40 @@
|
||||
-- ═══ Debounce de rajada configurável por agente (0498) ═══
|
||||
--
|
||||
-- Até aqui a janela que junta as mensagens do MESMO contato numa resposta só
|
||||
-- era só a env do worker `INBOUND_DEBOUNCE_MS` (default 8000, em
|
||||
-- lib/agent-engine/env.ts): global à instalação, invisível na tela e exigindo
|
||||
-- acesso à VPS para mudar. Esta migration leva o valor para a versão do agente
|
||||
-- (`ai_agent_versions.inbound_debounce_ms`).
|
||||
--
|
||||
-- Sem semântica explícita é fácil errar o desenho, então a regra fica escrita:
|
||||
-- - NULL (default) = usa o `INBOUND_DEBOUNCE_MS` da instalação. Quem só
|
||||
-- atualiza não muda de comportamento nenhum — regressão zero.
|
||||
-- - 0 desliga a coalescência de rajada para aquele agente (job imediato).
|
||||
-- - 1..60000 define a janela em milissegundos, com TETO em 60s para ninguém
|
||||
-- travar o atendimento sem querer.
|
||||
-- O teto é reforçado no leitor do worker (`debounceEfetivo` em debounce.ts):
|
||||
-- a constraint abaixo é a cerca do banco, mas quem decide de verdade é o
|
||||
-- TypeScript, que clampa o valor contra o teto mesmo para dados sujos.
|
||||
--
|
||||
-- Por que coluna e não jsonb: o resto da config "Limites do atendimento" da
|
||||
-- versão (`max_steps`, `token_budget`, `history_message_window`, …) vive em
|
||||
-- colunas tipadas nesta tabela, e o PATCH/INSERT da versão as mapeia campo a
|
||||
-- campo. Nascer jsonb aqui seria divergir do vizinho sem CHECK forte nenhum.
|
||||
|
||||
alter table public.ai_agent_versions
|
||||
drop constraint if exists ai_agent_versions_inbound_debounce_ms_check;
|
||||
|
||||
alter table public.ai_agent_versions
|
||||
add column if not exists inbound_debounce_ms integer;
|
||||
|
||||
comment on column public.ai_agent_versions.inbound_debounce_ms is
|
||||
'Janela de coalescência de rajada inbound em ms para ESTE agente. NULL = usa o INBOUND_DEBOUNCE_MS da instalação; 0 = desliga a coalescência; teto 60s.';
|
||||
|
||||
-- 0..60000 (0 desliga; 60000 = 60s, o teto que impede travar o atendimento).
|
||||
-- O `drop … if exists` antes do `add` torna a migration reaplicável: o
|
||||
-- `update.sh` de quem já aplicou a 0498 roda o apêndice do baseline de novo, e
|
||||
-- `add constraint` sem guarda quebraria com 'already exists'. Mesmo par no
|
||||
-- apêndice do baseline.sql.
|
||||
alter table public.ai_agent_versions
|
||||
add constraint ai_agent_versions_inbound_debounce_ms_check
|
||||
check (inbound_debounce_ms is null or (inbound_debounce_ms >= 0 and inbound_debounce_ms <= 60000));
|
||||
@@ -0,0 +1,52 @@
|
||||
-- ═══ Atraso humano configurável por conexão (0499) ═══
|
||||
--
|
||||
-- Os quatro números que governam o atraso humano ANTES da primeira bolha
|
||||
-- (`lib/agent-engine/agent/atraso-humano.ts`) eram fixos em código: NOTAR
|
||||
-- (~900ms), POR_CARACTERE (~22ms), MINIMO (~1200ms) e MAXIMO (~7500ms), sem
|
||||
-- superfície. Uma clínica e um e-commerce querem ritmos diferentes, e os dois
|
||||
-- recebiam o mesmo valor cravado (issue #653, do achado no PR #644).
|
||||
--
|
||||
-- Estes quatro knobs nascem em `channel_knobs` — a MESMA tabela que já guarda
|
||||
-- `throttle_ms`/`jitter_max_ms` (0010) e `resposta_*` (0495) por conexão.
|
||||
-- Coluna NULL = cai no default conservador em `defaults.ts`, que espelha os
|
||||
-- valores de antes: regressão zero para quem não configurou.
|
||||
--
|
||||
-- Nenhuma migration nova se o mecanismo knob-por-conexão já existisse — ele
|
||||
-- existe, e é `channel_knobs`. Estes são só quatro colunas novas na tabela
|
||||
-- existente, com o MESMO contrato das vizinhas (nullable, sem default).
|
||||
--
|
||||
-- O jitter entre bolhas (`1200 + rand*800` em `inbound-turn.ts`) NÃO ganha
|
||||
-- coluna nova: ele já é `throttle_ms + jitter_max_ms` (defaults 1200/800) —
|
||||
-- o que mudou foi o call site passar a LER os knobs da conexão em vez do
|
||||
-- literal. O preenchimento acima e a exposição na ficha Anti-ban valem para
|
||||
-- o par.
|
||||
|
||||
alter table public.channel_knobs
|
||||
add column if not exists atraso_notar_ms integer,
|
||||
add column if not exists ms_por_caractere integer,
|
||||
add column if not exists atraso_minimo_ms integer,
|
||||
add column if not exists atraso_maximo_ms integer;
|
||||
|
||||
comment on column public.channel_knobs.atraso_notar_ms is
|
||||
'Parcela fixa do atraso humano (ms): ver a notificação e abrir a conversa. NULL = default (900).';
|
||||
comment on column public.channel_knobs.ms_por_caractere is
|
||||
'Taxa de digitação do atraso humano (ms por caractere). NULL = default (22).';
|
||||
comment on column public.channel_knobs.atraso_minimo_ms is
|
||||
'Piso do atraso humano (ms). NULL = default (1200).';
|
||||
comment on column public.channel_knobs.atraso_maximo_ms is
|
||||
'Teto do atraso humano (ms). NULL = default (7500).';
|
||||
|
||||
-- Sanidade: valores positivos e dentro do mesmo teto dos vizinhos (intervalMaxMs).
|
||||
-- O `drop … if exists` antes do `add` é o que torna o apêndice reaplicável.
|
||||
alter table public.channel_knobs
|
||||
drop constraint if exists channel_knobs_atraso_humano_saneamento;
|
||||
|
||||
alter table public.channel_knobs
|
||||
add constraint channel_knobs_atraso_humano_saneamento
|
||||
check (
|
||||
(atraso_notar_ms is null or (atraso_notar_ms between 0 and 600000))
|
||||
and (ms_por_caractere is null or (ms_por_caractere between 0 and 1000))
|
||||
and (atraso_minimo_ms is null or (atraso_minimo_ms between 0 and 600000))
|
||||
and (atraso_maximo_ms is null or (atraso_maximo_ms between 0 and 600000))
|
||||
and (atraso_maximo_ms is null or atraso_minimo_ms is null or atraso_maximo_ms >= atraso_minimo_ms)
|
||||
);
|
||||
@@ -485,3 +485,6 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
|
||||
| `20260930010300` | `0494_lgpd_cascata_banco_alcanca_notas_itens` | **A cascata do BANCO alcança `lead_notes`, `ai_agent_runs.tool_calls`, `lead_state` e `contacts.social_identity` (issue #1964, follow-up do #1958).** O gatilho da virada de `is_anonymized` (desenho da 0391, a porta que os DOIS caminhos de anonimização cruzam) passou a redigir também: `lead_notes.headline`/`body` (e `embedding`) → `(anonimizado)`, `ai_agent_runs.tool_calls` → redação que PRESERVA o nome da ferramenta e apaga argumentos/resultados (espelha `redigirToolCalls`/`serialize.ts`, com `redacted = true` em todo passo), `lead_state.next_action` → `null` e `qualification` → `{}`, e `contacts.social_identity` → `null` (o índice único parcial `where social_identity is not null` torna anular seguro). Filtra organização **e** contato em todo passo; idempotente — guard no WHERE repete o marcador de "já redigido" da app para a varredura diária não reescrever o que já está anonimizado. `fn_lgpd_redigir_tool_calls(jsonb)` é função nova `immutable` revogada de `anon`/`authenticated` (único chamador é o gatilho, `security definer` de dono postgres). Sem cura retroativa no corpo (a varredura diária da app já cura o que veio antes); apêndice idempotente no `baseline.sql` antes da VARREDURA anon. Gate: `tests/invariants/lgpd-cascata-do-banco-alcanca-notas-itens.test.ts`. |
|
||||
| `20260930120000` | `0495_janela_de_resposta_separada` | **A janela de RESPOSTA sai da janela de DISPARO (#1983, de @suporteubere99-coder).** As duas coisas eram regidas por UM par de horas em `channel_knobs` (`window_start_hour`/`window_end_hour`, da 0010), então abrir o atendimento para 24h abria junto o disparo em massa, a prospecção e o `followup_turn` (retomada de conversa parada). Duas colunas nullable, `resposta_start_hour`/`resposta_end_hour`, sem DEFAULT: **NULL herda a janela de disparo, coluna a coluna** (`loadChannelKnobs` em `lib/agent-engine/pacing/store.ts` e `effectiveKnobs` em `lib/ai/pacing-knobs.ts`, a mesma regra que a ficha Anti-ban mostra no placeholder) — quem só atualiza não muda de operação. CHECK 0..23 / 1..24 com `drop … if exists` antes do `add`. Um bloco `do` guardado renomeia `reengajar_start_hour`/`reengajar_end_hour` para o nome novo onde a primeira versão do PR (nome antigo) já tinha sido aplicada. A separação vive em `PacingInput.resposta` (omitido = disparo: todo chamador fora do turno de resposta fica na janela de disparo); só `inbound_turn`/`case_reply_turn` passam `true` (`eTurnoDeResposta` em `inbound-turn.ts`), inclusive na pergunta do roteiro que sai no mesmo turno. `nextDayOpen` segue na janela de disparo: cap diário é proteção anti-ban. Cap, warm-up e throttle continuam para os dois lados. Sem função nova. Gates: `lib/agent-engine/pacing/janela-de-resposta.test.ts` e `tests/unit/protecao-de-envio-aceita-data-em-branco.test.ts` (0/24 aceito, 22/7 recusado com 422). |
|
||||
| `20260930130000` | `0496_org_operante_e_suspensao_tipada` | **A suspensão que suspende (spec cobrança do revendedor, PR 1).** Suspender só tirava a pessoa da tela: a rota fazia leitura, UPDATE e `event_log` sem await em três passos soltos, nada parava jobs `pending` nem mensagens `queued`, e `status` era gravável pelo PostgREST por qualquer platform admin (`orgs_write_platform_admin` aceita `fn_is_platform_admin()`, que ignora o scope — um `support_readonly` reativava uma suspensa). (A) `organizations.suspended_kind` (`administrativa`/`cobranca`), backfill antes do CHECK, SEM CHECK de coerência com `status` (o `lgpd-redact-worker` troca para `redacted` sem limpar o tipo; regra de leitura em `lib/organizacao/operante.ts`) e `fn_org_operante(uuid)`, a régua SQL do predicado. (B) Gatilho `trg_organizacao_estado_so_pelo_servidor` (invoker, molde `fn_meet_stamp`): `authenticated`/`anon` não inserem organização nem mudam `status`, `suspended_*` ou `created_by` (42501). (C0) `fn_org_parada_descarta_fila` (invoker, EXECUTE revogado até de `service_role`; só as duas funções de estado a chamam): jobs `pending` → `failed`/`org_nao_operante` e, na mesma CTE, o que dependia deles — rascunho `approved` → `failed`/`org_suspensa`, link do Meet → `state failed` + aviso na Central (`fn_meet_notice`) — e o evento `turn_discarded` para a inscrição de follow-up parada num nó `action`. (C) `fn_suspender_organizacao` (definer, uma transação, `for update`): chama a C0, mensagens `queued` → `failed`/`org_suspensa`, `tenant.suspended` no `event_log` na mesma transação; a administrativa prevalece. (D) kind `org_reativada` no bloco único de `agent_inbox_items_kind_check` (lista completa do baseline, `kind-check-migration-x-baseline`). (E) `fn_reativar_organizacao` (definer): exige o tipo (suspensão nula vale como administrativa), zera a suspensão, falha `pending` remanescente e abre UM item `org_reativada` com a contagem de conversas (fora grupos) com mensagem durante a suspensão — nada é reprocessado. (F) `fn_claim_due_followup_enrollments` (definição vigente da 0308 + `exists` de org `active` na CTE `orgs`): o motor de follow-up não reclama inscrição de org parada nem toca o lease; na reativação ela RETOMA, e a de nó `action` com `turn_discarded` ganha turno novo em vez de esgotar o dead-man. (G) `fn_followup_generation_write` (definição vigente da 0488): recusa `turn_discarded` vindo da sessão (`auth.uid()`, 42501 `followup_step_internal`) — só o servidor grava o evento que faz o motor enfileirar. Nenhuma das duas cita `cobranca_assinaturas` (PR 2). EXECUTE revogado de `public`, `anon` e `authenticated`; só `service_role`. Apêndice antes da VARREDURA anon (seção D editada no lugar). Gate: `tests/invariants/org-suspensa.test.ts`, `tests/invariants/followup-org-suspensa.test.ts` e o par `organizations.suspended_kind` × `TIPOS_DE_SUSPENSAO` em `tests/invariants/vocabulario-banco-x-typescript.test.ts`. |
|
||||
| `20260930140000` | `0497_lgpd_anonimizar_apaga_transcricao` | **Anonimizar apaga também a TRANSCRIÇÃO da mídia (`messages.media_derived_text`).** Achado na triagem do #1988 (@AlecLimaDev). A coluna (0058, gravada pelo `media-derive-worker`) não era zerada por nenhum caminho: o body do áudio virava `[mensagem anonimizada]` e a transcrição continuava legível para o agente. Uma linha `media_derived_text = null` em cada UPDATE de messages de `fn_redigir_conversas_ao_anonimizar` (corpo da 0494, 2 passadas) e de `fn_lgpd_cascade_redact_contact` (corpo da 0483, passo 3) — nenhuma outra linha muda. Cura idempotente das mensagens já anonimizadas pelo marcador do body (alcança grupos e poupa quem voltou). A varredura diária de `lib/lgpd/cascata.ts` ganha o mesmo passo, e fecha a corrida do worker que grava o texto depois da anonimização. |
|
||||
| `20260930150000` | `0498_debounce_rajada_configuravel` | **A janela de rajada do WhatsApp vira campo da versão do agente (#1856).** A janela que junta mensagens do MESMO contato numa resposta só era só a env do worker `INBOUND_DEBOUNCE_MS` (global, invisível na tela, exige VPS). Coluna `ai_agent_versions.inbound_debounce_ms` (integer, sem DEFAULT): NULL = usa a env da instalação (regressão zero para quem só atualiza), 0 desliga a coalescência, 1..60000 define a janela em ms com TETO de 60s — o teto é reforçado no leitor do worker (`debounceEfetivo` em `lib/agent-engine/edge/crm/debounce.ts`) e com CHECK `0..60000` no banco (par drop/add pra reaplicar). O drain lê a config do agente da conversa (`loadConversationAgentConfig`) e usa o valor do agente no lugar do literal. Gate: `lib/agent-engine/edge/crm/debounce.test.ts` (default = env, campo = valor, teto clamp no limite). |
|
||||
| `20260930160000` | `0499_atraso_humano_por_conexao` | **O atraso humano vira configuração por conexão (issue #653).** Os quatro números que governam o atraso ANTES da primeira bolha (`atraso_notar_ms`/`ms_por_caractere`/`atraso_minimo_ms`/`atraso_maximo_ms`) saem do literal em `atraso-humano.ts` e passam a ser 4 colunas NULL em `channel_knobs` (a MESMA tabela dos knobs de pacing por conexão — 0010 e 0495; nada de tabela nova). NULL = default conservador em `defaults.ts` (900/22/1200/7500 — os valores de antes: regressão zero). O jitter entre bolhas (`1200 + rand*800` em `inbound-turn.ts`) NÃO ganha coluna: ele já é `throttle_ms` + `jitter_max_ms` (defaults 1200/800) e passa apenas a LER os knobs da conexão em vez do literal. CHECK de sanidade (`atraso_maximo_ms >= atraso_minimo_ms`, teto intervalMaxMs); exposição na ficha Anti-ban via `POST/GET /api/v1/ai/pacing`. Gate: `tests/unit/atraso-humano-knobs.test.ts`. |
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import pg from "pg";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* ANONIMIZAR APAGA A TRANSCRIÇÃO DA MÍDIA (migration 0497, triagem do #1988).
|
||||
*
|
||||
* `messages.media_derived_text` é o texto que o `media-derive-worker` tira da
|
||||
* mídia — a transcrição do áudio, o OCR da imagem. Até a 0497, a anonimização
|
||||
* trocava o body por '[mensagem anonimizada]' e deixava a transcrição legível,
|
||||
* e é ela que o agente lê do áudio (get-lead-context, inbound-turn…).
|
||||
*
|
||||
* Prova, no Postgres real e pelo COMPORTAMENTO:
|
||||
* - a virada de is_anonymized (gatilho da 0391/0494, a porta dos dois
|
||||
* caminhos) zera a transcrição das conversas do contato E das mensagens de
|
||||
* grupo que ele escreveu;
|
||||
* - o pedido formal (`fn_lgpd_cascade_redact_contact`) chega ao mesmo estado;
|
||||
* - a mensagem de OUTRO contato da mesma org fica intacta;
|
||||
* - a cura do apêndice (lida do `baseline.sql`, não copiada) alcança quem JÁ
|
||||
* era anonimizado e poupa a mensagem nova de quem voltou a escrever.
|
||||
*
|
||||
* O passo 3 da RPC não tem prova por comportamento própria: a RPC vira
|
||||
* is_anonymized no passo 1 e o gatilho zera a coluna antes do passo 3. Quem
|
||||
* vigia aquela linha é o último caso deste arquivo, pelo corpo instalado.
|
||||
*
|
||||
* ─── Por que a régua por COLUNA mora aqui ───────────────────────────────────
|
||||
* `lgpd-redact-unificado-alcanca-pelo-catalogo` e
|
||||
* `lgpd-cascata-alcanca-quem-guarda-pessoa` decidem por TABELA: um comando em
|
||||
* `messages` basta para ela contar como coberta, e foi assim que esta coluna
|
||||
* passou meses legível com os dois verdes (o `PADRAO_PII` do segundo nem casa
|
||||
* `media_derived_text`). Os dois arquivos estão sob o congelamento de
|
||||
* `tests/invariants/**`, então a régua nova nasce aqui.
|
||||
*/
|
||||
|
||||
const pool = new pg.Pool({
|
||||
connectionString: `postgresql://postgres:postgres@127.0.0.1:${process.env.TEST_DB_PORT}/postgres`,
|
||||
});
|
||||
afterAll(() => pool.end());
|
||||
const q = (text: string, args: unknown[] = []) => pool.query(text, args);
|
||||
|
||||
const ORG = randomUUID();
|
||||
const SESSAO = randomUUID();
|
||||
const ALVO = randomUUID(); // anonimizado pela virada direta (o UPDATE do botão)
|
||||
const PELO_PEDIDO = randomUUID(); // anonimizado pela RPC do pedido formal
|
||||
const VIZINHO = randomUUID(); // mesma org, NÃO anonimizado
|
||||
const JA_ANONIMIZADO = randomUUID(); // anonimizado antes da 0497 — alvo da cura
|
||||
const GRUPO = randomUUID(); // contato placeholder do grupo
|
||||
const TELEFONE_DO_ALVO = "+5511987650497";
|
||||
|
||||
const conversaDe = new Map<string, string>();
|
||||
const TRANSCRICAO = "oi, aqui é a Maria Souza, meu CPF é 52998224725";
|
||||
|
||||
async function audio(contato: string, body: string | null = null, metadata: object = {}) {
|
||||
const id = randomUUID();
|
||||
await q(
|
||||
`insert into messages(id,organization_id,conversation_id,channel_session_id,contact_id,type,direction,status,
|
||||
sent_via,body,media_storage_path,media_derived_text,media_derived_status,sent_at,metadata)
|
||||
values($1,$2,$3,$4,$5,'audio','inbound','delivered','external_device',$6,$7,$8,'ready',now(),$9::jsonb)`,
|
||||
[id, ORG, conversaDe.get(contato), SESSAO, contato, body, `${ORG}/${id}.ogg`, TRANSCRICAO, JSON.stringify(metadata)],
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function transcricao(id: string): Promise<string | null> {
|
||||
return (await q("select media_derived_text from messages where id=$1", [id])).rows[0].media_derived_text;
|
||||
}
|
||||
|
||||
let doAlvo: string;
|
||||
let doGrupo: string;
|
||||
let doPedido: string;
|
||||
let doVizinho: string;
|
||||
let residuo: string;
|
||||
let deQuemVoltou: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await q("insert into organizations(id,slug,legal_name,display_name) values($1::uuid,$1::text,'Transcrição LGPD','Transcrição LGPD')", [ORG]);
|
||||
await q(
|
||||
"insert into channel_sessions(id,organization_id,waha_session_name,webhook_secret_encrypted) values($1::uuid,$2,$1::text,'\\x00'::bytea)",
|
||||
[SESSAO, ORG],
|
||||
);
|
||||
for (const [id, nome, telefone] of [
|
||||
[ALVO, "Alvo", TELEFONE_DO_ALVO],
|
||||
[PELO_PEDIDO, "Pelo Pedido", "+5521911110497"],
|
||||
[VIZINHO, "Vizinho", "+5531922220497"],
|
||||
] as const) {
|
||||
await q("insert into contacts(id,organization_id,name,display_name,phone_number) values($1,$2,$3,$3,$4)", [id, ORG, nome, telefone]);
|
||||
}
|
||||
// Já anonimizado ANTES da 0497: nasce com is_anonymized (o gatilho só dispara na virada).
|
||||
await q(
|
||||
"insert into contacts(id,organization_id,name,display_name,is_anonymized,anonymized_at) values($1,$2,'Cliente Anonimizado','Cliente Anonimizado',true,now())",
|
||||
[JA_ANONIMIZADO, ORG],
|
||||
);
|
||||
await q(
|
||||
"insert into contacts(id,organization_id,name,display_name,kind,source,source_metadata) values($1,$2,'Grupo','Grupo','whatsapp_group','whatsapp_group',jsonb_build_object('group_chat_id','120363000000000497@g.us'))",
|
||||
[GRUPO, ORG],
|
||||
);
|
||||
for (const contato of [ALVO, PELO_PEDIDO, VIZINHO, JA_ANONIMIZADO, GRUPO]) {
|
||||
const conversa = randomUUID();
|
||||
conversaDe.set(contato, conversa);
|
||||
await q(
|
||||
"insert into conversations(id,organization_id,contact_id,channel_session_id,status,is_group) values($1,$2,$3,$4,'open',$5)",
|
||||
[conversa, ORG, contato, SESSAO, contato === GRUPO],
|
||||
);
|
||||
}
|
||||
|
||||
doAlvo = await audio(ALVO);
|
||||
doGrupo = await audio(GRUPO, null, { group_sender: { name: "Alvo", phone: TELEFONE_DO_ALVO, lid: null } });
|
||||
doPedido = await audio(PELO_PEDIDO);
|
||||
doVizinho = await audio(VIZINHO);
|
||||
// O estado que a main deixava: body redigido, transcrição legível.
|
||||
residuo = await audio(JA_ANONIMIZADO, "[mensagem anonimizada]");
|
||||
// Quem voltou a escrever depois de anonimizado (religado pelo LID): body de verdade.
|
||||
deQuemVoltou = await audio(JA_ANONIMIZADO, "voltei, quero o orçamento");
|
||||
});
|
||||
|
||||
describe("LGPD: anonimizar apaga a transcrição da mídia (0497)", () => {
|
||||
it("antes: todas as mensagens guardam a transcrição — o experimento tem massa", async () => {
|
||||
for (const id of [doAlvo, doGrupo, doPedido, doVizinho, residuo, deQuemVoltou]) {
|
||||
expect(await transcricao(id)).toBe(TRANSCRICAO);
|
||||
}
|
||||
});
|
||||
|
||||
it("⭐ a virada de is_anonymized zera a transcrição da conversa do contato E da mensagem de grupo dele", async () => {
|
||||
await q(
|
||||
`update contacts set name=null, display_name='Contato Anonimizado', phone_number=null,
|
||||
is_anonymized=true, anonymized_at=now(), updated_at=now()
|
||||
where organization_id=$1 and id=$2`,
|
||||
[ORG, ALVO],
|
||||
);
|
||||
expect(await transcricao(doAlvo)).toBeNull();
|
||||
expect(await transcricao(doGrupo)).toBeNull();
|
||||
const m = (await q("select body, media_derived_status from messages where id=$1", [doAlvo])).rows[0];
|
||||
expect(m.body).toBe("[mensagem anonimizada]");
|
||||
// O status é vocabulário, não conteúdo: é ele que diz ao drain que a derivação terminou.
|
||||
expect(m.media_derived_status).toBe("ready");
|
||||
});
|
||||
|
||||
it("⭐ o pedido formal (fn_lgpd_cascade_redact_contact) chega ao mesmo estado", async () => {
|
||||
await q("select public.fn_lgpd_cascade_redact_contact($1,$2,null)", [ORG, PELO_PEDIDO]);
|
||||
expect(await transcricao(doPedido)).toBeNull();
|
||||
});
|
||||
|
||||
it("⭐ a mensagem de OUTRO contato da mesma org não é tocada", async () => {
|
||||
expect(await transcricao(doVizinho)).toBe(TRANSCRICAO);
|
||||
});
|
||||
|
||||
it("⭐ a cura do apêndice alcança quem já era anonimizado e poupa quem voltou", async () => {
|
||||
const baseline = readFileSync(join(process.cwd(), "supabase", "baseline.sql"), "utf8");
|
||||
const bloco = baseline.slice(baseline.indexOf("(migration 0497) ----"));
|
||||
const cura = /update public\.messages set\s+media_derived_text = null,[\s\S]*?where body = '\[mensagem anonimizada\]'[\s\S]*?;/.exec(
|
||||
bloco.slice(0, bloco.indexOf("\n-- ---- ")),
|
||||
)?.[0];
|
||||
expect(cura, "a cura da 0497 não foi achada no apêndice do baseline").toBeTruthy();
|
||||
|
||||
await q(cura!);
|
||||
|
||||
expect(await transcricao(residuo)).toBeNull();
|
||||
expect(await transcricao(deQuemVoltou)).toBe(TRANSCRICAO);
|
||||
expect(await transcricao(doVizinho)).toBe(TRANSCRICAO);
|
||||
});
|
||||
|
||||
it("⭐ todo UPDATE de messages que redige o body zera a transcrição, nos DOIS corpos instalados", async () => {
|
||||
for (const funcao of ["fn_lgpd_cascade_redact_contact", "fn_redigir_conversas_ao_anonimizar"]) {
|
||||
const { rows } = await q(
|
||||
"select pg_get_functiondef(p.oid) def from pg_proc p where p.proname = $1 and p.pronamespace = 'public'::regnamespace",
|
||||
[funcao],
|
||||
);
|
||||
expect(rows, `função ${funcao} não existe (ou existe em dobro) em public`).toHaveLength(1);
|
||||
const blocos = [...(rows[0].def as string).matchAll(/update\s+(?:public\.)?messages\s+set([\s\S]*?)\bwhere\b/gi)].map(
|
||||
(m) => m[1] ?? "",
|
||||
);
|
||||
expect(blocos.length, `nenhum UPDATE de messages em ${funcao} — a sonda ficou cega`).toBeGreaterThan(0);
|
||||
for (const bloco of blocos) {
|
||||
expect(bloco, `${funcao}: UPDATE de messages que redige o body e deixa a transcrição`).toContain(
|
||||
"[mensagem anonimizada]",
|
||||
);
|
||||
expect(bloco, `${funcao}: UPDATE de messages sem media_derived_text = null`).toMatch(
|
||||
/media_derived_text\s*=\s*null/,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import pg from "pg";
|
||||
|
||||
import { pgComoSupabase } from "../pg-como-supabase";
|
||||
|
||||
/**
|
||||
* O ADAPTADOR NEGA `is` — `.not(col, "is", null)`, medido antes de medir.
|
||||
*
|
||||
* Nasceu pela cascata de LGPD (0497): `completarRedacaoDoContato` só apaga a
|
||||
* transcrição da mensagem que AINDA a tem. Um `not(is)` que não filtrasse
|
||||
* traria (e reescreveria) a tabela inteira, e a idempotência da varredura
|
||||
* seria de mentira. Arquivo próprio porque `pg-como-supabase.test.ts` está sob
|
||||
* o congelamento de `tests/invariants/**`; a régua é a mesma de lá.
|
||||
*/
|
||||
const container = process.env.TEST_DB_CONTAINER;
|
||||
if (!container) {
|
||||
throw new Error("TEST_DB_CONTAINER not set — rode via `pnpm test:db` (scripts/test-db.sh)");
|
||||
}
|
||||
|
||||
const pool = new pg.Pool({
|
||||
connectionString: `postgresql://postgres:postgres@127.0.0.1:${Number(process.env.TEST_DB_PORT ?? 54329)}/postgres`,
|
||||
max: 2,
|
||||
});
|
||||
const db = pgComoSupabase(pool);
|
||||
|
||||
const ORG = "ada57e00-0000-4000-8000-000000000497";
|
||||
|
||||
beforeAll(async () => {
|
||||
await pool.query(
|
||||
`insert into organizations (id, slug, legal_name, display_name)
|
||||
values ($1, 'org-adaptador-nega-is', 'Adaptador Nega Is', 'Adaptador Nega Is') on conflict (id) do nothing`,
|
||||
[ORG],
|
||||
);
|
||||
// O trigger de seed criou o "Pedidos" (default). Estes são os extras: só
|
||||
// "Alfa" tem descrição.
|
||||
await pool.query(
|
||||
`insert into crm_pipelines (organization_id, name, slug, is_default, position, description) values
|
||||
($1, 'Alfa', 'alfa', false, 10, 'tem texto'),
|
||||
($1, 'Bravo', 'bravo', false, 20, null),
|
||||
($1, 'Zulu', 'zulu', false, 30, null)
|
||||
on conflict do nothing`,
|
||||
[ORG],
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.query("delete from organizations where id = $1", [ORG]);
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
describe("o adaptador NEGA `is` — `.not(col, \"is\", null)`", () => {
|
||||
it("na LEITURA traz só quem não é null", async () => {
|
||||
const { data } = await db
|
||||
.from("crm_pipelines")
|
||||
.select("name")
|
||||
.eq("organization_id", ORG)
|
||||
.eq("is_default", false)
|
||||
.not("description", "is", null);
|
||||
expect((data as Array<{ name: string }>).map((x) => x.name)).toEqual(["Alfa"]);
|
||||
});
|
||||
|
||||
it("na ESCRITA só alcança quem não é null", async () => {
|
||||
const { data } = await db
|
||||
.from("crm_pipelines")
|
||||
.update({ description: "reescrita" })
|
||||
.eq("organization_id", ORG)
|
||||
.eq("is_default", false)
|
||||
.not("description", "is", null)
|
||||
.select("name");
|
||||
expect((data as Array<{ name: string }>).map((x) => x.name)).toEqual(["Alfa"]);
|
||||
});
|
||||
});
|
||||
@@ -214,6 +214,13 @@ class ConsultaPg<T> implements PromiseLike<RespostaFalsa<T[]>> {
|
||||
this.filtros.push(["<>", coluna, valor]);
|
||||
return this;
|
||||
}
|
||||
// `.not(col, "is", null)` — nasceu pela cascata de LGPD (0497), que só
|
||||
// apaga a transcrição de mensagem que AINDA a tem. Um `is not` que não
|
||||
// filtrasse faria a varredura parecer idempotente sem ser.
|
||||
if (operador === "is") {
|
||||
this.filtros.push(["is not", coluna, valor]);
|
||||
return this;
|
||||
}
|
||||
return naoImplementado(`not(${operador})`);
|
||||
}
|
||||
|
||||
@@ -292,6 +299,7 @@ class ConsultaPg<T> implements PromiseLike<RespostaFalsa<T[]>> {
|
||||
const onde = this.filtros.map(([op, c, v]) => {
|
||||
// `is` não gasta placeholder: a palavra entra inline.
|
||||
if (op === "is") return `"${c}" is ${literalDeIs(v)}`;
|
||||
if (op === "is not") return `"${c}" is not ${literalDeIs(v)}`;
|
||||
valores.push(v);
|
||||
// `= any` recebe o array inteiro num placeholder só; os demais operadores
|
||||
// são infixos comuns.
|
||||
@@ -463,6 +471,13 @@ class AtualizacaoPg<T> implements PromiseLike<RespostaFalsa<unknown>> {
|
||||
this.filtros.push(["<>", coluna, valor]);
|
||||
return this;
|
||||
}
|
||||
// `.not(col, "is", null)` — nasceu pela cascata de LGPD (0497), que só
|
||||
// apaga a transcrição de mensagem que AINDA a tem. Um `is not` que não
|
||||
// filtrasse faria a varredura parecer idempotente sem ser.
|
||||
if (operador === "is") {
|
||||
this.filtros.push(["is not", coluna, valor]);
|
||||
return this;
|
||||
}
|
||||
return naoImplementado(`not(${operador})`);
|
||||
}
|
||||
|
||||
@@ -481,6 +496,7 @@ class AtualizacaoPg<T> implements PromiseLike<RespostaFalsa<unknown>> {
|
||||
const onde = this.filtros.map(([op, c, v]) => {
|
||||
// `is` não gasta placeholder: a palavra entra inline.
|
||||
if (op === "is") return `"${c}" is ${literalDeIs(v)}`;
|
||||
if (op === "is not") return `"${c}" is not ${literalDeIs(v)}`;
|
||||
valores.push(v);
|
||||
if (op === "not = any") return `not ("${c}" = any($${valores.length}))`;
|
||||
return `"${c}" ${op} $${valores.length}`;
|
||||
|
||||
@@ -216,6 +216,32 @@ describe("paraEventoDoGoogle", () => {
|
||||
paraEventoDoGoogle(agendamento({ participantes: [{ email: " " }] })),
|
||||
).toThrow(/participante sem e-mail/);
|
||||
});
|
||||
|
||||
// ─── A CERCA (#1959) ────────────────────────────────────────────────────
|
||||
// `AgendamentoParaGoogle` não tem `notes`, mas o `sync-executor.ts` chama esta
|
||||
// função com `{ ...a, ... }` — o snapshot inteiro. Se o snapshot voltar a
|
||||
// carregar `notes`, ele CHEGA aqui em tempo de execução, e o TypeScript não
|
||||
// acusa nada (nem se os dois tipos ganharem o campo: medido). A cerca é de
|
||||
// runtime. O caso sem observação é o que importa: foi exatamente o
|
||||
// `a.description?.trim() || a.notes?.trim()` da #1959.
|
||||
it.each([
|
||||
["com observação visível", "Primeira consulta", "Primeira consulta"],
|
||||
["sem observação (null)", null, undefined],
|
||||
["com observação em branco", " ", undefined],
|
||||
])("⚠️ nunca vaza a anotação INTERNA (notes) para o Google — %s", (_rotulo, description, esperada) => {
|
||||
const comNotaInterna = {
|
||||
...agendamento({ description }),
|
||||
notes: "queixa clínica: paciente com ansiedade e dor torácica recorrente",
|
||||
} as unknown as AgendamentoParaGoogle;
|
||||
const corpo = paraEventoDoGoogle(comNotaInterna);
|
||||
expect(corpo.description).toBe(esperada);
|
||||
// Nenhum campo do corpo pode carregar o texto interno — pega também um
|
||||
// espalhamento (`...a`) que criasse uma chave nova.
|
||||
const serializa = JSON.stringify(corpo);
|
||||
expect(serializa).not.toContain("queixa clínica");
|
||||
expect(serializa).not.toContain("dor torácica");
|
||||
expect("notes" in corpo).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── VOLTA ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -122,3 +122,44 @@ describe("versionCreateSchema aceita as flags por-agente que a tela edita", () =
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* O SELECT e o schema iguais ainda não bastam: a server action da tela monta os
|
||||
* INSERTs de versão campo a campo. `inbound_debounce_ms` (#1856) entrou só no
|
||||
* PATCH de rascunho existente e ficou fora dos três INSERTs — salvar um agente
|
||||
* publicado, reverter pelo Histórico e criar pela tela gravavam NULL, e a janela
|
||||
* voltava calada para a env. Este bloco lê cada `.insert({ ... })` encadeado em
|
||||
* `.from("ai_agent_versions")` da action e cobra a coluna em todos.
|
||||
*/
|
||||
function insertsDeVersao(source: string): string[] {
|
||||
const blocos: string[] = [];
|
||||
const re = /\.from\("ai_agent_versions"\)\s*\.insert\(\{/g;
|
||||
for (let m = re.exec(source); m !== null; m = re.exec(source)) {
|
||||
let i = m.index + m[0].length;
|
||||
let nivel = 1;
|
||||
for (; i < source.length && nivel > 0; i++) {
|
||||
if (source[i] === "{") nivel++;
|
||||
else if (source[i] === "}") nivel--;
|
||||
}
|
||||
blocos.push(source.slice(m.index, i));
|
||||
}
|
||||
return blocos;
|
||||
}
|
||||
|
||||
describe("INSERTs de versão da tela levam a janela de rajada", () => {
|
||||
it("o extrator acusa um INSERT sem a coluna (controle positivo)", () => {
|
||||
const sem = 'admin.from("ai_agent_versions").insert({ split_max_chars: 1, followup: { a: 1 } })';
|
||||
const blocos = insertsDeVersao(sem);
|
||||
expect(blocos).toHaveLength(1);
|
||||
expect(blocos[0]).toContain("split_max_chars");
|
||||
expect(blocos[0]).not.toContain("inbound_debounce_ms");
|
||||
});
|
||||
|
||||
it("todo INSERT de ai_agent_versions em _actions.ts grava inbound_debounce_ms", () => {
|
||||
const source = readFileSync(join(ROOT, "app/app/ai/agents/[id]/_actions.ts"), "utf8");
|
||||
const blocos = insertsDeVersao(source);
|
||||
expect(blocos.length).toBeGreaterThan(0);
|
||||
const semColuna = blocos.filter((b) => !b.includes("inbound_debounce_ms"));
|
||||
expect(semColuna.map((b) => b.slice(0, 200))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ATRASO_MAXIMO_MS,
|
||||
ATRASO_MINIMO_MS,
|
||||
ATRASO_NOTAR_MS,
|
||||
MS_POR_CARACTERE,
|
||||
atrasoHumanoEfetivo,
|
||||
calcularAtrasoHumano,
|
||||
} from "@/lib/agent-engine/agent/atraso-humano";
|
||||
import { PACING_DEFAULTS } from "@/lib/agent-engine/pacing/defaults";
|
||||
import { effectiveKnobs } from "@/lib/ai/pacing-knobs";
|
||||
|
||||
/**
|
||||
* Issue #653 — o atraso humano antes da 1ª bolha vira knob por conexão.
|
||||
*
|
||||
* Os quatro números (`atraso_humano` NOTAR/POR_CARACTERE/MINIMO/MAXIMO) eram
|
||||
* literais em `atraso-humano.ts`. Agora entram pelo `channel_knobs` (0499), com
|
||||
* DEFAULT = os valores de antes: quem nunca configurou tem regressão ZERO, e o
|
||||
* leitor (`loadChannelKnobs`/`effectiveKnobs`) preenche os defaults.
|
||||
*/
|
||||
describe("atraso humano: default idêntico ao código de antes (regressão zero)", () => {
|
||||
it("os defaults do módulo espelham os valores históricos", () => {
|
||||
expect(PACING_DEFAULTS.atrasoNotarMs).toBe(ATRASO_NOTAR_MS); // 900
|
||||
expect(PACING_DEFAULTS.msPorCaractere).toBe(MS_POR_CARACTERE); // 22
|
||||
expect(PACING_DEFAULTS.atrasoMinimoMs).toBe(ATRASO_MINIMO_MS); // 1200
|
||||
expect(PACING_DEFAULTS.atrasoMaximoMs).toBe(ATRASO_MAXIMO_MS); // 7500
|
||||
});
|
||||
|
||||
it("sem knobs, calcularAtrasoHumano devolve exatamente o histórico (piso e teto)", () => {
|
||||
// O mesmo texto curto que o teste antigo prendia: cai no PISO.
|
||||
expect(calcularAtrasoHumano("Oi, tudo bem?")).toBe(ATRASO_MINIMO_MS);
|
||||
// Parágrafo longo cai no TETO.
|
||||
expect(calcularAtrasoHumano("a".repeat(4000))).toBe(ATRASO_MAXIMO_MS);
|
||||
// Vazio >= piso.
|
||||
expect(calcularAtrasoHumano("")).toBe(ATRASO_MINIMO_MS);
|
||||
});
|
||||
|
||||
it("atrasoHumanoEfetivo preenche todos os quatro com o default quando ausente", () => {
|
||||
expect(atrasoHumanoEfetivo(undefined)).toEqual({
|
||||
atrasoNotarMs: ATRASO_NOTAR_MS,
|
||||
msPorCaractere: MS_POR_CARACTERE,
|
||||
atrasoMinimoMs: ATRASO_MINIMO_MS,
|
||||
atrasoMaximoMs: ATRASO_MAXIMO_MS,
|
||||
});
|
||||
// default parcial: o não-informado cai no default.
|
||||
expect(atrasoHumanoEfetivo({ msPorCaractere: 10 })).toMatchObject({ msPorCaractere: 10 });
|
||||
expect(atrasoHumanoEfetivo({ msPorCaractere: 10 }).atrasoNotarMs).toBe(ATRASO_NOTAR_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("atraso humano: knobs por conexão mudam o resultado", () => {
|
||||
it("NOTAR maior empurra o atraso para cima (mesmo texto)", () => {
|
||||
const texto = "Temos sim.";
|
||||
const base = calcularAtrasoHumano(texto);
|
||||
const comNotar = calcularAtrasoHumano(texto, { atrasoNotarMs: base + 500, atrasoMaximoMs: 10_000 });
|
||||
expect(comNotar).toBeGreaterThan(base);
|
||||
});
|
||||
|
||||
it("POR_CARACTERE maior pentencia em textos maiores", () => {
|
||||
const curto = calcularAtrasoHumano("Oi", { msPorCaractere: 50, atrasoMaximoMs: 10_000 });
|
||||
const longo = calcularAtrasoHumano("a".repeat(40), { msPorCaractere: 50, atrasoMaximoMs: 10_000 });
|
||||
expect(longo).toBeGreaterThan(curto);
|
||||
});
|
||||
|
||||
it("teto da conexão é respeitado (não o default 7500)", () => {
|
||||
// Texto que no default explodiria no teto de 7500; com teto menor, para nele.
|
||||
// bruto = 900 + 22*400 = 9700 (> 7500 nos dois casos).
|
||||
expect(calcularAtrasoHumano("a".repeat(400), { atrasoMaximoMs: 3000 })).toBe(3000);
|
||||
expect(calcularAtrasoHumano("a".repeat(400))).toBe(ATRASO_MAXIMO_MS);
|
||||
});
|
||||
|
||||
it("piso da conexão pode ser menor que o piso padrão sem afrouxar anti-ban", () => {
|
||||
// "" só ultrapassa o piso 1200 quando NOTAR > 1200; com NOTAR 50 o bruto
|
||||
// (50) fica abaixo do piso 200 da conexão, que o leva para cima.
|
||||
expect(
|
||||
calcularAtrasoHumano("", { atrasoNotarMs: 50, atrasoMinimoMs: 200, atrasoMaximoMs: 5000 }),
|
||||
).toBe(200);
|
||||
// Sem knobs, o "" recai no piso padrão 1200 (regressão zero).
|
||||
expect(calcularAtrasoHumano("")).toBe(ATRASO_MINIMO_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("leitor efetivo: linha de channel_knobs sobre os defaults", () => {
|
||||
it("sem linha, effectiveKnobs devolve os defaults", () => {
|
||||
const eff = effectiveKnobs(null);
|
||||
expect(eff.atrasoNotarMs).toBe(PACING_DEFAULTS.atrasoNotarMs);
|
||||
expect(eff.msPorCaractere).toBe(PACING_DEFAULTS.msPorCaractere);
|
||||
expect(eff.atrasoMinimoMs).toBe(PACING_DEFAULTS.atrasoMinimoMs);
|
||||
expect(eff.atrasoMaximoMs).toBe(PACING_DEFAULTS.atrasoMaximoMs);
|
||||
});
|
||||
|
||||
it("com linha, effectiveKnobs devolve o valor da conexão (e null volta ao default)", () => {
|
||||
const eff = effectiveKnobs(
|
||||
{
|
||||
atraso_notar_ms: 1200,
|
||||
ms_por_caractere: null,
|
||||
atraso_minimo_ms: 1500,
|
||||
atraso_maximo_ms: 6000,
|
||||
} as never,
|
||||
);
|
||||
expect(eff.atrasoNotarMs).toBe(1200);
|
||||
expect(eff.msPorCaractere).toBe(PACING_DEFAULTS.msPorCaractere); // null -> default
|
||||
expect(eff.atrasoMinimoMs).toBe(1500);
|
||||
expect(eff.atrasoMaximoMs).toBe(6000);
|
||||
});
|
||||
});
|
||||
@@ -162,10 +162,15 @@ describe("fiação — a espera humana é paga UMA vez por turno", () => {
|
||||
expect(doSendMessage).toBeGreaterThan(-1);
|
||||
const i = FONTE_INBOUND.indexOf("send: (finalBody: string) =>", doSendMessage);
|
||||
expect(i).toBeGreaterThan(-1);
|
||||
const janela = FONTE_INBOUND.slice(i, i + 1600);
|
||||
const janela = FONTE_INBOUND.slice(i, i + 2400);
|
||||
// Os dois convivem: o jitter é throttle anti-ban entre mensagens físicas, o
|
||||
// atraso humano é a pausa do turno. Perder o primeiro é afrouxar o anti-ban.
|
||||
expect(janela).toMatch(/jitter\s*[:=]\s*\(\)\s*=>\s*1200 \+ Math\.floor\(Math\.random\(\) \* 800\)/);
|
||||
// Desde a 0499 o jitter LÊ os knobs da conexão (throttleMs + jitterMaxMs,
|
||||
// defaults 1200/800) em vez do literal `1200 + rand*800` — mesma proteção,
|
||||
// agora configurável por número.
|
||||
expect(janela).toMatch(/const jitter = \(\) =>/);
|
||||
expect(janela).toMatch(/pacingDoTurno\?\.knobs\.throttleMs \?\? 1200/);
|
||||
expect(janela).toMatch(/Math\.floor\(Math\.random\(\) \* \(pacingDoTurno\?\.knobs\.jitterMaxMs \?\? 800\)\)/);
|
||||
expect(janela).toContain("sendInBubbles(texto, {");
|
||||
// Issue #654: a pausa humana saiu daqui. Ela era paga no `antesDaPrimeira`, que
|
||||
// rodava dentro do callback `send` — isto é, com o `pg_advisory_xact_lock` do
|
||||
|
||||
@@ -95,6 +95,7 @@ function db(
|
||||
return q;
|
||||
},
|
||||
in: () => q,
|
||||
not: () => q,
|
||||
limit: () => q,
|
||||
maybeSingle: async () => ({ data: contato, error: null }),
|
||||
then: (r: (v: { data: unknown; error: null }) => unknown) => {
|
||||
|
||||
@@ -124,6 +124,9 @@ function banco(linhas: Linha[]) {
|
||||
dentro = [col, vals];
|
||||
return q;
|
||||
},
|
||||
// `.not(col, "is", null)` do passo da transcrição (0497): este arquivo
|
||||
// não planta mensagem, então a tabela nunca casa e o filtro só precisa existir.
|
||||
not: () => q,
|
||||
limit: (n: number) => {
|
||||
teto = n;
|
||||
return q;
|
||||
|
||||
@@ -33,6 +33,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MAX_CONTATOS_EXAMINADOS,
|
||||
MAX_CONTATOS_POR_VARREDURA,
|
||||
MENSAGEM_REDIGIDA,
|
||||
NOTA_REDIGIDA,
|
||||
SUFIXO_ANONIMIZADO,
|
||||
type ClienteDaCascata,
|
||||
@@ -92,7 +93,7 @@ interface Linha {
|
||||
payload?: unknown;
|
||||
is_anonymized?: boolean;
|
||||
headline?: string;
|
||||
body?: string;
|
||||
body?: string | null;
|
||||
tool_calls?: unknown;
|
||||
social_identity?: unknown;
|
||||
next_action?: string | null;
|
||||
@@ -103,6 +104,7 @@ interface Linha {
|
||||
completed_at?: string | null;
|
||||
cancel_reason?: string | null;
|
||||
embedding?: unknown;
|
||||
media_derived_text?: string | null;
|
||||
}
|
||||
|
||||
interface Escrita {
|
||||
@@ -133,6 +135,7 @@ function banco(linhas: Linha[]) {
|
||||
filtros: Array<[string, unknown]>,
|
||||
dentro: [string, string[]] | null,
|
||||
faixas: Array<[string, string]> = [],
|
||||
naoNulos: string[] = [],
|
||||
): Linha[] =>
|
||||
linhas.filter((l) => {
|
||||
if ((l.id.split(":")[0] ?? "") !== tabela) return false;
|
||||
@@ -145,6 +148,12 @@ function banco(linhas: Linha[]) {
|
||||
const atual = (l as unknown as Record<string, string | undefined>)[col];
|
||||
if (atual === undefined || !(atual < val)) return false;
|
||||
}
|
||||
// `.not(col, "is", null)` do passo da transcrição (0497). Ignorá-lo
|
||||
// faria a mensagem já limpa voltar como resíduo e mascararia a idempotência.
|
||||
for (const col of naoNulos) {
|
||||
const atual = (l as unknown as Record<string, unknown>)[col];
|
||||
if (atual === null || atual === undefined) return false;
|
||||
}
|
||||
// O `.in()` da cascata vem em DUAS colunas — `id` no UPDATE das
|
||||
// atividades, `contact_id` na detecção em bloco. Um dublê que
|
||||
// ignorasse a coluna casaria as duas na errada e daria verde falso.
|
||||
@@ -162,6 +171,7 @@ function banco(linhas: Linha[]) {
|
||||
) => {
|
||||
const filtros: Array<[string, unknown]> = [];
|
||||
const faixas: Array<[string, string]> = [];
|
||||
const naoNulos: string[] = [];
|
||||
let dentro: [string, string[]] | null = null;
|
||||
let teto: number | null = null;
|
||||
const q: Record<string, unknown> = {
|
||||
@@ -177,6 +187,10 @@ function banco(linhas: Linha[]) {
|
||||
dentro = [col, vals];
|
||||
return q;
|
||||
},
|
||||
not: (col: string) => {
|
||||
naoNulos.push(col);
|
||||
return q;
|
||||
},
|
||||
select: () => q,
|
||||
order: () => q,
|
||||
limit: (n: number) => {
|
||||
@@ -184,7 +198,7 @@ function banco(linhas: Linha[]) {
|
||||
return q;
|
||||
},
|
||||
then: (r: (v: unknown) => unknown) => {
|
||||
let achadas = casar(filtros, dentro, faixas);
|
||||
let achadas = casar(filtros, dentro, faixas, naoNulos);
|
||||
if (teto !== null) achadas = achadas.slice(0, teto);
|
||||
if (modo === "update") {
|
||||
aplicar(tabela, patch, achadas);
|
||||
@@ -386,6 +400,7 @@ describe("a unidade que as duas bocas compartilham", () => {
|
||||
runsRedigidas: 0,
|
||||
estadosRedigidos: 0,
|
||||
socialIdentidadeRedigida: false,
|
||||
transcricoesRedigidas: 0,
|
||||
tabelas: [],
|
||||
falhas: [],
|
||||
};
|
||||
@@ -396,6 +411,7 @@ describe("a unidade que as duas bocas compartilham", () => {
|
||||
expect(houveRedacao({ ...vazio, runsRedigidas: 1 })).toBe(true);
|
||||
expect(houveRedacao({ ...vazio, estadosRedigidos: 1 })).toBe(true);
|
||||
expect(houveRedacao({ ...vazio, socialIdentidadeRedigida: true })).toBe(true);
|
||||
expect(houveRedacao({ ...vazio, transcricoesRedigidas: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("⭐ `tabelas` lista só o que foi tocado — não o literal das três", async () => {
|
||||
@@ -535,6 +551,73 @@ describe("a unidade que as duas bocas compartilham", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a transcrição da mídia sai junto (0497, triagem do #1988)", () => {
|
||||
// O banco redige o body da mensagem na virada de is_anonymized; a transcrição
|
||||
// (`media_derived_text`) ficava. A varredura é quem alcança a que o
|
||||
// `media-derive-worker` grava DEPOIS da anonimização — nenhum gatilho dispara ali.
|
||||
const audio = (sufixo: string, over: Partial<Linha> = {}): Linha => ({
|
||||
id: `messages:${sufixo}`,
|
||||
organization_id: ORG,
|
||||
contact_id: "contacts:a",
|
||||
body: MENSAGEM_REDIGIDA,
|
||||
media_derived_text: "oi, aqui é a Maria, meu CPF é 123",
|
||||
...over,
|
||||
});
|
||||
|
||||
it("⭐ mensagem anonimizada que guardou a transcrição: a varredura apaga", async () => {
|
||||
alvo = banco([contatoAnonimizado(), audio("1")]);
|
||||
|
||||
const r = await varrerRedacoesIncompletas(alvo.cliente);
|
||||
|
||||
expect(r.completados).toHaveLength(1);
|
||||
expect(r.completados[0]!.resultado.transcricoesRedigidas).toBe(1);
|
||||
expect(r.completados[0]!.resultado.tabelas).toContain("messages:media_derived_text");
|
||||
expect(alvo.linhas.find((l) => l.id === "messages:1")!.media_derived_text).toBeNull();
|
||||
});
|
||||
|
||||
it("⭐ poupa quem voltou a escrever, o vizinho e a outra org", async () => {
|
||||
alvo = banco([
|
||||
contatoAnonimizado(),
|
||||
// body de verdade: mensagem NOVA de quem voltou (religado pelo LID).
|
||||
audio("nova", { body: null, media_derived_text: "voltei, quero o orçamento" }),
|
||||
// mesmo body-sentinela, mas de OUTRO contato da mesma org, não anonimizado.
|
||||
audio("vizinho", { contact_id: "contacts:b" }),
|
||||
// mesmo contact_id em OUTRA org — só existe se algo já vazou.
|
||||
audio("outra-org", { organization_id: OUTRA_ORG }),
|
||||
]);
|
||||
|
||||
const r = await varrerRedacoesIncompletas(alvo.cliente);
|
||||
|
||||
expect(r.completados).toEqual([]);
|
||||
expect(alvo.linhas.find((l) => l.id === "messages:nova")!.media_derived_text).toBe("voltei, quero o orçamento");
|
||||
expect(alvo.linhas.find((l) => l.id === "messages:vizinho")!.media_derived_text).not.toBeNull();
|
||||
expect(alvo.linhas.find((l) => l.id === "messages:outra-org")!.media_derived_text).not.toBeNull();
|
||||
});
|
||||
|
||||
it("⭐ idempotente: a segunda passada não escreve nada", async () => {
|
||||
alvo = banco([contatoAnonimizado(), audio("1"), audio("2")]);
|
||||
|
||||
const primeira = await varrerRedacoesIncompletas(alvo.cliente);
|
||||
expect(primeira.completados[0]!.resultado.transcricoesRedigidas).toBe(2);
|
||||
const escritas = alvo.escritas.length;
|
||||
|
||||
const segunda = await varrerRedacoesIncompletas(alvo.cliente);
|
||||
|
||||
expect(segunda.completados).toEqual([]);
|
||||
expect(alvo.escritas.length).toBe(escritas);
|
||||
});
|
||||
|
||||
it("a rota (completarRedacaoDoContato) apaga pelo mesmo passo", async () => {
|
||||
alvo = banco([contatoAnonimizado(), audio("1")]);
|
||||
|
||||
const r = await completarRedacaoDoContato(alvo.cliente, { id: "contacts:a", organizationId: ORG });
|
||||
|
||||
expect(r.transcricoesRedigidas).toBe(1);
|
||||
expect(houveRedacao(r)).toBe(true);
|
||||
expect(alvo.linhas.find((l) => l.id === "messages:1")!.media_derived_text).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("o laço de retorno: a varredura deixa rastro por contato", () => {
|
||||
function requisicaoAutorizada() {
|
||||
return { headers: new Headers({ authorization: "Bearer segredo" }) } as never;
|
||||
|
||||
@@ -88,3 +88,14 @@ describe("todo montador de ferramentas do turno passa o escopo", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("o espelho de etapa recebe o escopo do agente publicado", () => {
|
||||
it("o turno não omite os funis ao chamar mirrorLeadStageToCrm", () => {
|
||||
const fonte = readFileSync(resolve(RAIZ, "lib/agent-engine/agent/inbound-turn.ts"), "utf8");
|
||||
const chamada = fonte.match(/const mirror = await mirrorLeadStageToCrm\([\s\S]*?\n\s*\}\);/);
|
||||
expect(chamada, "o turno não chama o espelho do CRM").not.toBeNull();
|
||||
expect(chamada?.[0], "o espelho ignoraria os funis autorizados na versão publicada").toMatch(
|
||||
/pipelineIds: agentConfig\.pipelineIds/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +84,10 @@ function canal(over: Partial<ChannelSession> = {}): ChannelSession {
|
||||
const KNOBS: PacingKnobs = {
|
||||
throttleMs: 1_200,
|
||||
jitterMaxMs: 800,
|
||||
atrasoNotarMs: 900,
|
||||
msPorCaractere: 22,
|
||||
atrasoMinimoMs: 1_200,
|
||||
atrasoMaximoMs: 7_500,
|
||||
windowStartHour: 7,
|
||||
windowEndHour: 22,
|
||||
respostaStartHour: 7,
|
||||
@@ -113,6 +117,8 @@ function itemDePacing(over: Partial<PacingKnobsItem> = {}): PacingKnobsItem {
|
||||
defaults: KNOBS,
|
||||
bounds: {
|
||||
intervalMaxMs: 600_000,
|
||||
msPorCaractereMax: 200,
|
||||
atrasoMaximoMsMax: 60_000,
|
||||
hourLastStart: 23,
|
||||
hourEnd: 24,
|
||||
daily_limit: { min: 1, max: 2_000 },
|
||||
|
||||
@@ -319,6 +319,40 @@ describe("PUT /api/v1/ai/pacing — janela de resposta (0495)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/v1/ai/pacing — atraso humano (0499): mínimo acima do máximo é 422, não 500", () => {
|
||||
it("mínimo sozinho acima do teto padrão (7500) é recusado, em vez de ignorado pelo clamp", async () => {
|
||||
authOk();
|
||||
const db = makeDb(null);
|
||||
const { PUT } = await import("@/app/api/v1/ai/pacing/route");
|
||||
const res = await PUT(put(corpoDaTela({ atraso_minimo_ms: 9000 })));
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(db.upserts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("mínimo e máximo gravados invertidos são recusados antes do CHECK do banco", async () => {
|
||||
authOk();
|
||||
const db = makeDb(null);
|
||||
const { PUT } = await import("@/app/api/v1/ai/pacing/route");
|
||||
const res = await PUT(put(corpoDaTela({ atraso_minimo_ms: 5000, atraso_maximo_ms: 3000 })));
|
||||
const corpo = await res.json();
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(corpo.error.message).toContain("5000");
|
||||
expect(db.upserts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("par coerente continua sendo gravado (a guarda não virou bloqueio)", async () => {
|
||||
authOk();
|
||||
const db = makeDb(null);
|
||||
const { PUT } = await import("@/app/api/v1/ai/pacing/route");
|
||||
const res = await PUT(put(corpoDaTela({ atraso_minimo_ms: 3000, atraso_maximo_ms: 3000 })));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(db.knobs).toMatchObject({ atraso_minimo_ms: 3000, atraso_maximo_ms: 3000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("A TELA e o texto que ela promete", () => {
|
||||
it("o texto de ajuda não promete rejuvenescer um número cuja data já está salva", () => {
|
||||
const sheet = readFileSync(
|
||||
|
||||
Reference in New Issue
Block a user