Skip to content

Commit 424c929

Browse files
melgarafaelclaude
andcommitted
feat(atendimento): fila de leads por atendente ganha porta, e o rodízio passa a distribuir lead (#144)
Pedido por @WerikoEntusiasta: distribuir leads em rodízio entre atendentes, com cada um vendo só os seus. Medido antes de escrever uma linha: as duas coisas já existiam INTEIRAS no backend — o worker de rodízio (`lib/routing/`, modo `round_robin`, elegibilidade por disponibilidade e horário) e a RLS de visibilidade (`fn_can_view_lead`, `fn_can_view_conversation`, modo `own`). O que não existia era a PORTA. Nenhum arquivo de `app/` ou `components/` consumia `/api/v1/settings/routing`, e `visibility_mode` só aparecia sendo LIDO em `app/app/layout.tsx` — escrito por ninguém. O único jeito de ligar a feature que o contribuidor pediu era `UPDATE` à mão no Postgres. Num produto self-host isso é a feature não existir. TRÊS BURACOS, e os dois últimos só apareceram porque a prova foi pela tela: 1. **Sem tela.** Entra `/app/settings/atendimento` (manager+, que é o que a matriz da spec 13 §4 dá para "atendimento/routing"), com as duas decisões juntas porque uma sem a outra quebra: distribuir sem restringir deixa todos vendo a carteira do colega; restringir sem distribuir deixa o funil vazio. A tela avisa justamente essa combinação morta. Registrada em `lib/navigation/registry.ts` — tela sem porta é o mesmo defeito de novo. 2. **O rodízio distribuía CONVERSA e não LEAD.** `fn_conversation_assign` grava `conversations.assigned_to_user_id` e não toca em `crm_leads.owner_user_id`. Com `visibility_mode='own'` — exatamente o que a issue pede — lead sem dono não aparece para NINGUÉM: o atendente receberia a conversa e abriria um funil vazio. A restrição funcionaria e a fila não existiria. O worker agora adota os leads abertos do contato, e só os SEM DONO (nem humano nem agente de IA): roubar lead com dono transformaria o rodízio em reatribuição silenciosa a cada mensagem nova. 3. **`PATCH /api/v1/settings/routing` era um no-op silencioso.** A única policy de escrita de `organizations` é `orgs_write_platform_admin`, com `USING (fn_is_platform_admin())`. Pelo client de sessão o UPDATE de um manager casa ZERO linhas — e o PostgREST devolve sucesso, porque "nada casou o filtro" não é erro. A tela dizia "salvo" e o reload trazia o estado antigo. Medido no banco: manager → 0 linhas, postgres → 1 (controle positivo). Ninguém tinha notado porque o dono do repo e o owner criado pelo `bootstrap-owner.ts` são platform_admin; quem tropeça é o segundo admin convidado e todo manager. Consertei a CLASSE, não a instância: `updateTenant` (perfil da org, admin-only) tinha o mesmo defeito e está no mesmo commit. PROVAS - `distribuicao-atendimento.spec.ts` dirige o frontend: acha a tela pela navegação (não pela URL), liga rodízio + restrição, salva, RECARREGA e confere que o estado voltou do banco, e confirma que a API concorda com a tela. Mais o RBAC pelo servidor (agent → 403), porque o redirect da página é conforto, não defesa. **3 testes verdes** contra Supabase local com o `baseline.sql` aplicado. - Sabotagem: voltar o PATCH para o client de sessão deixa a spec vermelha — foi assim que o defeito nº 3 apareceu. - Unit: `routing-adota-lead.test.ts` guarda as condições da adoção (sabotado nos dois eixos) e `atendimento-config-visibilidade.test.ts` cobra que um corpo sem `visibility_mode` PRESERVE a restrição em vez de voltar ao default — o jeito de uma org perder a restrição sem ninguém pedir. - Sem mudança de schema: `settings` é jsonb e as colunas de dono já existem. - gov:verify verde: 258 arquivos, 2381 testes. Closes #144 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HstH7nNmrTvCtZsasppeHj
1 parent f261cef commit 424c929

12 files changed

Lines changed: 896 additions & 15 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,8 @@ jobs:
124124
rbac-roles.spec.ts inbox-scope.spec.ts reset-password-mfa.spec.ts \
125125
degradacao-silenciosa.spec.ts vps-webhook-outbound-ssrf.spec.ts \
126126
kanban-owner-filter.spec.ts queue-assign.spec.ts \
127-
risk-radar.spec.ts invite-lifecycle.spec.ts system-update.spec.ts
127+
risk-radar.spec.ts invite-lifecycle.spec.ts system-update.spec.ts \
128+
distribuicao-atendimento.spec.ts
128129
env:
129130
INTERNAL_SECRET: ci-placeholder-nao-e-segredo
130131
CPF_ENCRYPTION_KEY: ci-placeholder-nao-e-segredo
@@ -143,10 +144,11 @@ jobs:
143144
{
144145
echo "## E2E — cobertura deste job"
145146
echo ""
146-
echo "**Rodou (15 de 20 specs):** smoke, auth, error-pages, password-recovery,"
147+
echo "**Rodou (16 de 33 specs):** smoke, auth, error-pages, password-recovery,"
147148
echo "signup-journey, rbac-roles, inbox-scope, reset-password-mfa,"
148149
echo "degradacao-silenciosa, vps-webhook-outbound-ssrf, kanban-owner-filter,"
149-
echo "queue-assign, risk-radar, invite-lifecycle, system-update."
150+
echo "queue-assign, risk-radar, invite-lifecycle, system-update,"
151+
echo "distribuicao-atendimento."
150152
echo ""
151153
echo "**Não rodou (5):**"
152154
echo "- precisa de WAHA: followup-journey, webhooks"

app/actions/settings/updateTenant.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { headers } from "next/headers";
44
import { revalidatePath } from "next/cache";
55

6+
import { createAdminClient } from "@/lib/supabase/admin";
67
import { createClient } from "@/lib/supabase/server";
78
import { audit } from "@/lib/audit";
89
import { tenantSchema, type TenantInput } from "@/lib/schemas/settings";
@@ -27,7 +28,26 @@ export async function updateTenant(input: TenantInput): Promise<UpdateTenantResu
2728
return { ok: false, error: "forbidden_role" };
2829
}
2930

30-
const supabase = await createClient();
31+
/**
32+
* A ESCRITA EM `organizations` VAI PELO ADMIN CLIENT — e não é preguiça.
33+
*
34+
* A única policy de escrita da tabela é `orgs_write_platform_admin`, com
35+
* `USING (fn_is_platform_admin())`. Pelo client de sessão, o UPDATE de quem não
36+
* é super-admin de plataforma casa ZERO linhas — e o PostgREST devolve sucesso,
37+
* porque "nenhuma linha casou o filtro" não é erro. Resultado: a tela dizia
38+
* "salvo", nada era gravado, e recarregar mostrava o estado antigo.
39+
*
40+
* Medido em Postgres com o baseline aplicado (issue #144): sob `authenticated`
41+
* com o JWT de um manager, `update organizations` devolve 0 linhas; sob
42+
* postgres, 1. Ninguém tinha notado porque o dono do repo e o owner criado pelo
43+
* `bootstrap-owner.ts` SÃO platform_admin — quem tropeça é o segundo admin
44+
* convidado e qualquer manager.
45+
*
46+
* O gate continua sendo o de cima (papel resolvido de fonte confiável), e o
47+
* filtro por `organization_id` é explícito, como a doutrina exige de todo
48+
* handler que usa service role.
49+
*/
50+
const supabase = createAdminClient();
3151
const hdrs = await headers();
3252
const requestId = hdrs.get("x-request-id");
3353
const ip = hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;

app/api/v1/settings/routing/route.ts

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
2-
* GET /api/v1/settings/routing — lê organizations.settings.routing (manager+).
3-
* PATCH /api/v1/settings/routing — grava a config de roteamento (manager+).
2+
* GET /api/v1/settings/routing — lê a config de ATENDIMENTO (manager+):
3+
* `organizations.settings.routing` + `settings.visibility_mode`.
4+
* PATCH /api/v1/settings/routing — grava as duas (manager+).
45
*
56
* Rota v1 dedicada (não o Server Action updateTenant): a matriz spec 13 §4 nota
67
* 5 separa autz — routing/atendimento é manager+, enquanto o perfil da org
@@ -9,6 +10,22 @@
910
*
1011
* knobs (max_retries, backoff_seconds) são CONFIG — o worker de G5-02 LÊ daqui;
1112
* nunca constantes hardcoded no worker (doutrina). org de fonte confiável.
13+
*
14+
* POR QUE `visibility_mode` ENTRA AQUI (issue #144). A matriz de papéis da spec
15+
* 13 §4 trata as duas como a MESMA linha — "config de atendimento/roteamento:
16+
* manager+" — e a §3.5 as declara no mesmo bloco. Separar em duas rotas com o
17+
* mesmo gate seria inventar uma fronteira que a spec não tem, e deixaria a tela
18+
* fazendo dois round-trips para salvar uma decisão só ("como distribuo, e quem
19+
* enxerga o quê").
20+
*
21+
* Até esta mudança, `visibility_mode` era LIDO em `app/app/layout.tsx` e escrito
22+
* por NINGUÉM: a restrição existia na RLS, no schema e na doutrina, e não havia
23+
* caminho no produto para ligá-la — só `UPDATE` à mão no banco. Num produto
24+
* self-host isso equivale a não existir.
25+
*
26+
* `visibility_mode` fica em `settings.visibility_mode`, irmão de
27+
* `settings.routing` e não dentro dele: é esse caminho exato que
28+
* `fn_can_view_lead`/`fn_can_view_conversation` leem na RLS.
1229
*/
1330
import { randomUUID } from "node:crypto";
1431
import type { NextRequest } from "next/server";
@@ -17,7 +34,9 @@ import { ok, fail } from "@/lib/api/wrappers";
1734
import { ApiError } from "@/lib/api/types";
1835
import { audit } from "@/lib/audit";
1936
import { requireRole } from "@/lib/auth/require-role";
20-
import { routingConfigSchema, validateRequest } from "@/lib/schemas";
37+
import { atendimentoConfigPatchSchema, routingConfigSchema, validateRequest } from "@/lib/schemas";
38+
import { DEFAULT_VISIBILITY_MODE, type VisibilityMode } from "@/lib/auth/types";
39+
import { createAdminClient } from "@/lib/supabase/admin";
2140
import { createClient } from "@/lib/supabase/server";
2241

2342
export const dynamic = "force-dynamic";
@@ -41,7 +60,11 @@ export async function GET(_req: NextRequest): Promise<Response> {
4160

4261
const settings = (orgRow?.settings as Record<string, unknown> | null) ?? {};
4362
const routing = routingConfigSchema.catch(DEFAULT_ROUTING).parse(settings.routing ?? {});
44-
return ok(routing, { requestId });
63+
// O default é o MESMO de `lib/auth/types.ts`, que é o que a RLS assume quando
64+
// a chave não existe (`coalesce(..., 'own_and_unassigned')`). Divergir aqui
65+
// faria a tela mostrar um estado que o banco não pratica.
66+
const visibility_mode = (settings.visibility_mode as VisibilityMode | undefined) ?? DEFAULT_VISIBILITY_MODE;
67+
return ok({ ...routing, visibility_mode }, { requestId });
4568
}
4669

4770
export async function PATCH(req: NextRequest): Promise<Response> {
@@ -52,7 +75,7 @@ export async function PATCH(req: NextRequest): Promise<Response> {
5275

5376
let input;
5477
try {
55-
input = await validateRequest(routingConfigSchema, req);
78+
input = await validateRequest(atendimentoConfigPatchSchema, req);
5679
} catch (err) {
5780
if (err instanceof ApiError) {
5881
return fail(err.code, err.message, err.status, {
@@ -63,7 +86,26 @@ export async function PATCH(req: NextRequest): Promise<Response> {
6386
throw err;
6487
}
6588

66-
const supabase = await createClient();
89+
/**
90+
* A ESCRITA EM `organizations` VAI PELO ADMIN CLIENT — e não é preguiça.
91+
*
92+
* A única policy de escrita da tabela é `orgs_write_platform_admin`, com
93+
* `USING (fn_is_platform_admin())`. Pelo client de sessão, o UPDATE de quem não
94+
* é super-admin de plataforma casa ZERO linhas — e o PostgREST devolve sucesso,
95+
* porque "nenhuma linha casou o filtro" não é erro. Resultado: a tela dizia
96+
* "salvo", nada era gravado, e recarregar mostrava o estado antigo.
97+
*
98+
* Medido em Postgres com o baseline aplicado (issue #144): sob `authenticated`
99+
* com o JWT de um manager, `update organizations` devolve 0 linhas; sob
100+
* postgres, 1. Ninguém tinha notado porque o dono do repo e o owner criado pelo
101+
* `bootstrap-owner.ts` SÃO platform_admin — quem tropeça é o segundo admin
102+
* convidado e qualquer manager.
103+
*
104+
* O gate continua sendo o de cima (papel resolvido de fonte confiável), e o
105+
* filtro por `organization_id` é explícito, como a doutrina exige de todo
106+
* handler que usa service role.
107+
*/
108+
const supabase = createAdminClient();
67109
const { data: orgRow, error: readErr } = await supabase
68110
.from("organizations")
69111
.select("settings")
@@ -72,7 +114,13 @@ export async function PATCH(req: NextRequest): Promise<Response> {
72114
if (readErr) return fail("internal_error", readErr.message, 500, { requestId });
73115

74116
const currentSettings = (orgRow?.settings as Record<string, unknown> | null) ?? {};
75-
const nextSettings = { ...currentSettings, routing: input };
117+
const { visibility_mode, ...routing } = input;
118+
// Merge não-destrutivo em DOIS níveis: preserva as demais chaves de `settings`
119+
// e, quando `visibility_mode` não vem no corpo, preserva a que já valia — um
120+
// cliente antigo (que só conhece o roteamento) não pode desligar a restrição
121+
// de visibilidade sem pedir.
122+
const nextSettings: Record<string, unknown> = { ...currentSettings, routing };
123+
if (visibility_mode !== undefined) nextSettings.visibility_mode = visibility_mode;
76124

77125
const { error: updErr } = await supabase
78126
.from("organizations")
@@ -87,8 +135,16 @@ export async function PATCH(req: NextRequest): Promise<Response> {
87135
resourceType: "organization",
88136
resourceId: activeOrg.orgId,
89137
requestId,
90-
metadata: { routing: input },
138+
metadata: { routing, ...(visibility_mode !== undefined ? { visibility_mode } : {}) },
91139
});
92140

93-
return ok(input, { requestId });
141+
return ok(
142+
{
143+
...routing,
144+
visibility_mode:
145+
visibility_mode ??
146+
((currentSettings.visibility_mode as VisibilityMode | undefined) ?? DEFAULT_VISIBILITY_MODE),
147+
},
148+
{ requestId },
149+
);
94150
}

0 commit comments

Comments
 (0)