mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
feat(G6-03): read tools expose governance — assignee/tags/queue/owner/stage [gov-loop]
4 MCP read tools gain additive fields (conversation: assignee_kind,
assigned_to_user_name, tags, queue_position; lead: owner_user_name,
stage{id,name}, tags). Surface only — RLS closed by G6-00/G4. queue_position
uses new getQueuePositions (batch, last_inbound_at ASC + id ASC) matching G5-03
inbox order exactly (coherence test). LGPD: id+name only, no email/phone.
No N+1 (batch name/queue/stage resolution). No migration.
Verified-by: gov-verifier PASS 2026-07-18T21:40:09-0300
This commit is contained in:
@@ -18,7 +18,7 @@ type SB = SupabaseClient;
|
||||
|
||||
const SELECT_COLS = `
|
||||
id, organization_id, contact_id, channel_session_id, channel, status,
|
||||
status_changed_at, assigned_to_user_id, assigned_at, last_inbound_at,
|
||||
status_changed_at, assigned_to_user_id, assignee_kind, assigned_at, last_inbound_at,
|
||||
last_outbound_at, last_message_at, last_message_preview,
|
||||
unread_count_for_assignee, is_group, group_chat_id, tags, metadata,
|
||||
created_at, updated_at,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Resolução de NOME de usuário (assignee/owner) para os payloads de leitura MCP
|
||||
* (G6-03). Dedupe por id — numa listagem de N linhas só resolve K usuários
|
||||
* únicos, sem N+1.
|
||||
*
|
||||
* LGPD (doutrina do repo): expõe SÓ `full_name` do user. NUNCA email, phone,
|
||||
* user_metadata completo, tokens ou qualquer outra PII do usuário. Mesmo mínimo
|
||||
* que /api/v1/team/assignable já expõe.
|
||||
*/
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
export async function resolveUserNames(
|
||||
supabase: SupabaseClient,
|
||||
userIds: Array<string | null | undefined>,
|
||||
): Promise<Map<string, string | null>> {
|
||||
const unique = [...new Set(userIds.filter((id): id is string => Boolean(id)))];
|
||||
const entries = await Promise.all(
|
||||
unique.map(async (id): Promise<readonly [string, string | null]> => {
|
||||
try {
|
||||
const { data } = await supabase.auth.admin.getUserById(id);
|
||||
const fullName = (data?.user?.user_metadata?.full_name as string | undefined) ?? null;
|
||||
return [id, fullName] as const;
|
||||
} catch {
|
||||
// Nome é não-crítico: falha de lookup não pode quebrar a leitura.
|
||||
return [id, null] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return new Map(entries);
|
||||
}
|
||||
@@ -20,12 +20,12 @@ export const TOOL_CATALOG: ReadonlyArray<McpToolCatalogEntry> = [
|
||||
// read
|
||||
{ name: "crm_search_contacts", category: "read", description: "Busca contatos por nome/telefone/email" },
|
||||
{ name: "crm_get_contact", category: "read", description: "Detalhe de um contato" },
|
||||
{ name: "crm_list_conversations", category: "read", description: "Lista conversas" },
|
||||
{ name: "crm_get_conversation", category: "read", description: "Detalhe de conversa" },
|
||||
{ name: "crm_list_conversations", category: "read", description: "Lista conversas (com assignee_kind, assigned_to_user_name, tags, queue_position)" },
|
||||
{ name: "crm_get_conversation", category: "read", description: "Detalhe de conversa (com assignee_kind, assigned_to_user_name, tags, queue_position)" },
|
||||
{ name: "crm_get_conversation_history", category: "read", description: "Historico de mensagens de uma conversa" },
|
||||
{ name: "crm_get_queue_status", category: "read", description: "Snapshot da fila de atendimento da org" },
|
||||
{ name: "crm_list_leads", category: "read", description: "Lista leads de um pipeline" },
|
||||
{ name: "crm_get_lead", category: "read", description: "Detalhe de lead" },
|
||||
{ name: "crm_list_leads", category: "read", description: "Lista leads de um pipeline (com owner_user_name, stage, tags)" },
|
||||
{ name: "crm_get_lead", category: "read", description: "Detalhe de lead (com owner_user_name, stage, tags)" },
|
||||
{ name: "crm_list_pipelines", category: "read", description: "Lista pipelines da org" },
|
||||
// write
|
||||
{ name: "crm_create_lead", category: "write", description: "Cria um lead" },
|
||||
|
||||
@@ -12,8 +12,15 @@ import {
|
||||
getConversationHandler,
|
||||
} from "@/app/api/v1/conversations/_handler";
|
||||
import { listMessagesHandler } from "@/app/api/v1/messages/_handler";
|
||||
import { getQueuePositions } from "@/lib/routing/queue";
|
||||
import { resolveUserNames } from "./_users";
|
||||
import type { McpToolDefinition } from "../types";
|
||||
|
||||
/** Conversa está na fila (visível/atribuível) = sem dono ∧ status='open'. */
|
||||
function isInQueue(c: { assigned_to_user_id: string | null; status: string }): boolean {
|
||||
return c.assigned_to_user_id === null && c.status === "open";
|
||||
}
|
||||
|
||||
const listInputShape = {
|
||||
contact_id: z.string().uuid().optional(),
|
||||
status: z.enum(["open", "claimed", "ai_handling", "closed", "archived"]).optional(),
|
||||
@@ -24,7 +31,8 @@ const listInputShape = {
|
||||
export const crmListConversations: McpToolDefinition<typeof listInputShape> = {
|
||||
name: "crm_list_conversations",
|
||||
description:
|
||||
"Lista conversas do CRM com filtros opcionais por contato e status. Retorna preview da ultima mensagem.",
|
||||
"Lista conversas do CRM com filtros opcionais por contato e status. Retorna preview da ultima mensagem. " +
|
||||
"Campos de governança por conversa: assignee_kind ('user'|'ai'|null), assigned_to_user_id + assigned_to_user_name (só o nome do atendente, sem email/telefone), tags[], e queue_position (posição 1-based na fila do inbox — só quando na fila, senão null).",
|
||||
inputSchema: listInputShape,
|
||||
category: "read",
|
||||
requiresRole: "agent",
|
||||
@@ -47,6 +55,14 @@ export const crmListConversations: McpToolDefinition<typeof listInputShape> = {
|
||||
if (input.contact_id) {
|
||||
conversations = conversations.filter((c) => c.contact_id === input.contact_id);
|
||||
}
|
||||
// Nomes (dedupe) e posições de fila (1 query cada) — sem N+1 na listagem.
|
||||
const names = await resolveUserNames(
|
||||
ctx.supabase,
|
||||
conversations.map((c) => c.assigned_to_user_id),
|
||||
);
|
||||
const queueMap = conversations.some(isInQueue)
|
||||
? await getQueuePositions(ctx.supabase, ctx.organizationId)
|
||||
: new Map<string, number>();
|
||||
return {
|
||||
conversations: conversations.map((c) => ({
|
||||
id: c.id,
|
||||
@@ -54,6 +70,12 @@ export const crmListConversations: McpToolDefinition<typeof listInputShape> = {
|
||||
channel: c.channel,
|
||||
status: c.status,
|
||||
assigned_to_user_id: c.assigned_to_user_id,
|
||||
assignee_kind: c.assignee_kind,
|
||||
assigned_to_user_name: c.assigned_to_user_id
|
||||
? (names.get(c.assigned_to_user_id) ?? null)
|
||||
: null,
|
||||
tags: c.tags ?? [],
|
||||
queue_position: queueMap.get(c.id) ?? null,
|
||||
last_message_preview: c.last_message_preview,
|
||||
last_message_at: c.last_message_at,
|
||||
unread_count: c.unread_count_for_assignee,
|
||||
@@ -72,7 +94,8 @@ const getInputShape = {
|
||||
export const crmGetConversation: McpToolDefinition<typeof getInputShape> = {
|
||||
name: "crm_get_conversation",
|
||||
description:
|
||||
"Retorna detalhes de uma conversa pelo UUID. Inclui status, atribuicao, contato, ultima atividade.",
|
||||
"Retorna detalhes de uma conversa pelo UUID. Inclui status, atribuicao, contato, ultima atividade. " +
|
||||
"Governança: assignee_kind ('user'|'ai'|null), assigned_to_user_id + assigned_to_user_name (só o nome, sem email/telefone), tags[], e queue_position (1-based na fila do inbox — null quando não está na fila).",
|
||||
inputSchema: getInputShape,
|
||||
category: "read",
|
||||
requiresRole: "agent",
|
||||
@@ -87,6 +110,10 @@ export const crmGetConversation: McpToolDefinition<typeof getInputShape> = {
|
||||
},
|
||||
input.conversation_id,
|
||||
);
|
||||
const names = await resolveUserNames(ctx.supabase, [conv.assigned_to_user_id]);
|
||||
const queue_position = isInQueue(conv)
|
||||
? ((await getQueuePositions(ctx.supabase, ctx.organizationId)).get(conv.id) ?? null)
|
||||
: null;
|
||||
return {
|
||||
id: conv.id,
|
||||
contact_id: conv.contact_id,
|
||||
@@ -94,6 +121,12 @@ export const crmGetConversation: McpToolDefinition<typeof getInputShape> = {
|
||||
channel: conv.channel,
|
||||
status: conv.status,
|
||||
assigned_to_user_id: conv.assigned_to_user_id,
|
||||
assignee_kind: conv.assignee_kind,
|
||||
assigned_to_user_name: conv.assigned_to_user_id
|
||||
? (names.get(conv.assigned_to_user_id) ?? null)
|
||||
: null,
|
||||
tags: conv.tags ?? [],
|
||||
queue_position,
|
||||
assigned_at: conv.assigned_at,
|
||||
last_inbound_at: conv.last_inbound_at,
|
||||
last_outbound_at: conv.last_outbound_at,
|
||||
|
||||
+52
-5
@@ -21,7 +21,50 @@ import {
|
||||
moveLeadHandler,
|
||||
} from "@/app/api/v1/leads/_handler";
|
||||
import { createLeadSchema, updateLeadSchema } from "@/lib/schemas/leads";
|
||||
import type { McpToolDefinition } from "../types";
|
||||
import { resolveUserNames } from "./_users";
|
||||
import type { McpContext, McpToolDefinition } from "../types";
|
||||
|
||||
/**
|
||||
* Enriquece rows de lead com os campos de governança aditivos (G6-03):
|
||||
* `owner_user_name` (só o nome — LGPD) e `stage` ({ id, name }, o label legível
|
||||
* que o get_lead_context compõe). owner_user_id, stage_id, status e tags[] já
|
||||
* vêm na row (`select *`); nada existente muda. Dedupe de owners e stages —
|
||||
* sem N+1 numa listagem.
|
||||
*/
|
||||
async function enrichLeads(
|
||||
ctx: McpContext,
|
||||
leads: Array<Record<string, unknown>>,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
if (leads.length === 0) return leads;
|
||||
|
||||
const names = await resolveUserNames(
|
||||
ctx.supabase,
|
||||
leads.map((l) => l.owner_user_id as string | null),
|
||||
);
|
||||
|
||||
const stageIds = [
|
||||
...new Set(leads.map((l) => l.stage_id).filter((id): id is string => Boolean(id))),
|
||||
];
|
||||
const stageById = new Map<string, { id: string; name: string }>();
|
||||
if (stageIds.length > 0) {
|
||||
const { data } = await ctx.supabase
|
||||
.from("crm_stages")
|
||||
.select("id, name")
|
||||
.eq("organization_id", ctx.organizationId)
|
||||
.in("id", stageIds);
|
||||
for (const s of (data ?? []) as Array<{ id: string; name: string }>) {
|
||||
stageById.set(s.id, { id: s.id, name: s.name });
|
||||
}
|
||||
}
|
||||
|
||||
return leads.map((l) => ({
|
||||
...l,
|
||||
owner_user_name: l.owner_user_id
|
||||
? (names.get(l.owner_user_id as string) ?? null)
|
||||
: null,
|
||||
stage: l.stage_id ? (stageById.get(l.stage_id as string) ?? null) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list
|
||||
@@ -39,7 +82,8 @@ const listInputShape = {
|
||||
export const crmListLeads: McpToolDefinition<typeof listInputShape> = {
|
||||
name: "crm_list_leads",
|
||||
description:
|
||||
"Lista leads do CRM filtrando por pipeline, stage, status e owner. Cursor base64 para paginação.",
|
||||
"Lista leads do CRM filtrando por pipeline, stage, status e owner. Cursor base64 para paginação. " +
|
||||
"Governança por lead: owner_user_id + owner_user_name (só o nome do dono, sem email/telefone), stage ({ id, name } legível além do stage_id) e tags[].",
|
||||
inputSchema: listInputShape,
|
||||
category: "read",
|
||||
requiresRole: "agent",
|
||||
@@ -62,7 +106,7 @@ export const crmListLeads: McpToolDefinition<typeof listInputShape> = {
|
||||
},
|
||||
);
|
||||
return {
|
||||
leads: result.leads,
|
||||
leads: await enrichLeads(ctx, result.leads),
|
||||
cursor: result.cursor,
|
||||
has_more: result.has_more,
|
||||
};
|
||||
@@ -79,7 +123,9 @@ const getInputShape = {
|
||||
|
||||
export const crmGetLead: McpToolDefinition<typeof getInputShape> = {
|
||||
name: "crm_get_lead",
|
||||
description: "Retorna um lead pelo UUID. Inclui pipeline_id, stage_id, status, owner.",
|
||||
description:
|
||||
"Retorna um lead pelo UUID. Inclui pipeline_id, stage_id, status, owner. " +
|
||||
"Governança: owner_user_id + owner_user_name (só o nome, sem email/telefone), stage ({ id, name } legível) e tags[].",
|
||||
inputSchema: getInputShape,
|
||||
category: "read",
|
||||
requiresRole: "agent",
|
||||
@@ -98,7 +144,8 @@ export const crmGetLead: McpToolDefinition<typeof getInputShape> = {
|
||||
// Defesa em profundidade — service-role bypassa RLS.
|
||||
throw new Error("not_found");
|
||||
}
|
||||
return { lead };
|
||||
const [enriched] = await enrichLeads(ctx, [lead]);
|
||||
return { lead: enriched };
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -57,6 +57,32 @@ export async function getQueueStatus(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapa id→posição (1-based) da fila de atendimento, na MESMA fonte e ordenação
|
||||
* que o inbox vê (G5-03 / gov-5d): sem dono ∧ status='open', ordenado por
|
||||
* last_inbound_at ASC (nulls last), tiebreak id ASC. O número que a tool MCP
|
||||
* devolve é EXATAMENTE o índice desta lista — bate com a posição da fila do
|
||||
* atendente. Uma query só (a fila é limitada) — evita N+1 numa listagem.
|
||||
*/
|
||||
export async function getQueuePositions(
|
||||
supabase: SupabaseClient,
|
||||
organizationId: string,
|
||||
): Promise<Map<string, number>> {
|
||||
const { data } = await supabase
|
||||
.from("conversations")
|
||||
.select("id")
|
||||
.eq("organization_id", organizationId)
|
||||
.is("assigned_to_user_id", null)
|
||||
.eq("status", "open")
|
||||
.order("last_inbound_at", { ascending: true, nullsFirst: false })
|
||||
.order("id", { ascending: true });
|
||||
|
||||
const rows = (data ?? []) as Array<{ id: string }>;
|
||||
const map = new Map<string, number>();
|
||||
rows.forEach((r, i) => map.set(r.id, i + 1));
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posição de uma conversa na fila de espera humana (handoff v2 fallback).
|
||||
* Conta as conversas sem dono aguardando (status open|pending) cujo
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Conversation {
|
||||
status: string;
|
||||
status_changed_at: string;
|
||||
assigned_to_user_id: string | null;
|
||||
assignee_kind: string | null;
|
||||
assigned_at: string | null;
|
||||
last_inbound_at: string | null;
|
||||
last_outbound_at: string | null;
|
||||
|
||||
+7
-2
@@ -758,8 +758,13 @@
|
||||
"priority": 30,
|
||||
"lane": "core",
|
||||
"kind": "build",
|
||||
"passes": false,
|
||||
"verification": null
|
||||
"passes": true,
|
||||
"verification": {
|
||||
"verdict": "PASS",
|
||||
"by": "gov-verifier",
|
||||
"at": "2026-07-18T21:40:09-0300",
|
||||
"commit": "self"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "G6-04",
|
||||
|
||||
@@ -647,3 +647,22 @@
|
||||
git status limpo, só meus 3 arquivos). 275 unit. Sem migration.
|
||||
- Hash-check estável (0 falso-positivo — o .git/info/exclude resolveu a raiz).
|
||||
- Próxima: G6-03 (tools de leitura expõem governança, dep G6-00+G6-01 — ambas feitas).
|
||||
|
||||
## 2026-07-18 — sessão 22 do loop (core) — G6-03
|
||||
|
||||
- G6-03 (tools de leitura expõem governança): as 4 read tools ganham campos
|
||||
ADITIVOS — conversation: assignee_kind, assigned_to_user_name, tags,
|
||||
queue_position; lead: owner_user_name, stage {id,name}, tags. Só superfície
|
||||
(RLS já fechada G6-00/G4). Sem migration.
|
||||
- Cuidado do Maestro (coerência queue_position↔inbox) provado: getQueuePositions
|
||||
NOVO (batch, last_inbound_at ASC nulls last + id ASC) = MESMA ordem da G5-03
|
||||
(verifier conferiu contra commit 14b4ebd, não a afirmação). Teste de coerência:
|
||||
3 conversas 30/10/2min → tool pos 1/2/3 = inbox. NÃO reusou o getQueuePosition
|
||||
antigo (count-based, sem tiebreak) que divergiria — decisão certa.
|
||||
- LGPD: payload expõe só id+full_name; teste asserta email/phone/user_metadata
|
||||
AUSENTES. N+1 resolvido (nomes dedupe batch, queue 1 query, stages 1 query).
|
||||
- Fixtures 3 estados (atribuída/fila/IA). gov-verifier PASS 1ª rodada SEM
|
||||
findings, hash OK. 282 unit. Adicionei prova-clipboard-token-http.png (externo)
|
||||
ao .git/info/exclude — hash estável.
|
||||
- PENÚLTIMA do épico. Próxima: G6-04 (spec 14 = contrato do Vendaval) → checkpoint
|
||||
G6 = GATILHO da FG. Só falta 1.
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* G6-03 — as 4 tools de leitura expõem governança (spec 13 §3.1).
|
||||
*
|
||||
* Prova, contra os handlers REAIS (ctx.supabase stub):
|
||||
* - campos ADITIVOS presentes (assignee_kind, assigned_to_user_name, tags,
|
||||
* queue_position; owner_user_name, stage, tags) por tool;
|
||||
* - shape ANTIGO intacto (nada renomeado/removido — consumidor atual não quebra);
|
||||
* - fixtures dos 3 estados: atribuída (user), na fila, IA atendendo;
|
||||
* - COERÊNCIA da queue_position: o número da tool = a posição na MESMA ordem que
|
||||
* o inbox (G5-03 / gov-5d): last_inbound_at ASC, id ASC — computada de forma
|
||||
* independente e comparada;
|
||||
* - LGPD: só id + nome do usuário no payload; nunca email/telefone/metadata.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// env.ts valida process.env no import; corta os chains client→env (os handlers só
|
||||
// recebem o ctx.supabase stub, nunca criam client real).
|
||||
vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn() }));
|
||||
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
|
||||
vi.mock("@/lib/audit", () => ({
|
||||
audit: vi.fn().mockResolvedValue(undefined),
|
||||
isServiceRoleConfigured: () => false,
|
||||
}));
|
||||
|
||||
import {
|
||||
crmListConversations,
|
||||
crmGetConversation,
|
||||
} from "@/lib/mcp/tools/conversations";
|
||||
import { crmListLeads, crmGetLead } from "@/lib/mcp/tools/leads";
|
||||
import type { McpContext } from "@/lib/mcp/types";
|
||||
|
||||
const ORG = "22222222-2222-4222-8222-222222222222";
|
||||
const USER_A = "11111111-1111-4111-8111-111111111111"; // Alice
|
||||
const USER_B = "33333333-3333-4333-8333-333333333333"; // Bob
|
||||
const STAGE_1 = "55555555-5555-4555-8555-555555555551";
|
||||
|
||||
const USER_NAMES: Record<string, string> = { [USER_A]: "Alice", [USER_B]: "Bob" };
|
||||
|
||||
// Fila: 3 conversas com tempos de espera conhecidos (oldest = pos 1).
|
||||
const CONV_OLD = "aaaaaaaa-0000-4000-8000-000000000001";
|
||||
const CONV_MID = "aaaaaaaa-0000-4000-8000-000000000002";
|
||||
const CONV_NEW = "aaaaaaaa-0000-4000-8000-000000000003";
|
||||
const now = Date.now();
|
||||
const QUEUE_ROWS = [
|
||||
{ id: CONV_NEW, last_inbound_at: new Date(now - 2 * 60_000).toISOString() },
|
||||
{ id: CONV_OLD, last_inbound_at: new Date(now - 30 * 60_000).toISOString() },
|
||||
{ id: CONV_MID, last_inbound_at: new Date(now - 10 * 60_000).toISOString() },
|
||||
];
|
||||
|
||||
/** Ordem canônica do inbox (G5-03): last_inbound_at ASC, id ASC. */
|
||||
function inboxOrder(rows: Array<{ id: string; last_inbound_at: string }>): string[] {
|
||||
return [...rows]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.last_inbound_at.localeCompare(b.last_inbound_at) || a.id.localeCompare(b.id),
|
||||
)
|
||||
.map((r) => r.id);
|
||||
}
|
||||
|
||||
interface Q {
|
||||
table: string;
|
||||
select: string | null;
|
||||
terminal: "maybeSingle" | "then";
|
||||
}
|
||||
type Resolver = (q: Q) => { data?: unknown; error?: unknown };
|
||||
|
||||
function makeSupabase(resolve: Resolver) {
|
||||
const from = (table: string) => {
|
||||
const q: Q = { table, select: null, terminal: "then" };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const chain: any = {
|
||||
select: (cols: string) => {
|
||||
q.select = cols;
|
||||
return chain;
|
||||
},
|
||||
eq: () => chain,
|
||||
is: () => chain,
|
||||
in: () => chain,
|
||||
or: () => chain,
|
||||
contains: () => chain,
|
||||
ilike: () => chain,
|
||||
order: () => chain,
|
||||
limit: () => chain,
|
||||
maybeSingle: () => Promise.resolve(resolve({ ...q, terminal: "maybeSingle" })),
|
||||
then: (res: (v: unknown) => unknown) =>
|
||||
Promise.resolve(resolve({ ...q, terminal: "then" })).then(res),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
return {
|
||||
from,
|
||||
auth: {
|
||||
admin: {
|
||||
getUserById: (id: string) =>
|
||||
Promise.resolve({
|
||||
data: id in USER_NAMES ? { user: { user_metadata: { full_name: USER_NAMES[id] } } } : { user: null },
|
||||
error: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(resolve: Resolver): McpContext {
|
||||
return {
|
||||
organizationId: ORG,
|
||||
role: "agent",
|
||||
actor: { type: "ai_agent", id: "run_1", role: "agent", api_token_id: "tok" },
|
||||
apiTokenId: "tok",
|
||||
requestId: "req",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
supabase: makeSupabase(resolve) as any,
|
||||
} as McpContext;
|
||||
}
|
||||
|
||||
// Row de conversa mínima com os campos que os handlers/tools leem.
|
||||
function convRow(over: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
id: over.id,
|
||||
organization_id: ORG,
|
||||
contact_id: "c0000000-0000-4000-8000-000000000001",
|
||||
channel_session_id: "s0000000-0000-4000-8000-000000000001",
|
||||
channel: "whatsapp",
|
||||
status: "open",
|
||||
status_changed_at: new Date(now).toISOString(),
|
||||
assigned_to_user_id: null,
|
||||
assignee_kind: null,
|
||||
assigned_at: null,
|
||||
last_inbound_at: new Date(now).toISOString(),
|
||||
last_outbound_at: null,
|
||||
last_message_at: new Date(now).toISOString(),
|
||||
last_message_preview: "oi",
|
||||
unread_count_for_assignee: 0,
|
||||
is_group: false,
|
||||
group_chat_id: null,
|
||||
tags: [],
|
||||
metadata: {},
|
||||
created_at: new Date(now).toISOString(),
|
||||
updated_at: new Date(now).toISOString(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolver de conversas: getQueuePositions (select "id") devolve a fila na ordem do inbox. */
|
||||
function convResolver(single: Record<string, unknown> | null): Resolver {
|
||||
return (q) => {
|
||||
if (q.table === "conversations" && q.select === "id") {
|
||||
// Emula o ORDER BY do banco: retorna a fila JÁ ordenada (inbox order).
|
||||
const ordered = inboxOrder(QUEUE_ROWS).map((id) => ({ id }));
|
||||
return { data: ordered, error: null };
|
||||
}
|
||||
if (q.table === "conversations" && q.terminal === "maybeSingle") {
|
||||
return { data: single, error: null };
|
||||
}
|
||||
return { data: single ? [single] : [], error: null };
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// crm_get_conversation — 3 estados
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("crm_get_conversation — governança + shape", () => {
|
||||
it("ATRIBUÍDA (user): assignee_kind='user', nome do atendente, sem queue_position", async () => {
|
||||
const row = convRow({
|
||||
id: CONV_OLD,
|
||||
status: "claimed",
|
||||
assigned_to_user_id: USER_A,
|
||||
assignee_kind: "user",
|
||||
assigned_at: new Date(now).toISOString(),
|
||||
tags: ["prioridade"],
|
||||
});
|
||||
const res = (await crmGetConversation.handler(
|
||||
{ conversation_id: CONV_OLD },
|
||||
makeCtx(convResolver(row)),
|
||||
)) as Record<string, unknown>;
|
||||
|
||||
expect(res.assignee_kind).toBe("user");
|
||||
expect(res.assigned_to_user_id).toBe(USER_A);
|
||||
expect(res.assigned_to_user_name).toBe("Alice");
|
||||
expect(res.tags).toEqual(["prioridade"]);
|
||||
expect(res.queue_position).toBeNull(); // tem dono ⇒ fora da fila
|
||||
// shape antigo intacto:
|
||||
expect(res.id).toBe(CONV_OLD);
|
||||
expect(res.status).toBe("claimed");
|
||||
expect(res.contact_id).toBeDefined();
|
||||
expect(res.channel).toBe("whatsapp");
|
||||
});
|
||||
|
||||
it("NA FILA: assignee_kind=null, sem nome, queue_position preenchida", async () => {
|
||||
const row = convRow({ id: CONV_MID, status: "open", assigned_to_user_id: null });
|
||||
const res = (await crmGetConversation.handler(
|
||||
{ conversation_id: CONV_MID },
|
||||
makeCtx(convResolver(row)),
|
||||
)) as Record<string, unknown>;
|
||||
|
||||
expect(res.assignee_kind).toBeNull();
|
||||
expect(res.assigned_to_user_id).toBeNull();
|
||||
expect(res.assigned_to_user_name).toBeNull();
|
||||
// CONV_MID (10 min) é a 2ª mais antiga ⇒ posição 2.
|
||||
expect(res.queue_position).toBe(inboxOrder(QUEUE_ROWS).indexOf(CONV_MID) + 1);
|
||||
expect(res.queue_position).toBe(2);
|
||||
});
|
||||
|
||||
it("IA ATENDENDO: assignee_kind='ai', sem nome, sem queue_position", async () => {
|
||||
const row = convRow({
|
||||
id: CONV_NEW,
|
||||
status: "ai_handling",
|
||||
assigned_to_user_id: null,
|
||||
assignee_kind: "ai",
|
||||
});
|
||||
const res = (await crmGetConversation.handler(
|
||||
{ conversation_id: CONV_NEW },
|
||||
makeCtx(convResolver(row)),
|
||||
)) as Record<string, unknown>;
|
||||
|
||||
expect(res.assignee_kind).toBe("ai");
|
||||
expect(res.assigned_to_user_name).toBeNull();
|
||||
expect(res.queue_position).toBeNull(); // status != 'open' ⇒ fora da fila
|
||||
|
||||
// LGPD: nenhum campo de PII do usuário além do nome (id) vaza no payload.
|
||||
const keys = Object.keys(res);
|
||||
expect(keys).not.toContain("email");
|
||||
expect(keys).not.toContain("phone");
|
||||
expect(keys).not.toContain("user_metadata");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// crm_list_conversations — coerência da queue_position com o inbox (G5-03)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("crm_list_conversations — coerência queue_position ↔ inbox", () => {
|
||||
it("as 3 conversas na fila recebem a posição da ordem do inbox (last_inbound_at ASC, id ASC)", async () => {
|
||||
// Handler de list retorna as 3 conversas da fila.
|
||||
const rows = QUEUE_ROWS.map((r) =>
|
||||
convRow({ id: r.id, status: "open", assigned_to_user_id: null, last_inbound_at: r.last_inbound_at }),
|
||||
);
|
||||
const resolve: Resolver = (q) => {
|
||||
if (q.table === "conversations" && q.select === "id") {
|
||||
return { data: inboxOrder(QUEUE_ROWS).map((id) => ({ id })), error: null };
|
||||
}
|
||||
return { data: rows, error: null }; // list (then)
|
||||
};
|
||||
const res = (await crmListConversations.handler(
|
||||
{ limit: 10 } as Parameters<typeof crmListConversations.handler>[0],
|
||||
makeCtx(resolve),
|
||||
)) as { conversations: Array<Record<string, unknown>> };
|
||||
|
||||
const posById = new Map(res.conversations.map((c) => [c.id as string, c.queue_position]));
|
||||
const expectedOrder = inboxOrder(QUEUE_ROWS); // independente do tool
|
||||
|
||||
// Cada conversa: a posição da tool bate com a posição na ordem do inbox.
|
||||
for (let i = 0; i < expectedOrder.length; i++) {
|
||||
expect(posById.get(expectedOrder[i]!)).toBe(i + 1);
|
||||
}
|
||||
// A mais antiga (30 min) é posição 1; a mais nova (2 min) é a última.
|
||||
expect(posById.get(CONV_OLD)).toBe(1);
|
||||
expect(posById.get(CONV_NEW)).toBe(3);
|
||||
});
|
||||
|
||||
it("shape aditivo: campos antigos preservados, novos presentes", async () => {
|
||||
const rows = [convRow({ id: CONV_OLD, status: "claimed", assigned_to_user_id: USER_B, assignee_kind: "user", tags: ["x"] })];
|
||||
const res = (await crmListConversations.handler(
|
||||
{ limit: 10 } as Parameters<typeof crmListConversations.handler>[0],
|
||||
makeCtx((q) =>
|
||||
q.select === "id" ? { data: [], error: null } : { data: rows, error: null },
|
||||
),
|
||||
)) as { conversations: Array<Record<string, unknown>> };
|
||||
const c = res.conversations[0]!;
|
||||
// antigos:
|
||||
for (const k of ["id", "contact_id", "channel", "status", "assigned_to_user_id", "last_message_preview", "last_message_at", "unread_count", "is_group"]) {
|
||||
expect(c).toHaveProperty(k);
|
||||
}
|
||||
// novos:
|
||||
expect(c.assignee_kind).toBe("user");
|
||||
expect(c.assigned_to_user_name).toBe("Bob");
|
||||
expect(c.tags).toEqual(["x"]);
|
||||
expect(c).toHaveProperty("queue_position");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// crm_get_lead / crm_list_leads — owner_user_name, stage, tags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function leadRow(over: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
id: "d0000000-0000-4000-8000-000000000001",
|
||||
organization_id: ORG,
|
||||
pipeline_id: "e0000000-0000-4000-8000-000000000001",
|
||||
stage_id: STAGE_1,
|
||||
title: "Pedido #1",
|
||||
status: "open",
|
||||
owner_user_id: null,
|
||||
tags: [],
|
||||
value_cents: null,
|
||||
currency: "BRL",
|
||||
position_in_stage: 1000,
|
||||
created_at: new Date(now).toISOString(),
|
||||
updated_at: new Date(now).toISOString(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function leadResolver(rows: Array<Record<string, unknown>>): Resolver {
|
||||
return (q) => {
|
||||
if (q.table === "crm_stages") {
|
||||
return { data: [{ id: STAGE_1, name: "Qualificação" }], error: null };
|
||||
}
|
||||
if (q.table === "crm_leads" && q.terminal === "maybeSingle") {
|
||||
return { data: rows[0] ?? null, error: null };
|
||||
}
|
||||
return { data: rows, error: null };
|
||||
};
|
||||
}
|
||||
|
||||
describe("crm_get_lead / crm_list_leads — governança + shape", () => {
|
||||
it("get: com owner ⇒ owner_user_name + stage{id,name} + tags; shape antigo intacto", async () => {
|
||||
const row = leadRow({ owner_user_id: USER_A, tags: ["vip", "recorrente"] });
|
||||
const res = (await crmGetLead.handler(
|
||||
{ lead_id: row.id as string },
|
||||
makeCtx(leadResolver([row])),
|
||||
)) as { lead: Record<string, unknown> };
|
||||
|
||||
expect(res.lead.owner_user_id).toBe(USER_A);
|
||||
expect(res.lead.owner_user_name).toBe("Alice");
|
||||
expect(res.lead.stage).toEqual({ id: STAGE_1, name: "Qualificação" });
|
||||
expect(res.lead.tags).toEqual(["vip", "recorrente"]);
|
||||
// antigos preservados:
|
||||
expect(res.lead.stage_id).toBe(STAGE_1);
|
||||
expect(res.lead.status).toBe("open");
|
||||
expect(res.lead.pipeline_id).toBeDefined();
|
||||
expect(res.lead.title).toBe("Pedido #1");
|
||||
});
|
||||
|
||||
it("list: sem owner (fila) ⇒ owner_user_name=null, stage ainda resolvido", async () => {
|
||||
const row = leadRow({ owner_user_id: null, tags: [] });
|
||||
const res = (await crmListLeads.handler(
|
||||
{ limit: 20 } as Parameters<typeof crmListLeads.handler>[0],
|
||||
makeCtx(leadResolver([row])),
|
||||
)) as { leads: Array<Record<string, unknown>> };
|
||||
|
||||
const lead = res.leads[0]!;
|
||||
expect(lead.owner_user_id).toBeNull();
|
||||
expect(lead.owner_user_name).toBeNull();
|
||||
expect(lead.stage).toEqual({ id: STAGE_1, name: "Qualificação" });
|
||||
// LGPD: nenhum campo de PII do owner além do nome.
|
||||
const keys = Object.keys(lead);
|
||||
expect(keys).not.toContain("owner_email");
|
||||
expect(keys).not.toContain("owner_phone");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user