mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
feat(G3-02): first-class AI assignee — assignee_kind + bot veto + INB-06a guard [gov-loop]
Migration 0032: assignee_kind coherence CHECK + backfill; handoff writes reason=handoff event (kind ai->user or queue); ai-response-worker vetoes bot when kind='user'. INB-06a forward-fix: fn_conversation_assign validates dest membership via fn_member_role_in_org (revoke execute from anon — closes the cross-org enumeration leak the verifier caught). Ratchet flip: 1 GAP(G3) it.fails in gov-6-ai-handoff.test.ts flipped (DESKCOMM_GOV_INVARIANTS_EDIT=1). Verified-by: gov-verifier PASS 2026-07-17T08:38:44-0300 (repaired 1 round)
This commit is contained in:
@@ -102,17 +102,20 @@ pipeline do bot vetado deterministicamente (mesma família de guard de
|
||||
`force_human`/`bot_silenced_until`).
|
||||
|
||||
```sql
|
||||
-- RASCUNHO (migration real em G3-02)
|
||||
-- Migration real: 0032_conversation_assignee_kind (G3-02)
|
||||
alter table conversations
|
||||
add column if not exists assignee_kind text
|
||||
check (assignee_kind in ('user','ai'));
|
||||
|
||||
-- Coerência com assigned_to_user_id: null = sem atendente (fila).
|
||||
-- Coerência com assigned_to_user_id em FORMA DE IMPLICAÇÃO (acceptance G3-02):
|
||||
-- kind='user' ⇒ dono humano; kind='ai' ⇒ sem dono; kind null é livre — escritas
|
||||
-- legadas que não conhecem a coluna (ex.: PATCH de status) continuam válidas e
|
||||
-- a semântica forte chega pelos caminhos canônicos (fn_conversation_assign).
|
||||
alter table conversations
|
||||
add constraint conversations_assignee_kind_coherence check (
|
||||
(assignee_kind = 'user' and assigned_to_user_id is not null) or
|
||||
(assignee_kind = 'ai' and assigned_to_user_id is null) or
|
||||
(assignee_kind is null and assigned_to_user_id is null)
|
||||
(assignee_kind is null)
|
||||
);
|
||||
|
||||
-- Backfill (na migration, ANTES da constraint — doutrina de migrations §8):
|
||||
|
||||
@@ -35,6 +35,7 @@ export type SkipReason =
|
||||
| "kb_version_missing"
|
||||
| "contact_blocked"
|
||||
| "force_human"
|
||||
| "assigned_to_human"
|
||||
| "window_24h_expired"
|
||||
| "budget_throttled"
|
||||
| "silenced_post_handoff"
|
||||
|
||||
@@ -1267,6 +1267,7 @@ export type Database = {
|
||||
Row: {
|
||||
assigned_at: string | null;
|
||||
assigned_to_user_id: string | null;
|
||||
assignee_kind: string | null;
|
||||
bot_silenced_until: string | null;
|
||||
channel: string;
|
||||
channel_session_id: string;
|
||||
@@ -1295,6 +1296,7 @@ export type Database = {
|
||||
Insert: {
|
||||
assigned_at?: string | null;
|
||||
assigned_to_user_id?: string | null;
|
||||
assignee_kind?: string | null;
|
||||
bot_silenced_until?: string | null;
|
||||
channel?: string;
|
||||
channel_session_id: string;
|
||||
@@ -1323,6 +1325,7 @@ export type Database = {
|
||||
Update: {
|
||||
assigned_at?: string | null;
|
||||
assigned_to_user_id?: string | null;
|
||||
assignee_kind?: string | null;
|
||||
bot_silenced_until?: string | null;
|
||||
channel?: string;
|
||||
channel_session_id?: string;
|
||||
@@ -2743,6 +2746,7 @@ export type Database = {
|
||||
Returns: {
|
||||
assigned_at: string | null;
|
||||
assigned_to_user_id: string | null;
|
||||
assignee_kind: string | null;
|
||||
bot_silenced_until: string | null;
|
||||
channel: string;
|
||||
channel_session_id: string;
|
||||
@@ -2803,6 +2807,10 @@ export type Database = {
|
||||
};
|
||||
Returns: undefined;
|
||||
};
|
||||
fn_member_role_in_org: {
|
||||
Args: { p_org: string; p_user: string };
|
||||
Returns: string;
|
||||
};
|
||||
fn_publish_ai_agent_version: {
|
||||
Args: { p_agent_id: string; p_org_id: string; p_version_id: string };
|
||||
Returns: {
|
||||
|
||||
+55
-11
@@ -7,7 +7,10 @@
|
||||
* - event_log INSERT event_type='ai.handoff_triggered'
|
||||
* - Realtime broadcast `org:<org>:queue` event=handoff_pending
|
||||
* - api_audit_log action='ai.handoff_triggered'
|
||||
* - conversations.assigned_to_user_id round-robin entre membros agent+ ativos
|
||||
* - G3-02: handoff é reassignment auditado — com elegível (round-robin agent+),
|
||||
* fn_conversation_assign move kind ai→'user' + evento reason='handoff' na
|
||||
* MESMA transação; sem elegível, conversa vai à fila (assigned null, kind
|
||||
* null) e o evento reason='handoff' é gravado do mesmo jeito.
|
||||
*
|
||||
* Nenhum mirror REST. Wave 4 introduz como tool MCP only.
|
||||
*/
|
||||
@@ -15,6 +18,7 @@ import { z } from "zod";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
import { triggerHandoff } from "@/lib/ai/handoff/orchestrator";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { McpToolDefinition } from "../types";
|
||||
|
||||
const inputShape = {
|
||||
@@ -103,23 +107,63 @@ export const crmRequestHumanHandoff: McpToolDefinition<typeof inputShape> = {
|
||||
|
||||
let assignedUserId: string | null = null;
|
||||
if (result.triggered) {
|
||||
assignedUserId = await pickRoundRobinAssignee(
|
||||
const picked = await pickRoundRobinAssignee(
|
||||
ctx.supabase,
|
||||
ctx.organizationId,
|
||||
input.suggested_assignee_role ?? "agent",
|
||||
);
|
||||
if (assignedUserId) {
|
||||
const { error: assignErr } = await ctx.supabase
|
||||
if (picked) {
|
||||
// G3-02: reassignment auditado — UPDATE (kind ai→'user') + evento
|
||||
// reason='handoff' na MESMA transação (fn_conversation_assign, 0031/0032).
|
||||
// Service role: auth.uid() null → changed_by null (sistema).
|
||||
const { data: rows, error: assignErr } = await ctx.supabase.rpc(
|
||||
"fn_conversation_assign",
|
||||
{
|
||||
p_organization_id: ctx.organizationId,
|
||||
p_conversation_id: input.conversation_id,
|
||||
p_to_user_id: picked,
|
||||
p_reason: "handoff",
|
||||
p_enforce_expected: false,
|
||||
},
|
||||
);
|
||||
if (assignErr || !Array.isArray(rows) || rows.length === 0) {
|
||||
logger.warn("[mcp.handoff] assignment failed", {
|
||||
conversation_id: input.conversation_id,
|
||||
error: assignErr?.message ?? "0 rows (conversation not found)",
|
||||
});
|
||||
} else {
|
||||
assignedUserId = picked;
|
||||
}
|
||||
}
|
||||
if (!assignedUserId) {
|
||||
// Fila (roteamento vigente sem elegível): sem dono, kind sai de 'ai' →
|
||||
// null; o handoff continua auditado (evento reason='handoff', from/to null).
|
||||
const { error: kindErr } = await ctx.supabase
|
||||
.from("conversations")
|
||||
.update({
|
||||
assigned_to_user_id: assignedUserId,
|
||||
assigned_at: new Date().toISOString(),
|
||||
})
|
||||
.update({ assignee_kind: null })
|
||||
.eq("id", input.conversation_id)
|
||||
.eq("organization_id", ctx.organizationId);
|
||||
if (assignErr) {
|
||||
console.error("[mcp.handoff] assignment failed", assignErr.message);
|
||||
assignedUserId = null;
|
||||
if (kindErr) {
|
||||
logger.warn("[mcp.handoff] assignee_kind clear failed", {
|
||||
conversation_id: input.conversation_id,
|
||||
error: kindErr.message,
|
||||
});
|
||||
}
|
||||
const { error: eventErr } = await ctx.supabase
|
||||
.from("conversation_assignment_events")
|
||||
.insert({
|
||||
organization_id: ctx.organizationId,
|
||||
conversation_id: input.conversation_id,
|
||||
from_user_id: null,
|
||||
to_user_id: null,
|
||||
changed_by: null,
|
||||
reason: "handoff",
|
||||
});
|
||||
if (eventErr) {
|
||||
logger.warn("[mcp.handoff] handoff event insert failed", {
|
||||
conversation_id: input.conversation_id,
|
||||
error: eventErr.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -303,8 +303,14 @@
|
||||
"priority": 20,
|
||||
"lane": "core",
|
||||
"kind": "build",
|
||||
"passes": false,
|
||||
"verification": null
|
||||
"passes": true,
|
||||
"verification": {
|
||||
"verdict": "PASS",
|
||||
"by": "gov-verifier",
|
||||
"at": "2026-07-17T08:38:44-0300",
|
||||
"commit": "self",
|
||||
"note": "repaired-1-round: anon leak on fn_member_role_in_org closed, re-verified fresh"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "G3-03",
|
||||
|
||||
@@ -218,3 +218,25 @@
|
||||
(schema_migrations parou na 0027; 0031 aplicada só pro screenshot).
|
||||
- Próxima sessão: G3-02 (assignee_kind) destravou; G3-03 também elegível — a
|
||||
regra manda menor priority ⇒ G3-02 (prio 20).
|
||||
|
||||
## 2026-07-17 — sessão 14 do loop (core) — G3-02 (1 rodada de reparo)
|
||||
|
||||
- G3-02 (assignee_kind): migration 0032 em tripla — coluna assignee_kind +
|
||||
CHECK de coerência (forma de implicação, verbatim do acceptance) + backfill
|
||||
antes da constraint. Handoff grava evento reason=handoff (kind ai→user com
|
||||
elegível / fila sem elegível). Veto determinístico do bot no ai-response-worker
|
||||
(kind='user' ⇒ skip 'assigned_to_human'). Forward-fix INB-06a: guard de
|
||||
membership dentro de fn_conversation_assign via helper fn_member_role_in_org.
|
||||
- FAIL na 1ª verificação: fn_member_role_in_org (SECURITY DEFINER) executável
|
||||
por anon (grant herdado de ALTER DEFAULT PRIVILEGES do baseline) + ramo
|
||||
auth.uid() null respondia a request anônimo → enumeração de role cross-tenant
|
||||
sem autenticar. Reparo (1 rodada): revoke execute from anon explícito nas 2
|
||||
cópias (migration+baseline) + invariante que prova permission denied SOB role
|
||||
anon real + service_role ainda servido. Re-verificação FRESCA: PASS, hash OK.
|
||||
- 47 invariantes + 135 unit verdes. database.types.ts editado à mão (gen do
|
||||
container poluiria Functions com extensões).
|
||||
- INB-07 aberto: varredura do verifier achou 6 SECURITY DEFINER de ESCRITA
|
||||
anon-executáveis pré-existentes (fn_upsert_wa_*, emit_event, fn_log_event,
|
||||
fn_audit_log_row, fn_mark_conversation_message) — gap do baseline, não da
|
||||
G3-02. Os helpers RLS caller-scoped (fn_user_*) NÃO vazam (probe: anon → null).
|
||||
- Próxima sessão: G3-03 (dono do lead no kanban, prio 30) — elegível.
|
||||
|
||||
@@ -4364,3 +4364,138 @@ $$;
|
||||
revoke all on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) from public;
|
||||
grant execute on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean)
|
||||
to authenticated, service_role;
|
||||
|
||||
-- ---- assignee_kind + guard de membership na fn_conversation_assign (migration 0032) ----
|
||||
-- G3-02 (gov-loop): IA como assignee de 1ª classe (spec 13 §3.2). Coluna
|
||||
-- conversations.assignee_kind ('user'|'ai') + CHECK de coerência em forma de
|
||||
-- implicação (kind='user' ⇒ dono humano; kind='ai' ⇒ sem dono; kind null livre
|
||||
-- pra escritas legadas). Backfill ANTES da constraint (auto-curativo em clones).
|
||||
-- Forward-fix INB-06a: fn_conversation_assign valida DENTRO da função que o
|
||||
-- destino é membro ativo agent+ da org (via fn_member_role_in_org, SECURITY
|
||||
-- DEFINER) e mantém assignee_kind coerente em claim/transfer/release/handoff.
|
||||
-- fn_member_role_in_org é executável APENAS por authenticated (responde só a
|
||||
-- membro ativo da org) e service_role (auth.uid() null); anon tem EXECUTE
|
||||
-- revogado EXPLICITAMENTE — o default privilege do Supabase concede EXECUTE a
|
||||
-- anon em toda função nova e o JWT anon também tem uid null.
|
||||
|
||||
alter table public.conversations
|
||||
add column if not exists assignee_kind text
|
||||
check (assignee_kind in ('user','ai'));
|
||||
|
||||
update public.conversations
|
||||
set assignee_kind = 'user'
|
||||
where assigned_to_user_id is not null
|
||||
and assignee_kind is distinct from 'user';
|
||||
|
||||
update public.conversations
|
||||
set assignee_kind = null
|
||||
where assigned_to_user_id is null
|
||||
and assignee_kind = 'user';
|
||||
|
||||
update public.conversations
|
||||
set assignee_kind = 'ai'
|
||||
where status = 'ai_handling'
|
||||
and assigned_to_user_id is null
|
||||
and assignee_kind is distinct from 'ai';
|
||||
|
||||
alter table public.conversations
|
||||
drop constraint if exists conversations_assignee_kind_coherence;
|
||||
alter table public.conversations
|
||||
add constraint conversations_assignee_kind_coherence check (
|
||||
(assignee_kind = 'user' and assigned_to_user_id is not null) or
|
||||
(assignee_kind = 'ai' and assigned_to_user_id is null) or
|
||||
(assignee_kind is null)
|
||||
);
|
||||
|
||||
create or replace function public.fn_member_role_in_org(p_user uuid, p_org uuid)
|
||||
returns text
|
||||
language sql stable security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
select uo.role
|
||||
from public.user_organizations uo
|
||||
where uo.user_id = p_user
|
||||
and uo.organization_id = p_org
|
||||
and uo.revoked_at is null
|
||||
and (
|
||||
auth.uid() is null
|
||||
or exists (
|
||||
select 1 from public.user_organizations me
|
||||
where me.user_id = auth.uid()
|
||||
and me.organization_id = p_org
|
||||
and me.revoked_at is null
|
||||
)
|
||||
)
|
||||
limit 1;
|
||||
$$;
|
||||
|
||||
revoke all on function public.fn_member_role_in_org(uuid, uuid) from public;
|
||||
-- O revoke from public NÃO cobre o grant DIRETO que anon carrega via
|
||||
-- ALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anon (padrão
|
||||
-- Supabase). Sem esta linha, o PostgREST expõe a função como RPC pública
|
||||
-- (anon key vai pro browser) e o ramo auth.uid() null responde a request
|
||||
-- anônimo — enumeração de membership/role de qualquer tenant.
|
||||
revoke execute on function public.fn_member_role_in_org(uuid, uuid) from anon;
|
||||
grant execute on function public.fn_member_role_in_org(uuid, uuid)
|
||||
to authenticated, service_role;
|
||||
|
||||
create or replace function public.fn_conversation_assign(
|
||||
p_organization_id uuid,
|
||||
p_conversation_id uuid,
|
||||
p_to_user_id uuid,
|
||||
p_reason text,
|
||||
p_expected_assignee uuid default null,
|
||||
p_enforce_expected boolean default false
|
||||
) returns setof public.conversations
|
||||
language plpgsql
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_from uuid;
|
||||
v_conv public.conversations%rowtype;
|
||||
begin
|
||||
if p_to_user_id is not null then
|
||||
if coalesce(public.fn_member_role_in_org(p_to_user_id, p_organization_id), 'none')
|
||||
not in ('agent','manager','admin') then
|
||||
raise exception 'assignee_not_eligible_member'
|
||||
using hint = 'target must be an active agent+ member of the organization';
|
||||
end if;
|
||||
end if;
|
||||
|
||||
select assigned_to_user_id into v_from
|
||||
from public.conversations
|
||||
where id = p_conversation_id
|
||||
and organization_id = p_organization_id
|
||||
for update;
|
||||
|
||||
if not found then
|
||||
return;
|
||||
end if;
|
||||
|
||||
if p_enforce_expected and v_from is distinct from p_expected_assignee then
|
||||
return;
|
||||
end if;
|
||||
|
||||
update public.conversations
|
||||
set assigned_to_user_id = p_to_user_id,
|
||||
assigned_at = case when p_to_user_id is null then null else now() end,
|
||||
assignee_kind = case when p_to_user_id is null then null else 'user' end,
|
||||
status = case when p_to_user_id is null then 'open' else 'claimed' end,
|
||||
status_changed_at = now(),
|
||||
unread_count_for_assignee = 0,
|
||||
updated_at = now()
|
||||
where id = p_conversation_id
|
||||
returning * into v_conv;
|
||||
|
||||
insert into public.conversation_assignment_events
|
||||
(organization_id, conversation_id, from_user_id, to_user_id, changed_by, reason)
|
||||
values
|
||||
(p_organization_id, p_conversation_id, v_from, p_to_user_id, auth.uid(), p_reason);
|
||||
|
||||
return next v_conv;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) from public;
|
||||
grant execute on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean)
|
||||
to authenticated, service_role;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
-- 0032_conversation_assignee_kind
|
||||
-- G3-02 (gov-loop): IA como assignee de 1ª classe (spec 13 §3.2) + forward-fix
|
||||
-- INB-06a na fn_conversation_assign (migration 0031).
|
||||
--
|
||||
-- 1. conversations.assignee_kind ('user'|'ai') desambigua quem atende: humano
|
||||
-- (assigned_to_user_id) ou o bot (status legado 'ai_handling'). CHECK de
|
||||
-- coerência em forma de implicação (acceptance G3-02):
|
||||
-- kind='user' ⇒ assigned_to_user_id not null;
|
||||
-- kind='ai' ⇒ assigned_to_user_id null;
|
||||
-- kind null ⇒ sem exigência (escritas legadas que não conhecem a coluna
|
||||
-- continuam válidas — a semântica forte chega pelos caminhos canônicos).
|
||||
-- Backfill ANTES da constraint (doutrina de migrations §8).
|
||||
-- 2. fn_member_role_in_org(p_user, p_org): helper SECURITY DEFINER (família de
|
||||
-- fn_user_role_in_org) — a RLS de user_organizations só mostra o próprio
|
||||
-- membership a um agent, então a validação de destino DENTRO da função
|
||||
-- invoker precisa deste bypass controlado. Executável APENAS por
|
||||
-- authenticated (responde só quando o caller é membro ativo da org) e
|
||||
-- service_role (worker/handoff, auth.uid() null). anon tem EXECUTE
|
||||
-- revogado EXPLICITAMENTE: o ALTER DEFAULT PRIVILEGES do Supabase concede
|
||||
-- EXECUTE a anon em toda função nova de public, e o JWT anon também tem
|
||||
-- auth.uid() null — sem o revoke, o PostgREST exporia a função como RPC
|
||||
-- pública e qualquer um enumeraria membership/role cross-org.
|
||||
-- 3. fn_conversation_assign v2 (INB-06a): destino usuário DEVE ser membro
|
||||
-- ativo agent+ da MESMA org — validado DENTRO da função (probe H8 do
|
||||
-- verifier: rpc direto atribuía a viewer/usuário de outra org). Também
|
||||
-- passa a manter assignee_kind coerente ('user' quando ganha dono, null
|
||||
-- quando volta à fila) em claim/transfer/release/handoff.
|
||||
--
|
||||
-- Idempotente, portável em psql puro (sem BEGIN/COMMIT, sem temp tables).
|
||||
|
||||
-- A. Coluna
|
||||
alter table public.conversations
|
||||
add column if not exists assignee_kind text
|
||||
check (assignee_kind in ('user','ai'));
|
||||
|
||||
-- B. Backfill (ANTES da constraint — corrige qualquer banco de clone):
|
||||
-- dono humano ⇒ 'user'; 'user' órfão (sem dono) ⇒ null; ai_handling sem
|
||||
-- dono ⇒ 'ai'.
|
||||
update public.conversations
|
||||
set assignee_kind = 'user'
|
||||
where assigned_to_user_id is not null
|
||||
and assignee_kind is distinct from 'user';
|
||||
|
||||
update public.conversations
|
||||
set assignee_kind = null
|
||||
where assigned_to_user_id is null
|
||||
and assignee_kind = 'user';
|
||||
|
||||
update public.conversations
|
||||
set assignee_kind = 'ai'
|
||||
where status = 'ai_handling'
|
||||
and assigned_to_user_id is null
|
||||
and assignee_kind is distinct from 'ai';
|
||||
|
||||
-- C. Constraint de coerência (drop+add — re-aplicável)
|
||||
alter table public.conversations
|
||||
drop constraint if exists conversations_assignee_kind_coherence;
|
||||
alter table public.conversations
|
||||
add constraint conversations_assignee_kind_coherence check (
|
||||
(assignee_kind = 'user' and assigned_to_user_id is not null) or
|
||||
(assignee_kind = 'ai' and assigned_to_user_id is null) or
|
||||
(assignee_kind is null)
|
||||
);
|
||||
|
||||
-- D. Helper: role de QUALQUER membro da org (SECURITY DEFINER). Caller
|
||||
-- authenticated precisa ser membro ativo da org; auth.uid() null é o path
|
||||
-- do sistema (service_role) — e SÓ dele, porque anon (que também tem uid
|
||||
-- null) tem EXECUTE revogado explicitamente abaixo.
|
||||
create or replace function public.fn_member_role_in_org(p_user uuid, p_org uuid)
|
||||
returns text
|
||||
language sql stable security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
select uo.role
|
||||
from public.user_organizations uo
|
||||
where uo.user_id = p_user
|
||||
and uo.organization_id = p_org
|
||||
and uo.revoked_at is null
|
||||
and (
|
||||
auth.uid() is null
|
||||
or exists (
|
||||
select 1 from public.user_organizations me
|
||||
where me.user_id = auth.uid()
|
||||
and me.organization_id = p_org
|
||||
and me.revoked_at is null
|
||||
)
|
||||
)
|
||||
limit 1;
|
||||
$$;
|
||||
|
||||
revoke all on function public.fn_member_role_in_org(uuid, uuid) from public;
|
||||
-- O revoke from public NÃO cobre o grant DIRETO que anon carrega via
|
||||
-- ALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anon (padrão
|
||||
-- Supabase). Sem esta linha, o PostgREST expõe a função como RPC pública
|
||||
-- (anon key vai pro browser) e o ramo auth.uid() null responde a request
|
||||
-- anônimo — enumeração de membership/role de qualquer tenant.
|
||||
revoke execute on function public.fn_member_role_in_org(uuid, uuid) from anon;
|
||||
grant execute on function public.fn_member_role_in_org(uuid, uuid)
|
||||
to authenticated, service_role;
|
||||
|
||||
-- E. fn_conversation_assign v2 — guard INB-06a + manutenção de assignee_kind.
|
||||
create or replace function public.fn_conversation_assign(
|
||||
p_organization_id uuid,
|
||||
p_conversation_id uuid,
|
||||
p_to_user_id uuid, -- null = release (volta à fila)
|
||||
p_reason text, -- claim|transfer|release|routing|handoff (CHECK da tabela)
|
||||
p_expected_assignee uuid default null,
|
||||
p_enforce_expected boolean default false
|
||||
) returns setof public.conversations
|
||||
language plpgsql
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_from uuid;
|
||||
v_conv public.conversations%rowtype;
|
||||
begin
|
||||
-- INB-06a: destino usuário DEVE ser membro ativo agent+ da mesma org —
|
||||
-- validado aqui (não só na rota); rpc direto não atribui a viewer/estranho.
|
||||
if p_to_user_id is not null then
|
||||
if coalesce(public.fn_member_role_in_org(p_to_user_id, p_organization_id), 'none')
|
||||
not in ('agent','manager','admin') then
|
||||
raise exception 'assignee_not_eligible_member'
|
||||
using hint = 'target must be an active agent+ member of the organization';
|
||||
end if;
|
||||
end if;
|
||||
|
||||
select assigned_to_user_id into v_from
|
||||
from public.conversations
|
||||
where id = p_conversation_id
|
||||
and organization_id = p_organization_id
|
||||
for update;
|
||||
|
||||
if not found then
|
||||
return; -- inexistente / fora do escopo RLS → 0 rows
|
||||
end if;
|
||||
|
||||
if p_enforce_expected and v_from is distinct from p_expected_assignee then
|
||||
return; -- optimistic lock perdeu (spec 04 §9.2) → rota devolve 409
|
||||
end if;
|
||||
|
||||
update public.conversations
|
||||
set assigned_to_user_id = p_to_user_id,
|
||||
assigned_at = case when p_to_user_id is null then null else now() end,
|
||||
-- G3-02: quem atende é 'user' ou volta à fila (null); 'ai' nunca sai daqui.
|
||||
assignee_kind = case when p_to_user_id is null then null else 'user' end,
|
||||
status = case when p_to_user_id is null then 'open' else 'claimed' end,
|
||||
status_changed_at = now(),
|
||||
unread_count_for_assignee = 0, -- G3-01 acceptance 5: re-zera pro novo dono
|
||||
updated_at = now()
|
||||
where id = p_conversation_id
|
||||
returning * into v_conv;
|
||||
|
||||
insert into public.conversation_assignment_events
|
||||
(organization_id, conversation_id, from_user_id, to_user_id, changed_by, reason)
|
||||
values
|
||||
(p_organization_id, p_conversation_id, v_from, p_to_user_id, auth.uid(), p_reason);
|
||||
|
||||
return next v_conv;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) from public;
|
||||
grant execute on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean)
|
||||
to authenticated, service_role;
|
||||
@@ -35,6 +35,7 @@ Migrations applied to Supabase project `rrydmwnporysaiysiztn` (sa-east-1, Postgr
|
||||
| `20260706210000` | `0027_whatsapp_conversation_unification` | Bugfix de governança de conversas WhatsApp. Causa-raiz: contatos @lid sem unique key + resolução check-then-act, e o WAHA emitindo `message`+`message.any` por mensagem → 1 pessoa virava N contatos/conversas (medido: 1 lid = 12 contatos). Adiciona coluna gerada `contacts.wa_identity` (`phone:+E164`/`lid:<digits>`), faz merge idempotente do histórico duplicado repontando todas as FKs (usa `is_merged_into` como mapa, sem temp tables → portável em psql), cria `uniq_contacts_org_wa_identity` + `uniq_conversations_1to1_per_contact_session` (a antiga unique incluía group_chat_id NULL, que no Postgres não protege 1:1), e as funções de upsert atômico `fn_upsert_wa_contact`/`fn_upsert_wa_conversation`/`fn_mark_conversation_message` que a app passa a usar (lib/waha/ingest.ts) no lugar do check-then-act. Também corrige mapeamento de `type` WAHA→CRM (`chat`→`text` etc.) que fazia mensagens reais violarem messages_type_check e sumirem. |
|
||||
| `20260716120000` | `0030_config_rls_role_policies` | G2-03 (gov-loop): RLS por role nas tabelas de config, fechando no banco o que a G2-01 fechou na API (spec 13 §4; auditoria em §4.1). crm_pipelines/crm_stages: policy ALL org-flat vira SELECT org + WRITE manager+ (`fn_role_at_least`, padrão de api_tokens/merge_queue) — agent deixa de escrever config de pipeline. conversations: WRITE vira agent+ (viewer é read-only); SELECT permanece org-flat com a mesma expressão (escopo own/unassigned é G4-01). NNNN pula 0028/0029 (ocupados nas branches vendaval/F2-*). Sem mudança de contrato — database.types.ts intocado. |
|
||||
| `20260717120000` | `0031_conversation_assignment_events` | G3-01 (gov-loop): conversation_assignment_events (spec 13 §3.1 — org, conversation, from/to, changed_by, reason claim\|transfer\|release\|routing\|handoff; RLS org SELECT+INSERT, append-only sem UPDATE/DELETE, índice por conversa) + fn_conversation_assign (SECURITY INVOKER: SELECT FOR UPDATE + UPDATE condicional de assigned_to_user_id + INSERT do evento na MESMA transação; 0 rows = lock perdeu → rota 409; unread_count_for_assignee re-zerado; changed_by = auth.uid(), nunca input do caller). Rotas claim/release migram pra função; rota transfer nova. |
|
||||
| `20260717150000` | `0032_conversation_assignee_kind` | G3-02 (gov-loop): IA como assignee de 1ª classe (spec 13 §3.2) — conversations.assignee_kind ('user'\|'ai') com CHECK de coerência em implicação (kind='user' ⇒ assigned_to_user_id not null; kind='ai' ⇒ null; kind null livre p/ escritas legadas), backfill ANTES da constraint (assigned ⇒ 'user'; status='ai_handling' sem dono ⇒ 'ai'). Forward-fix INB-06a: fn_conversation_assign valida DENTRO da função que o destino é membro ativo agent+ da MESMA org (helper novo fn_member_role_in_org, SECURITY DEFINER — RLS de user_organizations não mostra membership alheio a agent) e passa a manter assignee_kind coerente em claim/transfer/release/handoff. Handoff IA→humano (lib/mcp/tools/handoff.ts) vira reassignment auditado reason='handoff'. |
|
||||
|
||||
## Reproducibility
|
||||
|
||||
|
||||
@@ -28,10 +28,10 @@ describe("eixo 6 — handoff IA→humano", () => {
|
||||
expect(def).toContain("ai_handling");
|
||||
});
|
||||
|
||||
// GAP(G6): handoff ainda não é reassignment auditado de 1ª classe — a
|
||||
// coluna conversations.assignee_kind ('user'|'ai', spec 13 §3) não existe;
|
||||
// quem atende (humano vs IA) segue ambíguo entre status e ai_handling.
|
||||
it.fails("conversations.assignee_kind ('user'|'ai') existe (spec 13 §3)", () => {
|
||||
// G3-02 fechou o GAP: assignee_kind ('user'|'ai') existe (migration 0032);
|
||||
// handoff IA→humano é reassignment auditado (reason='handoff') e kind='user'
|
||||
// veta o bot deterministicamente (workers/ai-response-worker.ts).
|
||||
it("conversations.assignee_kind ('user'|'ai') existe (spec 13 §3)", () => {
|
||||
expect(columnExists("conversations", "assignee_kind")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
GOV_AGENT_A,
|
||||
GOV_ORG,
|
||||
GOV_SESSION,
|
||||
GOV_VIEWER,
|
||||
countAs,
|
||||
lastLine,
|
||||
seedGov,
|
||||
sql,
|
||||
} from "./gov-helpers";
|
||||
|
||||
/**
|
||||
* Eixo 6 — G3-02: assignee_kind ('user'|'ai') + guard de membership na
|
||||
* fn_conversation_assign (migration 0032, spec 13 §3.2; forward-fix INB-06a).
|
||||
*
|
||||
* Invariantes:
|
||||
* - CHECK de coerência: kind='user' exige dono humano; kind='ai' exige sem dono;
|
||||
* - fn_conversation_assign mantém assignee_kind ('user' no claim/handoff,
|
||||
* null no release) e grava evento reason='handoff';
|
||||
* - INB-06a: rpc direto NÃO atribui a viewer nem a usuário de outra org
|
||||
* (raise assignee_not_eligible_member DENTRO da função — probe H8);
|
||||
* - fn_member_role_in_org NEGA anon (revoke explícito de EXECUTE — o default
|
||||
* privilege do Supabase concede EXECUTE a anon em toda função nova de
|
||||
* public, e o JWT anon também tem auth.uid() null; sem o revoke, a anon
|
||||
* key pública enumeraria membership/role cross-org via RPC do PostgREST).
|
||||
*/
|
||||
|
||||
// Fixture própria (namespace eeeeeeee) — não toca nas conversas dos outros arquivos.
|
||||
const AK_CONTACT = "eeeeeeee-3333-4000-8000-000000000001";
|
||||
const AK_CONV = "eeeeeeee-4444-4000-8000-000000000001";
|
||||
// Usuário de OUTRA org (org B) para a probe cross-org do INB-06a.
|
||||
const AK_ORG_B = "eeeeeeee-0000-4000-8000-000000000002";
|
||||
const AK_USER_ORG_B = "eeeeeeee-1111-4000-8000-000000000002";
|
||||
|
||||
function assignAs(userId: string, args: string): number {
|
||||
return countAs(
|
||||
userId,
|
||||
`select count(*) from public.fn_conversation_assign(
|
||||
'${GOV_ORG}'::uuid, '${AK_CONV}'::uuid, ${args})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Executa o assign esperando erro; devolve o stderr do psql (mensagem do raise). */
|
||||
function assignRejected(userId: string, args: string): string {
|
||||
try {
|
||||
assignAs(userId, args);
|
||||
return "";
|
||||
} catch (err) {
|
||||
return (err as { stderr?: string }).stderr ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
function convState(): string {
|
||||
return lastLine(
|
||||
sql(
|
||||
`select coalesce(assignee_kind, 'null') || '|' || coalesce(assigned_to_user_id::text, 'null')
|
||||
from public.conversations where id = '${AK_CONV}';`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
seedGov();
|
||||
sql(`
|
||||
insert into auth.users (id, email)
|
||||
values ('${AK_USER_ORG_B}', 'gov-agent-org-b@invariant.test')
|
||||
on conflict do nothing;
|
||||
insert into public.organizations (id, slug, legal_name, display_name)
|
||||
values ('${AK_ORG_B}', 'gov-inv-b', 'Gov Invariant Org B', 'Gov Inv B')
|
||||
on conflict do nothing;
|
||||
insert into public.user_organizations (user_id, organization_id, role, accepted_at)
|
||||
values ('${AK_USER_ORG_B}', '${AK_ORG_B}', 'agent', now())
|
||||
on conflict do nothing;
|
||||
insert into public.contacts (id, organization_id, display_name)
|
||||
values ('${AK_CONTACT}', '${GOV_ORG}', 'Gov Invariant Contact AK')
|
||||
on conflict do nothing;
|
||||
insert into public.conversations (id, organization_id, contact_id, channel_session_id, status, assignee_kind)
|
||||
values ('${AK_CONV}', '${GOV_ORG}', '${AK_CONTACT}', '${GOV_SESSION}', 'ai_handling', 'ai')
|
||||
on conflict do nothing;
|
||||
`);
|
||||
});
|
||||
|
||||
describe("eixo 6 — G3-02: assignee_kind + guard INB-06a", () => {
|
||||
it("CHECK de coerência: kind='user' sem dono e kind='ai' com dono são rejeitados", () => {
|
||||
let err = "";
|
||||
try {
|
||||
sql(
|
||||
`update public.conversations set assignee_kind = 'user', assigned_to_user_id = null
|
||||
where id = '${AK_CONV}';`,
|
||||
);
|
||||
} catch (e) {
|
||||
err = (e as { stderr?: string }).stderr ?? "";
|
||||
}
|
||||
expect(err).toContain("conversations_assignee_kind_coherence");
|
||||
|
||||
err = "";
|
||||
try {
|
||||
sql(
|
||||
`update public.conversations
|
||||
set assignee_kind = 'ai', assigned_to_user_id = '${GOV_AGENT_A}'
|
||||
where id = '${AK_CONV}';`,
|
||||
);
|
||||
} catch (e) {
|
||||
err = (e as { stderr?: string }).stderr ?? "";
|
||||
}
|
||||
expect(err).toContain("conversations_assignee_kind_coherence");
|
||||
});
|
||||
|
||||
it("handoff via fn: kind ai→'user' + evento reason='handoff' na mesma transação", () => {
|
||||
// Garante o estado 'com a IA' (independe do teste anterior).
|
||||
sql(
|
||||
`update public.conversations
|
||||
set assignee_kind = 'ai', assigned_to_user_id = null, status = 'ai_handling'
|
||||
where id = '${AK_CONV}';`,
|
||||
);
|
||||
|
||||
const rows = assignAs(GOV_AGENT_A, `'${GOV_AGENT_A}'::uuid, 'handoff', null::uuid, false`);
|
||||
expect(rows).toBe(1);
|
||||
expect(convState()).toBe(`user|${GOV_AGENT_A}`);
|
||||
|
||||
const events = countAs(
|
||||
GOV_AGENT_A,
|
||||
`select count(*) from public.conversation_assignment_events
|
||||
where conversation_id = '${AK_CONV}' and reason = 'handoff'
|
||||
and to_user_id = '${GOV_AGENT_A}'`,
|
||||
);
|
||||
expect(events).toBe(1);
|
||||
});
|
||||
|
||||
it("release via fn: kind volta a null junto com o dono", () => {
|
||||
const rows = assignAs(GOV_AGENT_A, `null::uuid, 'release', '${GOV_AGENT_A}'::uuid, true`);
|
||||
expect(rows).toBe(1);
|
||||
expect(convState()).toBe("null|null");
|
||||
});
|
||||
|
||||
it("INB-06a: rpc direto NÃO atribui a viewer (raise dentro da função)", () => {
|
||||
const stderr = assignRejected(GOV_AGENT_A, `'${GOV_VIEWER}'::uuid, 'transfer', null::uuid, false`);
|
||||
expect(stderr).toContain("assignee_not_eligible_member");
|
||||
expect(convState()).toBe("null|null"); // nada mudou
|
||||
});
|
||||
|
||||
it("INB-06a: rpc direto NÃO atribui a usuário de OUTRA org", () => {
|
||||
const stderr = assignRejected(
|
||||
GOV_AGENT_A,
|
||||
`'${AK_USER_ORG_B}'::uuid, 'transfer', null::uuid, false`,
|
||||
);
|
||||
expect(stderr).toContain("assignee_not_eligible_member");
|
||||
expect(convState()).toBe("null|null");
|
||||
});
|
||||
|
||||
it("fn_member_role_in_org NEGA anon (permission denied) e segue servindo o service_role", () => {
|
||||
// Mesma simulação do PostgREST anônimo: role anon + JWT sem `sub`
|
||||
// (auth.uid() null — igual ao path do service_role, por isso o EXECUTE
|
||||
// precisa ser negado no grant, não no corpo da função).
|
||||
let stderr = "";
|
||||
try {
|
||||
sql(`
|
||||
set role anon;
|
||||
select set_config('request.jwt.claims', '{}', false);
|
||||
select public.fn_member_role_in_org('${GOV_AGENT_A}'::uuid, '${GOV_ORG}'::uuid);
|
||||
`);
|
||||
} catch (e) {
|
||||
stderr = (e as { stderr?: string }).stderr ?? "";
|
||||
}
|
||||
expect(stderr).toContain("permission denied");
|
||||
|
||||
// O path legítimo do sistema (worker via service_role, uid null) continua
|
||||
// respondendo — é dele que o guard INB-06a depende no handoff.
|
||||
const roleAsSystem = lastLine(
|
||||
sql(`
|
||||
set role service_role;
|
||||
select set_config('request.jwt.claims', '{}', false);
|
||||
select public.fn_member_role_in_org('${GOV_AGENT_A}'::uuid, '${GOV_ORG}'::uuid);
|
||||
`),
|
||||
);
|
||||
expect(roleAsSystem).toBe("agent");
|
||||
});
|
||||
|
||||
it("claim via fn segue válido para membro agent+ e marca kind='user'", () => {
|
||||
const rows = assignAs(GOV_AGENT_A, `'${GOV_AGENT_A}'::uuid, 'claim', null::uuid, true`);
|
||||
expect(rows).toBe(1);
|
||||
expect(convState()).toBe(`user|${GOV_AGENT_A}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* G3-02 acceptance 3 — regra determinística: conversa com assignee_kind='user'
|
||||
* (humano atendendo) VETA o pipeline de resposta do bot, na mesma família de
|
||||
* guard de force_human/bot_silenced_until (workers/ai-response-worker.ts).
|
||||
*
|
||||
* Prova, contra o worker REAL (admin client e gateway mockados):
|
||||
* - kind='user' → skip 'assigned_to_human' ANTES de qualquer leitura de
|
||||
* mensagem (nenhuma query além de conversations);
|
||||
* - kind='ai' → o guard NÃO veta: o pipeline avança até o próximo passo
|
||||
* (aqui, agente ausente → 'agent_inactive_or_missing').
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { processMessageReceived } from "@/workers/ai-response-worker";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import type { EventRow } from "@/lib/event-log/dispatcher";
|
||||
|
||||
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
|
||||
vi.mock("@/lib/logger", () => ({
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/lib/ai/gateway", () => ({
|
||||
DEFAULT_BOT_MODEL: "anthropic/claude-sonnet-4-6",
|
||||
gatewayConfig: {},
|
||||
gatewayHeaders: () => ({}),
|
||||
isAiGatewayConfigured: () => true,
|
||||
isEmbeddingProviderConfigured: () => false,
|
||||
}));
|
||||
|
||||
const ORG_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const CONV_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const MSG_ID = "55555555-5555-4555-8555-555555555555";
|
||||
|
||||
interface StubTables {
|
||||
conversations: Record<string, unknown> | null;
|
||||
messages: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function makeAdminStub(tables: StubTables, queried: string[]) {
|
||||
const from = (table: string) => {
|
||||
const result =
|
||||
table === "conversations"
|
||||
? tables.conversations
|
||||
: table === "messages"
|
||||
? tables.messages
|
||||
: null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const chain: any = {
|
||||
select: () => chain,
|
||||
eq: () => chain,
|
||||
order: () => chain,
|
||||
limit: () => chain,
|
||||
maybeSingle: () => Promise.resolve({ data: result, error: null }),
|
||||
then: (resolve: (v: unknown) => unknown) =>
|
||||
Promise.resolve({ data: result ? [result] : [], error: null }).then(resolve),
|
||||
};
|
||||
queried.push(table);
|
||||
return chain;
|
||||
};
|
||||
return { from };
|
||||
}
|
||||
|
||||
function convRow(assigneeKind: string | null) {
|
||||
return {
|
||||
id: CONV_ID,
|
||||
organization_id: ORG_ID,
|
||||
contact_id: "66666666-6666-4666-8666-666666666666",
|
||||
channel_session_id: "77777777-7777-4777-8777-777777777777",
|
||||
last_inbound_at: new Date().toISOString(),
|
||||
bot_silenced_until: null,
|
||||
last_handoff_at: null,
|
||||
assignee_kind: assigneeKind,
|
||||
contacts: {
|
||||
id: "66666666-6666-4666-8666-666666666666",
|
||||
display_name: null, // sem PII em teste (LGPD)
|
||||
locale: "pt-BR",
|
||||
is_blocked: false,
|
||||
force_human: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const eventRow = {
|
||||
organization_id: ORG_ID,
|
||||
entity_id: MSG_ID,
|
||||
payload: { message_id: MSG_ID, conversation_id: CONV_ID },
|
||||
} as unknown as EventRow;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("guard determinístico do bot — assignee_kind (G3-02)", () => {
|
||||
it("kind='user' (humano atendendo) → bot NÃO dispara: skip 'assigned_to_human'", async () => {
|
||||
const queried: string[] = [];
|
||||
vi.mocked(createAdminClient).mockReturnValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
makeAdminStub({ conversations: convRow("user"), messages: null }, queried) as any,
|
||||
);
|
||||
|
||||
const result = await processMessageReceived(eventRow);
|
||||
|
||||
expect(result).toEqual({ status: "skipped", reason: "assigned_to_human" });
|
||||
// Veto é determinístico e imediato: só a conversa foi lida, nada do resto
|
||||
// do pipeline (mensagem, agente, budget) foi consultado.
|
||||
expect(queried).toEqual(["conversations"]);
|
||||
});
|
||||
|
||||
it("kind='ai' → o guard não veta e o pipeline avança além dele", async () => {
|
||||
const queried: string[] = [];
|
||||
vi.mocked(createAdminClient).mockReturnValue(
|
||||
makeAdminStub(
|
||||
{
|
||||
conversations: convRow("ai"),
|
||||
messages: { id: MSG_ID, body: "oi", direction: "inbound", organization_id: ORG_ID },
|
||||
},
|
||||
queried,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any,
|
||||
);
|
||||
|
||||
const result = await processMessageReceived(eventRow);
|
||||
|
||||
// Avançou até o passo seguinte do pipeline (sem ai_agents no stub):
|
||||
// prova que o guard de assignment deixou passar quando a IA é a assignee.
|
||||
expect(result.reason).toBe("agent_inactive_or_missing");
|
||||
expect(queried).toContain("messages");
|
||||
expect(queried).toContain("ai_agents");
|
||||
});
|
||||
|
||||
it("kind=null (fila) → guard também não veta", async () => {
|
||||
const queried: string[] = [];
|
||||
vi.mocked(createAdminClient).mockReturnValue(
|
||||
makeAdminStub(
|
||||
{
|
||||
conversations: convRow(null),
|
||||
messages: { id: MSG_ID, body: "oi", direction: "inbound", organization_id: ORG_ID },
|
||||
},
|
||||
queried,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any,
|
||||
);
|
||||
|
||||
const result = await processMessageReceived(eventRow);
|
||||
expect(result.reason).toBe("agent_inactive_or_missing");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* G3-02 acceptance 2 — crm_request_human_handoff grava o assignment event
|
||||
* (reason='handoff') e move kind ai→user/fila conforme o roteamento vigente
|
||||
* (round-robin agent+ que o handoff já tinha).
|
||||
*
|
||||
* Prova, contra a tool REAL (ctx.supabase mockado, triggerHandoff mockado):
|
||||
* - com elegível: rpc fn_conversation_assign com p_reason='handoff' (evento +
|
||||
* kind='user' saem da função, na mesma transação);
|
||||
* - sem elegível: fila — assignee_kind limpo (null) + INSERT direto do evento
|
||||
* reason='handoff' com from/to/changed_by null (sistema).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { triggerHandoff } from "@/lib/ai/handoff/orchestrator";
|
||||
import { crmRequestHumanHandoff } from "@/lib/mcp/tools/handoff";
|
||||
import type { McpContext } from "@/lib/mcp/types";
|
||||
|
||||
vi.mock("@/lib/ai/handoff/orchestrator", () => ({ triggerHandoff: vi.fn() }));
|
||||
vi.mock("@/lib/logger", () => ({
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
const ORG_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const CONV_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const AGENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
interface StubState {
|
||||
members: Array<{ user_id: string; role: string }>;
|
||||
rpcCalls: Array<{ fn: string; args: Record<string, unknown> }>;
|
||||
updates: Array<{ table: string; values: Record<string, unknown> }>;
|
||||
inserts: Array<{ table: string; values: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
function makeSupabaseStub(state: StubState) {
|
||||
const from = (table: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const chain: any = {
|
||||
select: () => chain,
|
||||
eq: () => chain,
|
||||
is: () => chain,
|
||||
in: () =>
|
||||
// user_organizations round-robin query termina em .in(...)
|
||||
Promise.resolve({ data: state.members, error: null }),
|
||||
order: () => chain,
|
||||
limit: () => chain,
|
||||
maybeSingle: () =>
|
||||
Promise.resolve({
|
||||
data:
|
||||
table === "conversations"
|
||||
? { id: CONV_ID, organization_id: ORG_ID, contact_id: null }
|
||||
: null,
|
||||
error: null,
|
||||
}),
|
||||
update: (values: Record<string, unknown>) => {
|
||||
state.updates.push({ table, values });
|
||||
return chain;
|
||||
},
|
||||
insert: (values: Record<string, unknown>) => {
|
||||
state.inserts.push({ table, values });
|
||||
return Promise.resolve({ data: null, error: null });
|
||||
},
|
||||
then: (resolve: (v: unknown) => unknown) =>
|
||||
Promise.resolve({ data: null, error: null }).then(resolve),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
return {
|
||||
from,
|
||||
rpc: (fn: string, args: Record<string, unknown>) => {
|
||||
state.rpcCalls.push({ fn, args });
|
||||
return Promise.resolve({ data: [{ id: CONV_ID }], error: null });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(state: StubState): McpContext {
|
||||
return {
|
||||
organizationId: ORG_ID,
|
||||
role: "agent",
|
||||
actor: { type: "user", id: AGENT_ID },
|
||||
apiTokenId: "tok",
|
||||
requestId: "req",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
supabase: makeSupabaseStub(state) as any,
|
||||
} as McpContext;
|
||||
}
|
||||
|
||||
function stubState(overrides: Partial<StubState> = {}): StubState {
|
||||
return {
|
||||
members: [{ user_id: AGENT_ID, role: "agent" }],
|
||||
rpcCalls: [],
|
||||
updates: [],
|
||||
inserts: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const input = {
|
||||
conversation_id: CONV_ID,
|
||||
reason: "cliente pediu humano",
|
||||
urgency: "normal" as const,
|
||||
suggested_assignee_role: "agent" as const,
|
||||
metadata: undefined,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(triggerHandoff).mockResolvedValue({ triggered: true, reason: "requested_human" });
|
||||
});
|
||||
|
||||
describe("crm_request_human_handoff — reassignment auditado (G3-02)", () => {
|
||||
it("com elegível: fn_conversation_assign com reason='handoff' (kind ai→user + evento na fn)", async () => {
|
||||
const state = stubState();
|
||||
const result = (await crmRequestHumanHandoff.handler(input, makeCtx(state))) as {
|
||||
assigned_to_user_id: string | null;
|
||||
};
|
||||
|
||||
expect(state.rpcCalls).toEqual([
|
||||
{
|
||||
fn: "fn_conversation_assign",
|
||||
args: {
|
||||
p_organization_id: ORG_ID,
|
||||
p_conversation_id: CONV_ID,
|
||||
p_to_user_id: AGENT_ID,
|
||||
p_reason: "handoff",
|
||||
p_enforce_expected: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.assigned_to_user_id).toBe(AGENT_ID);
|
||||
// Caminho da fila NÃO roda quando a atribuição venceu.
|
||||
expect(state.inserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("sem elegível: fila — kind limpo + evento reason='handoff' from/to null (sistema)", async () => {
|
||||
const state = stubState({ members: [] });
|
||||
const result = (await crmRequestHumanHandoff.handler(input, makeCtx(state))) as {
|
||||
assigned_to_user_id: string | null;
|
||||
};
|
||||
|
||||
expect(result.assigned_to_user_id).toBeNull();
|
||||
expect(state.rpcCalls).toEqual([]);
|
||||
expect(state.updates).toContainEqual({
|
||||
table: "conversations",
|
||||
values: { assignee_kind: null },
|
||||
});
|
||||
expect(state.inserts).toContainEqual({
|
||||
table: "conversation_assignment_events",
|
||||
values: {
|
||||
organization_id: ORG_ID,
|
||||
conversation_id: CONV_ID,
|
||||
from_user_id: null,
|
||||
to_user_id: null,
|
||||
changed_by: null,
|
||||
reason: "handoff",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -252,7 +252,7 @@ async function buildContext(input: BuildContextInput): Promise<GuardDecision> {
|
||||
const { data: conv, error: convErr } = await admin
|
||||
.from("conversations")
|
||||
.select(
|
||||
"id, organization_id, contact_id, channel_session_id, last_inbound_at, bot_silenced_until, last_handoff_at, contacts:contact_id(id, display_name, locale, is_blocked, force_human)",
|
||||
"id, organization_id, contact_id, channel_session_id, last_inbound_at, bot_silenced_until, last_handoff_at, assignee_kind, contacts:contact_id(id, display_name, locale, is_blocked, force_human)",
|
||||
)
|
||||
.eq("id", input.conversationId)
|
||||
.eq("organization_id", input.organizationId)
|
||||
@@ -269,6 +269,7 @@ async function buildContext(input: BuildContextInput): Promise<GuardDecision> {
|
||||
last_inbound_at: string | null;
|
||||
bot_silenced_until: string | null;
|
||||
last_handoff_at: string | null;
|
||||
assignee_kind: string | null;
|
||||
contacts: {
|
||||
id: string;
|
||||
display_name: string | null;
|
||||
@@ -281,6 +282,9 @@ async function buildContext(input: BuildContextInput): Promise<GuardDecision> {
|
||||
if (!c.contacts) return skip("conversation_not_found", "contact join missing");
|
||||
if (c.contacts.is_blocked) return skip("contact_blocked");
|
||||
if (c.contacts.force_human) return skip("force_human");
|
||||
// G3-02 — assignee de 1ª classe: humano atendendo (kind='user') veta o bot
|
||||
// deterministicamente, mesma família de guard de force_human/bot_silenced_until.
|
||||
if (c.assignee_kind === "user") return skip("assigned_to_human");
|
||||
|
||||
// 24h window (IA-01). Use last_inbound_at — webhook updates it on receive.
|
||||
if (c.last_inbound_at) {
|
||||
|
||||
Reference in New Issue
Block a user