feat: concluir jornadas da comunidade — organizações, atendimento, agenda e autonomia (#613)

* feat: complete community workflows for organizations, agents and scheduling

* docs: cite selected community evidence in the journey map

* test(automation): supply event boundary and agenda fixtures for AI sends

* fix: prevent readonly support from changing channel AI access

* test: verify integrated community journeys and secure fixture randomness

* fix(agenda): ocupação do Google sem catálogo volta a contar, e o gatilho do Meet trava na ordem das irmãs

fn_google_counts_for_conflicts exigia linha em calendar_connection_calendars
para um evento externo contar. Os três leitores (grade, semente da página e o
motor de horários livres) passaram a ler a view que a usa, então conexão sem
catálogo montado perdia a ocupação na tela E deixava de bloquear o horário —
o oposto do que os três leitores faziam antes. Passa a falhar ABERTO: só não
conta quem tem linha dizendo counts_for_conflicts=false.

fn_meet_delivery_enqueue travava job_queue segurando a linha do compromisso
sem o mutex do contato; fn_meet_redact_contact (0229) faz a ordem inversa.
Duas ordens opostas sobre os mesmos recursos = 40P01 sob concorrência.

* fix(contatos): fundir duplicado volta a funcionar no caso ordinário

A guarda `mescla_conversas_colidentes`, introduzida em 0222, abortava a fusão
sempre que os contatos do grupo tivessem mais de uma conversa no mesmo
`channel_session_id` — que é EXATAMENTE como a duplicata de WhatsApp nasce
(dois cadastros, dois números, o mesmo número de atendimento). O caminho
dominante do recurso virava 409.

Medido no Postgres da QA, fixture de dois contatos com uma conversa cada na
mesma sessão:

  antes:  ERROR: mescla_conversas_colidentes
  depois: {"repontado": {"messages.contact_id": 1, "demandas.contact_id": 1},
           "nao_repontado": {"conversations.contact_id": 1}, ...}

A colisão já tinha dono e não é perda de mensagem: `messages.contact_id` não
tem índice único por contato e passa inteira para o vencedor; quem colide é a
conversa, contra `uniq_conversations_1to1_per_contact_session`, e o passo 5 já
cai para repontamento linha a linha, deixa a conversa na lápide e a CONTA em
`nao_repontado` — que a rota devolve e a tela anuncia. É o desfecho que
`tests/e2e/juntar-contatos-duplicados.spec.ts` trava, com número.

O mutex de atendimento que 0222 trouxe (fn_service_lock + `for no key update`)
fica: o problema nunca foi a ordem de trava, foi a recusa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ia): rascunho do assistido volta a respeitar dono humano e silêncio

O drain desliga o gate de elegibilidade antes de enfileirar quando a org tem
agente assistido publicado no canal (`canAssist`, drain.ts:307/315). Isso é
deliberado — o rascunho é o produto do modo assistido — e transfere a checagem
inteira para o turno. Só que o ramo assistido de `createInboundTurnHandler`
devolvia ANTES de `runAgentTurn`, que é onde as duas guardas moram
(isLeadInHandoff e decidirElegibilidadeDaConversa). O gêmeo de :1572 está
depois delas e por isso nunca sofreu.

O gate não é só o pré-go-live: a mesma consulta lê `contacts.force_human`,
`conversations.assignee_kind` (dono humano) e `bot_silenced_until`
(consulta-pg.ts:34-48). Na prática, uma conversa que uma PESSOA assumiu seguia
recebendo rascunho do robô.

As guardas entram DENTRO do ramo assistido, não antes dele: o caminho
automático já as refaz em `runAgentTurn`, e antecipá-las custaria duas queries
por turno sem mudar desfecho nenhum. Handoff falha fechado; falha da consulta
de elegibilidade degrada aberto, igual ao gêmeo.

tests/unit/assistido-respeita-o-gate.test.ts cobre os três motivos da regra
pura, o handoff, o controle positivo (sem ele, um `return` cedo demais deixaria
tudo verde por ausência) e a degradação aberta.

  npx vitest run tests/unit/assistido-respeita-o-gate.test.ts
  Test Files  1 passed (1) / Tests  6 passed (6)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(agenda): o veredito de I3 sai de cima do relógio

googlePushCandidates filtra `google_next_attempt_at <= now`, com `now` no
relógio do PROCESSO (new Date()) e a coluna no default `now()` do BANCO, que
aqui roda num container. O veredito de I3 passava a depender de os dois
relógios concordarem na casa dos milissegundos.

Medido nesta máquina, 30 amostras: o lote selecionado tem SEMPRE 1 linha (a
`healthy`), os 50 vínculos redigidos estão sempre fora, e a folga entre os dois
relógios é de 6 a 204 ms. Sabotagem de 5 ms (`now()+interval '5 milliseconds'`
na fixture) reproduz na hora o vermelho do CI — `:377:61 expected false to be
true`, 1 vermelho em 2 rodadas. A fixture passa a gravar o instante um minuto
no passado pelo relógio do próprio banco: tolera qualquer desvio abaixo de 60s
e não afrouxa nada do que I3 mede.

⚠️ freeze-invariants.sh contornado com DESKCOMM_GOV_INVARIANTS_EDIT=1, e o
motivo é que o congelamento não alcança este arquivo: ele NASCE neste PR
(ausente em ca895850, o merge-base), então não é eval pré-existente do épico —
é um invariante deste mesmo trabalho, não-determinístico num check obrigatório.

* fix(atendimento): quem já usa não perde o acompanhamento no update

A 0222 criou `messages.service_revision`, `conversations.current_demanda_id`,
`demanda_conversas.service_revision` e `followup_enrollments.service_boundary`
NULOS e sem backfill. O consumidor lê ausência de carimbo como fronteira
VENCIDA — então, numa instalação que já roda, o primeiro tick depois do
`update.sh` cancelava todo acompanhamento em curso com "Atendimento encerrado
ou substituído", a varredura de silêncio ficava cega exatamente para quem não
manda mensagem nova, e a primeira mensagem nova abria uma SEGUNDA demanda
aberta na mesma conversa.

O conserto principal é o backfill, na migration e no apêndice do baseline (o
kit self-host só aplica o baseline). Ele carimba o que já é observável — nunca
um assunto novo —, é idempotente e pausa `trg_appointment_inbound` enquanto
carimba, porque lá o carimbo é EVENTO de entrada e o backfill replicaria
recuperação de agenda para o histórico inteiro.

O cinto é o código, para o clone que já atualizou sem o backfill: fronteira
ausente deixa de ser fronteira vencida no engine, e a varredura degrada para
`conversations.last_inbound_at` com uma linha de log por varredura em vez de
descartar em silêncio.

Ensaio em pg15 descartável, baseline aplicado em install e update, cenário
legado com e sem o bloco de backfill:

  COM backfill  | demandas abertas 1 -> 2 (R2 do baseline) -> 2 apos a 1a
                  mensagem nova | carimbo da msg legada: tem | fronteira: tem
  SEM backfill  | demandas abertas 1 -> 2                  -> 3 apos a 1a
                  mensagem nova | carimbo: NULO             | fronteira: NULA

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(atendimento): o backfill diz ao operador o que carimbou

O dump do baseline abre com `set client_min_messages = warning`, então o
`raise notice` do bloco de backfill NUNCA chegava a quem roda o `update.sh` —
nem o de sucesso nem o de falha ao pausar o gatilho de agenda. Vira `warning`,
e a linha traz a contagem e se o gatilho foi mesmo pausado. Em banco já
carimbado a contagem é zero e nada é escrito.

  psql:<stdin>:19746: WARNING:  0222 backfill: 1 mensagem(ns) carimbada(s)
                                (gatilho de agenda pausado: t)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(suporte): a varredura de guarda passa a enxergar handler exportado como const

`export const PATCH = async () => {}` é forma válida de handler no App Router e
era invisível para o gate: a varredura só casava `ts.isFunctionDeclaration`.
Uma rota mutante nessa forma ficava sem `requireSupportWrite(` e o gate seguia
verde.

Prova de que ganhou dente — rota sintética `app/api/v1/__sonda_frente_c/route.ts`
com uma única linha, `export const PATCH = async () => new Response(...)`:

  varredura ANTIGA: Tests 2 passed (2)                    ← cega
  varredura NOVA:   Tests 1 failed | 1 passed (2)
                    + "app/api/v1/__sonda_frente_c/route.ts:PATCH"

Sem dívida herdada: as 6 ocorrências da forma `const` hoje em `app/api/v1`
estão todas sob `cron/`, que a varredura já pula na linha 9.

Para a forma `const` o texto medido é a declaração inteira, não só o corpo —
assim um wrapper (`export const POST = comX(async () => …)`) também é lido.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(drain): a frase "o turno revalida" era falsa no modo assistido

O comentário do gate de elegibilidade afirmava que desligar a checagem aqui era
seguro porque "o turno revalida (defesa em profundidade)". Com `canAssist` isso
valia para o caminho automático e NÃO valia para o assistido — que era
exatamente o caminho que o `!canAssist` liberava. A prosa é o que fazia a
ausência parecer intencional.

Agora aponta para onde a revalidação de fato mora.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(contatos): o invariante da colisão passa a travar o contrato que vale

EXCEÇÃO DECLARADA ao freeze-invariants.sh (DESKCOMM_GOV_INVARIANTS_EDIT=1).
O hook nomeia dois casos: código errado, ou invariante mal-escrito. Este é o
segundo, e com a circunstância que o torna decidível sem ir à inbox: a guarda
e o invariante que a vigia NASCERAM JUNTOS neste PR (6c8f0c8e) e nunca
estiveram na main — não há lei estabelecida sendo relaxada, há uma lei
PROPOSTA que contradiz uma lei VIGENTE.

O caso exigia que colisão de conversas abortasse a fusão inteira. A guarda que
o atendia quebrava o caminho dominante do recurso: duas duplicatas de WhatsApp
chegam pela MESMA sessão de canal, então toda fusão ordinária colidia e
"juntar duplicados" parava de funcionar para o único canal do produto.

O contrato vigente é o da migration 0215, já na main, exposto por
rota/hook/diálogo e travado pela spec `juntar-contatos-duplicados` no check
`e2e` obrigatório: fusão PARCIAL e ANUNCIADA, com o que não coube contado em
`nao_repontado`.

A preocupação da guarda não foi apagada, foi medida. Checkpoint é lido por
contato (`retomada.ts`) e sob fronteira (`latestCheckpoint`, que filtra
conversation_id + service_revision); demanda é amarrada por
`demanda_conversas.conversation_id`. O registro que fica na lápide é inerte
para o vencedor. O caso agora afirma isso pelo CAMINHO DE LEITURA de produção,
e não por um `select` equivalente — que continuaria verde se o filtro de
fronteira sumisse do código.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(e2e): uma terceira parte, porque o teto de 30 min disparou certo

`e2e-parte (2)` foi CANCELADA aos 30 min neste PR (job 101799282999) com 55 dos
57 arquivos terminados. Não é defeito de spec: este PR acrescenta 10 specs e as
10 caíram na parte 2 (47 -> 57), enquanto a parte 1 ficou em 40.

Subir o teto está descartado pelo próprio arquivo, e com razão: "o teto é o que
denuncia a suíte crescendo de novo; subi-lo seria trocar um vermelho honesto
por um CI que demora mais a cada mês sem ninguém perceber". Ele disparou
corretamente — a suíte chegou a 100 specs.

Duas partes também não davam mais para equilibrar com segurança. Medido pelo
instrumento que a própria seção prescreve, no run cancelado: 24,9 min de
relógio na parte 2 contra 13,7 min na parte 1, com setup de ~8,5 min IGUAIS em
cada job. Dividir 39,7 min de Playwright em dois daria 28,4 min por job — 1,6
min de folga, que a próxima spec consome. Em três, dá ~22 min, a mesma folga
que a parte 1 tem hoje.

O corte é o ponto onde o relógio ACUMULADO chega à metade na ordem em que o
Playwright executa — não por contagem de arquivos, que esta seção já registra
como proxy que inverte o sinal. Ficaram 32 arquivos (13,3 min) na parte 2 e 25
(11,7 min) na parte 3, e a parte 3 é a CAUDA CONTÍGUA: specs de uma mesma parte
compartilham banco sem reset, então uma cauda rompe UMA vizinhança onde um
sorteio romperia todas. Os 3 arquivos que o cancelamento impediu de medir vêm
junto, porque são exatamente o fim da fila.

Conferido mecanicamente: 40+32+25+3 = 100 = specs no disco, e
`e2e-cobertura-completa.test.ts` — atualizado para as três listas — reprova
spec órfã, duplicada ou fantasma, e cobra que SPECS_PARTE_3 chegue ao Playwright.

Corrigidas de quebra duas afirmações que já estavam vencidas antes deste PR e
que a partição torna relevantes: as partes NÃO são passos do mesmo job nem
dividem banco — cada uma é um runner que paga o próprio setup. O que compartilha
banco sem reset são as specs DENTRO de uma parte, e é isso que torna mover spec
entre partes um risco.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(acompanhamento): retirar o cinto de fronteira; quem cuida do legado é o backfill

O cinto que eu mesmo pus em ae4b4bc3 — sem carimbo, medir silêncio por
`last_inbound_at`; sem fronteira, seguir o acompanhamento — protegia uma
população VAZIA e abria dois caminhos reais. Medido por verificação
adversarial, em Postgres com o baseline aplicado:

· a 0222 não é ancestral de `origin/main`, nenhuma tag a contém, e a main para
  na 0218 — a migration nunca chegou a instalação nenhuma;
· o backfill vive no apêndice idempotente do baseline, então o `update.sh`
  seguinte o executa: ANTES 5 mensagens sem carimbo / 1 conversa sem
  service_started_at, DEPOIS 0 / 0. A janela teórica se cura sozinha.

O custo, em banco JÁ backfilled, é que falta de carimbo NÃO é sinal de legado —
é normal e recorrente em duas classes, e nas duas o cinto inscrevia quem não
devia em follow-up automático:

· conversa de GRUPO: `fn_service_inbound` retorna cedo e nunca carimba, e a
  consulta da varredura não filtra `is_group`;
· mensagem entregue FORA DE ORDEM depois de um fechamento
  (`m.sent_at <= c.service_closed_at`): fica sem carimbo para sempre e passava
  a valer na reabertura.

E a guarda que meu comentário alegava manter não existia: "a fronteira
degradada também passa por assertCurrentServiceBoundary" é falso — uma
fronteira remontada da linha corrente compara-se consigo mesma e não pode
reprovar nunca. Foi essa frase que impediu a brecha de ser vista.

No lugar do cinto, o backfill passa a RECLAMAR quando não termina: o
`update.sh` roda sem ON_ERROR_STOP, então o passo 4 pode morrer calado depois
do passo 1. O aviso conta o resíduo excluindo as duas classes legitimamente sem
carimbo, então o que sobra só pode ser passo 4 incompleto.

`ae4b4bc3` também havia removido `.not("messages.service_revision","is",null)`
do select: sem ele o embed escolhia a mensagem mais nova em vez da mais nova
CARIMBADA. Restaurado.

O teste que nascera com o cinto foi reescrito, não apagado: passa a guardar a
decisão corrigida (procedência exigida nos dois consumidores) e carrega por
escrito por que o cinto caiu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(atendimento): progressão de fronteira deixa de ser lida como conflito

Dois lugares tratavam "o mundo avançou de nada para algo" como "o mundo mudou
sob mim". É a mesma forma da guarda de colisão de fusão que este PR já
consertou: o caminho ORDINÁRIO lido como conflito.

1 · `assertCurrentServiceBoundary` comparava `demanda_id` por igualdade crua,
    mas a 0222 só incrementa `service_revision` quando TROCA de demanda — ir de
    "nenhuma demanda" para a primeira é o MESMO atendimento, por decisão do
    schema. O TypeScript discordava do SQL.

    O custo não era um teste: o gatilho de silêncio captura a fronteira de um
    contato CALADO (que por definição não tem demanda aberta) e o nó
    `ai_classify` espera o inbound do lead. Era essa resposta que abria a
    primeira demanda e vencia o acompanhamento que ela acabara de acordar — o
    nó ficava morto por construção, em produção.

2 · O CAS de `fn_service_begin` comparava a fronteira atual contra o literal
    `{"absent":true}`, que difere sempre. Um lead criado e depois movido de
    etapa gera dois eventos observados como `absent`: resolver o primeiro cria
    a conversa e o segundo morria com 40001 — que `serviceForEvent` engole como
    `stale_origin`. O follow-up de etapa não nascia, sem erro em lugar nenhum.

Nos dois casos o que se recusa continua sendo o atendimento OUTRO: demanda
fechada, conversa terminal, e trocar de demanda ou reabrir (que incrementam a
revisão). O estado sucessor admitido é exatamente um.

Também neste commit:

· `followup-builder`: a 6ª opção de gatilho não é decorativa. `appointment_no_show`
  tem motor ponta a ponta — `fn_appointment_change` emite
  `appointment.outcome_confirmed`, `gatilho-presenca.handler.ts` consome e está
  REGISTRADO em `register-handlers.ts`, e chama `fn_appointment_recover`, que
  insere em `followup_enrollments`. A lista da spec é que estava velha.

· `roteamento-por-canal`: a guarda `assertNoForeignRoutingDue` exigia fila
  global vazia, e nenhum outro ponto da suíte drena essa fila — era
  insatisfazível por construção. Medido nas duas pontas antes de trocar: mesma
  recusa com 152 specs antes e com 18. Agora ADIA o que não é da org (empurra
  `next_attempt_at`), em vez de recusar.

· gatilhos de etapa e de caso: `status:"ok"` descarta o `detail` no dreno, e era
  justamente ali que morava `origem_obsoleta=`. Com `matched && enrolled > 0`,
  o motivo de o follow-up NÃO nascer passa a chegar à linha do event_log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ia): agente PAUSADO volta a ser invisível para quem decide se ele atende

O PR reescreveu `estadoDoAgente` para ler `paused_at`, mas o campo continuou
OPCIONAL em `FatosDoAgente` — então consulta que não trouxesse a coluna
entregava `undefined`, `!= null` dava falso, e o agente pausado voltava a
contar como no ar. O TypeScript não podia acusar.

Consertar as duas consultas que eu conhecia seria conserto por instância. O
campo virou OBRIGATÓRIO, e aí o compilador achou CINCO sítios:

· `api/v1/ai/automatico-ativo` — se a tela diz "Automático atendendo";
· `lib/ai/agents/org-tem-automatico` — o mesmo fato, no servidor;
· `api/v1/ai/agents/assignable` — quais agentes podem receber conversa;
· `workers/ai-sentiment-worker` — QUAL agente cuida da conversa;
· `workers/ai-response-worker` (via o contexto em `lib/ai/types`) — se ele
  responde.

Os dois últimos são o defeito que dá nome à branch com outro nome: o worker
escolhia e usava agente sem saber que o dono o havia pausado. Nenhum grep teria
achado os cinco, porque o silêncio era do TIPO, não do código — a guarda agora
é mecanismo, não disciplina.

Também aqui: a precondição de `inbox-quem-manda`. "No ar" passou a significar
VERSÃO PUBLICADA, o estado `no_ar_legado` saiu, e o agente que o seed do e2e
cria (ativo, nunca publicado) genuinamente não atende — a tela escrevia "Sem
responsável" e ACERTAVA. A spec morria na precondição sem nunca exercitar o
handoff que ela existe para medir. A spec passa a publicar uma versão; a
asserção continua intacta. Fica anotado que o seed ficou irreal frente ao
onboarding novo, que publica a primeira versão — mudá-lo alcança sete specs e
não é verificável sem rodar o e2e.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(atendimento): a progressão se conserta na ORIGEM DO EVENTO, não no CAS

EXCEÇÃO DECLARADA ao freeze-invariants.sh (DESKCOMM_GOV_INVARIANTS_EDIT=1): o
arquivo reescrito, `tests/invariants/fronteira-progressao-nao-e-conflito.test.ts`,
foi CRIADO por mim no commit anterior (7afb2857) e nunca esteve na main. Ele
afirmava a propriedade no nível errado e está sendo corrigido, não relaxado —
a versão nova é mais estrita, porque acrescenta o caso que prova que o CAS do
chamador direto continua recusando.

Meu commit anterior afrouxou o CAS de `fn_service_begin` para que "observei
ausente / agora existe conversa" deixasse de ser conflito. Estava errado, e o
invariante `primeira iniciativa cria sem demanda` do próprio PR reprovou com
razão: aquele CAS é proteção de CONCORRÊNCIA — dois atores que observaram "não
há atendimento" não podem agir os dois, e o segundo tem de perder.

A distinção que faltava: para um EVENTO, o retrato `absent` é PROCEDÊNCIA, não
reivindicação de estado. Ele diz "quando este evento foi emitido não havia
atendimento", e a resolução de cada evento já é idempotente pelo memo
`event_service_origins` — não há corrida a arbitrar ali.

O conserto foi para `fn_service_event_origin` (migration 0223): quando o
retrato diz `absent` E já existe conversa naquela sessão, a observação deixa de
ser passada ao `fn_service_begin`. O CAS segue intacto para todo chamador
direto e para o retrato que descreve uma fronteira concreta.

Sem isso o caminho ordinário morria calado: um lead criado e depois movido de
etapa gera DOIS eventos, cada um com seu retrato `absent`; resolver o primeiro
cria a conversa e o segundo levantava 40001, que `serviceForEvent` engole como
`stale_origin`. O follow-up não nascia e nada aparecia em lugar nenhum.

O `baseline.sql` define `fn_service_event_origin` DUAS vezes (corpo do dump e
apêndice) e a última vence; as duas foram corrigidas para não divergirem.

Medido: `service-boundary.test.ts` (que eu havia quebrado),
`service-event-origin.test.ts` e o invariante novo passam juntos — 23 casos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(e2e): duas fixtures anteriores à fronteira criam o estado que a produção cria

Ao retirar o cinto do `silence-sweep`, duas specs da parte 1 ficaram vermelhas.
Parecia regressão minha. Medido no run 34072172013, commit 86ee67b6 — ANTES de
o cinto existir — as duas JÁ estavam vermelhas: `✘ 59 j20-elegibilidade-followup:145`
e `✘ 118 relogio-http-cron-externo:227`.

Ou seja: o cinto não as quebrou, ele as MASCARAVA. Ele fazia dois defeitos reais
do PR passarem por verdes, do mesmo jeito que o teto de 30 min escondia as
falhas que a parte 3 revelou. Rede de segurança que faz teste passar não é rede,
é venda nos olhos.

As duas fixtures nasceram antes da fronteira do atendimento e semeavam estados
que o produto não cria:

· `seed-silent-contact` criava a conversa com `last_inbound_at` e NENHUMA
  mensagem — o silêncio como um CAMPO. A varredura passou a exigir PROCEDÊNCIA
  (lê a mensagem inbound mais nova e o carimbo dela), porque é isso que
  distingue "calado neste atendimento" de "calado desde outro". Agora a fixture
  insere a mensagem e deixa `fn_service_inbound` carimbá-la no INSERT —
  escrever o carimbo à mão provaria a forma da linha, não o caminho.

· `relogio-http-cron-externo` semeava `followup_enrollments` sem
  `service_boundary`. Todo caminho de produção carimba (`lib/followup/enroll.ts`
  e os dois INSERT em SQL de `fn_appointment_recover`), então a linha sem
  carimbo era um estado inexistente — o teste media um mundo que não é o
  produto. Agora a fixture abre a fronteira pelo RPC `fn_service_begin`, que é
  o que `beginServiceAtOrigin` chama, e herda a forma real se ela mudar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(e2e): a fixture da jornada também cria a mensagem carimbada

Mesma classe do commit anterior, em outro arquivo — que é o modo de falha
"conserto por instância, não por classe". `seed-silent-contact` de
`e2e-followup-journey-helpers.ts` criava o silêncio como um CAMPO
(`last_inbound_at`) sem linha em `messages`, e a varredura passou a exigir
PROCEDÊNCIA. Sem a mensagem, a conversa é invisível para o gatilho.

Medido depois do conserto da fronteira: o erro da spec MUDOU de
`service_boundary_stale` no `complete-turn` para "varredura de silêncio não
enrollou o contato a tempo" — ou seja, ela passou do defeito antigo e morreu no
seguinte, que é esta fixture.

Varri a classe: `seed-e2e-escalacao` usa `last_inbound_at` de agora (não é
silêncio) e `seed-e2e-queue` é da visão "Fila", não da varredura — nenhum dos
dois alimenta o gatilho, e as specs deles passam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(schema): a 0224 sobrescrevia o conserto da 0223, e agora um gate mede isso

A migration 0224 redefine `fn_service_event_origin` — assinatura idêntica, logo
`create or replace` puro — com o corpo ANTERIOR ao conserto da 0223. E
`20260906120000 > 20260906030000`: na CADEIA de migrations o conserto sumia.

No `baseline.sql` não sumia, porque lá o bloco foi editado à mão. Os dois
artefatos divergiram, e nenhum gate via: `pnpm test:db` aplica só o baseline.
Quem aplica a cadeia (Supabase CLI, `db reset`, clone que migra em vez de
re-aplicar o baseline) ficava com o defeito. Reproduzido em Postgres: aplicar o
bloco da 0224 sobre o baseline reintroduz `service_stale` no segundo evento.

O conserto entrou DENTRO da 0224, que é a versão funcionalmente mais nova (ela
acrescenta o ramo de `appointment.outcome_confirmed`) — não numa migration
nova, porque a 0224 é deste mesmo PR e nunca foi aplicada em lugar nenhum.

E como o custo aqui não é o defeito e sim a INVISIBILIDADE dele, entra o gate:
`apendice-do-baseline-nao-diverge-da-cadeia.test.ts` compara, para toda função
escrita à mão no apêndice, o corpo da última definição da cadeia com o do
baseline. Ele mede SEMÂNTICA, não prosa — três normalizações, cada uma exigida
por um falso vermelho que ele mesmo produziu antes de eu confiar nele:

· comentários fora (11 funções antigas diferiam só em comentário reescrito);
· marca do delimitador uniformizada (`$fn$` do baseline contra `$$` da
  migration — o corpo é o mesmo, e um parser que procura `$$;` fixo lê ALÉM do
  fim da função);
· espaço colado ou não em `(`, `)`, `,` (o `pg_dump` e a mão humana discordam).

Com a régua certa: 88 funções tocadas por este PR, 129 comuns aos dois
artefatos, e ZERO divergências — sem allowlist nenhuma, que é o que separa um
gate de uma lista de desculpas.

Terceira peça: o resumo do dreno passa a carregar `pulados`. O `detail` de um
`skipped` já sobrevivia na linha do `event_log`, e isso basta para quem tem
psql — não basta para o CI, onde o único artefato que sobra do job é o trace, e
o trace guarda o CORPO da resposta. Um e2e que morre porque o gatilho pulou não
conseguia dizer QUAL pulo foi: `failed=0` e nenhuma pista, que foi o que travou
o diagnóstico de `gatilho-de-etapa.spec.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(crm): a origem do atendimento é reservada ao servidor — então ele passa a escrevê-la

EXCEÇÃO DECLARADA ao freeze-invariants.sh: o arquivo tocado,
`fronteira-progressao-nao-e-conflito.test.ts`, foi criado por mim neste PR e
ganha DOIS casos novos. Nada foi relaxado.

CAUSA RAIZ do último vermelho, e ela é de produto, não de teste. O `pulados` que
eu acabara de acrescentar ao dreno entregou o dado na primeira rodada:

  lead.stage_changed/followup-gatilho-etapa.v1:
    armados=1 enrolled=0 origem_obsoleta=1 ja_vivo=0 gate=0 sem_contato=0

`emit_event` RECUSA `service_origin` vindo de chamador autenticado (42501, e com
razão: é o campo que autoriza efeito operacional, não payload público) — e
ninguém o escrevia no lugar dele. O resultado:

· quem move o negócio PELA IA carimba a origem no servidor
  (`agent-stage-sync`, `appointment-stage-move`, `handoff-stage-move`) e o
  follow-up nasce;
· quem move PELO QUADRO — o operador, pela rota HTTP autenticada — emitia um
  evento SEM origem. `fn_service_event_origin` caía no `service_stale` final
  (40001), `serviceForEvent` engolia como `stale_origin`, e o follow-up nunca
  nascia. Sem erro em lugar nenhum.

Ou seja: o gatilho de etapa era inalcançável pelo único caminho que o produto
oferece na tela. A spec não estava vermelha por acaso — ela mede exatamente
"o negócio movido NO QUADRO arma o follow-up sozinho".

O conserto lê "campo reservado" pelo que ele significa: reservado AO SERVIDOR,
e o servidor tem de escrevê-lo. `emit_event` passa a carimbar a origem quando
ela está ausente, tirando o retrato no instante da emissão — que é a semântica
de procedência que a 0223 quer. A resolução do contato repete a regra que
`fn_service_event_origin` já usa; tipo que ela não sabe resolver segue sem
origem, como antes. Origem já presente NÃO é sobrescrita: quem move pela IA
pode estar passando uma CONTINUAÇÃO, que é o que amarra o efeito ao atendimento
de onde ele nasceu.

Entrou na 0224 (a última da cadeia a definir `emit_event`) e na definição
correspondente do baseline — o gate de espelho confirma que os dois artefatos
seguem iguais.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(e2e): a contagem de cobertura também soma a parte 3 — e o gate passa a cobrar isso

As TRÊS partes ficaram verdes e o job agregador reprovou assim mesmo:

  ::error::recorte das listas divergiu — rodou=72 fora=3 disco=100

40+32+3 = 75, e faltavam exatamente as 25 da parte 3. O passo de cobertura do
agregador soma as listas à mão, e eu tinha atualizado a matriz, o `case` que
alimenta `LISTA` e o gate de teste — mas não essa soma.

O defeito de verdade não é a linha esquecida: são DUAS implementações da mesma
regra sem nada ligando uma à outra. `e2e-cobertura-completa.test.ts` guardava
"toda spec está em alguma lista" e o passo do workflow guardava a mesma coisa
por outro caminho; deu para divergir porque nada media a divergência.

Então o gate parou de enumerar as partes à mão — enumerar aqui repetiria
exatamente o erro que ele existe para impedir. Ele DESCOBRE as
`SPECS_PARTE_\d+` declaradas no workflow e cobra, para cada uma:

  · que ela alimente `LISTA` (senão a lista existe e nunca roda);
  · que ela entre na soma `RODOU=$( { ... } )` do agregador.

Quem acrescentar uma quarta parte não precisa lembrar de nada — e se esquecer
de ligá-la, descobre no gate em vez de descobrir no CI vermelho.

Sabotagem, com previsão declarada antes: tirar a parte 3 da soma reprova
nomeando `SPECS_PARTE_3 não entra na contagem de cobertura`; tirar a linha do
`case` reprova com `SPECS_PARTE_3 não alimenta a variável que roda`. As duas
bateram.

`AGENTS.md` dizia "listas SPECS_PARTE_1/SPECS_PARTE_2" — afirmação de estado que
esta mudança venceu. Trocada pelo padrão `SPECS_PARTE_*`, que não envelhece.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Rafael Melgaço
2026-09-07 18:50:37 -03:00
committed by GitHub
co-authored by Claude Opus 5
parent 305ee87922
commit d69d708d8d
730 changed files with 47383 additions and 7788 deletions
@@ -0,0 +1,17 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Acompanhar uma organização com acesso temporário de verdade
---
A administração abre a organização escolhida com a identidade real de quem
presta suporte. É possível escolher edição ou somente leitura, sem adicionar
um membro permanente à equipe. O banner identifica a organização e oferece a
saída; ao encerrar, os dados da organização anterior são carregados novamente.
O modo somente leitura também impede alterações feitas por chamadas diretas.
Quando o prazo ou a permissão terminam, a tela pede encerrar o acompanhamento
antes de continuar.
As atualizações em tempo real aguardam a autenticação antes de abrir os canais,
inclusive ao trocar de organização ou acompanhar em mais de uma aba.
+13
View File
@@ -0,0 +1,13 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Crie o Google Meet e acompanhe a entrega do link na conversa
---
Compromissos com local Google Meet solicitam o link na agenda Google escolhida. O detalhe mostra criação pendente, link pronto ou falha com nova verificação. Quando pronto, você pode abrir e copiar o link.
O envio na conversa tem autorização e estado próprios. Escolha o atendimento e use “Enviar quando ficar pronto” ou “Enviar link ao cliente”. Marcar pelo assistente no atendimento atual agenda essa entrega. Se o atendimento mudar, a equipe recebe um aviso e pode autorizar uma nova entrega. Criar o link não significa que a mensagem já foi enviada.
Autorizar somente o link em um atendimento humano não ativa a IA nem muda o responsável ou o silêncio configurado. Bloqueios de mensagens e restrições do canal continuam valendo, com orientação no detalhe.
Depois de encerrar e reabrir o atendimento, uma nova entrega exige outro clique de autorização. Avisos da Central abrem o compromisso correspondente. O PDF de acesso aos dados também inclui entregas de links e avisos sobre compromissos, com seus estados e datas.
+11
View File
@@ -0,0 +1,11 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Escolha suas agendas e resolva mudanças entre a Agenda e o Google
---
Em Configurações → Agenda, escolha quais agendas Google ocupam seus horários e um destino gravável para novos compromissos. Os já publicados continuam na agenda original. Mudanças de horário e cancelamento são reconciliadas; quando ambos os lados mudam, o detalhe mostra a comparação e pede uma decisão. Alterar só o horário preserva os campos modificados diretamente no Google.
A tela informa erro, última sincronização, leitura parcial e retentativa. Comparecimento e falta continuam sendo fatos registrados pela equipe; mudanças no Google preservam o contato, a conversa e o histórico daqui.
Retentativas conservam a identidade do compromisso. Uma edição concorrente continua pendente até ser reconciliada; corrigir uma série no Google permite retomar a comparação. A anonimização encerra a sincronização daquele titular sem impedir outros compromissos.
+9
View File
@@ -0,0 +1,9 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Confirme presença e acompanhe faltas pela Agenda
---
Compromissos podem ser ligados ao contato e à conversa. A equipe registra comparecimento, falta ou cancelamento; a Central lembra quando falta confirmar, com prazos ajustáveis em Configurações. A agenda protege o cliente de cobranças de silêncio indevidas. Faltas confirmadas podem iniciar um fluxo configurado, e o compromisso mostra quando outro acompanhamento ou a configuração impedem o início. Resposta do cliente, cancelamento e remarcação interrompem a recuperação antiga.
As datas do detalhe seguem o idioma escolhido e o fuso do compromisso, inclusive quando ele termina no dia seguinte.
@@ -0,0 +1,15 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Testar o agente e revisar suas respostas antes de enviar
---
O agente pode preparar sugestões automaticamente para revisão humana. É possível editar,
aprovar ou rejeitar o texto na conversa, acompanhar o envio e informar o que deve melhorar.
Uma conversa alterada exige nova revisão. A aprovação do texto não executa mudanças no CRM
ou na agenda.
Pausar o atendimento automático preserva a versão publicada e mantém a assistência
à equipe. O teste usa o motor e o conhecimento do agente, apresenta propostas sem
aplicá-las ao cliente e fica disponível antes da publicação. Agentes antigos podem
concluir a configuração pela própria tela, preservando instruções e conhecimento.
+7
View File
@@ -0,0 +1,7 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Abra o contexto dos avisos sem perder o acompanhamento
---
A Central oferece acesso à conversa, ao contato, ao negócio ou à configuração correspondente quando seu acesso permite. Contextos removidos ou indisponíveis recebem orientação sem link quebrado. Abrir o contexto mantém o aviso aberto; resolver e reabrir continuam sendo escolhas separadas.
+9
View File
@@ -0,0 +1,9 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Encerre a conversa e registre o resultado da demanda separadamente
---
O atendimento agora diferencia fechar uma conversa de concluir a demanda do cliente. O painel mostra a demanda vigente, permite registrar seu resultado e mantém os fatos duráveis do contato e o histórico encerrado.
Uma nova mensagem após o fechamento reabre a fila com uma nova demanda. Trabalhos automáticos de um atendimento encerrado deixam de executar ações ou enviar respostas antigas depois da reabertura.
+9
View File
@@ -0,0 +1,9 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Escolha as áreas visíveis para cada pessoa da equipe
---
Quem administra pode escolher uma interface completa, simplificada ou personalizada por membro, inclusive antes de enviar o convite. A escolha vale em cada organização e atualiza a navegação de quem já está trabalhando sem fechar sua tela.
A interface simplificada mantém as áreas de trabalho e Conexões quando o papel permite. A seleção muda o menu, a busca e a página inicial; permissões e links das conversas continuam seguindo o papel da pessoa.
@@ -0,0 +1,16 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Criar organizações já entrega acesso e convite ao responsável
---
Quem administra a instalação encontra Gerenciar organizações no seletor, mesmo
quando só participa de uma empresa. A nova organização já inclui seu criador
como administrador e oferece um convite copiável ao responsável, inclusive sem
e-mail configurado. Falhas de criação não deixam empresas sem administrador.
A troca de empresa reinicia os dados da tela e aceita somente acessos ativos.
Reabrir um convite antigo não restaura privilégios removidos.
Se a resposta da criação se perder, tentar novamente recupera a mesma organização
e o link. O recibo dessa operação é protegido contra alterações por membros.
+9
View File
@@ -0,0 +1,9 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Escolha quem atende cada número e recupere conexões com segurança
---
Em Configurações › Atendimento, escolha os responsáveis de cada número. A capacidade da pessoa continua compartilhada entre canais; uma lista vazia deixa as conversas na fila, com aviso e novas tentativas. Conexões mostra o resumo e o caminho para ajustar a equipe.
Conectar um número preserva a identidade em falhas e permite reparar a tentativa. Conflitos do serviço só contam como sucesso depois da confirmação da sessão correta. A versão padrão mantém a possibilidade de mais de uma sessão sem bloqueio por tier; a prova local cobre duas sessões aguardando QR, sem pairing ou envio real.
+96 -36
View File
@@ -11,7 +11,7 @@ name: e2e
#
# A execução é dividida em DUAS invocações do playwright de propósito: o
# limitador de login do produto é por IP (60/300s) e no CI todos os specs vêm do
# mesmo 127.0.0.1. Ver o comentário do passo "E2E — parte 1 de 2".
# mesmo 127.0.0.1. Ver o comentário do passo "E2E — parte 1 de 3".
#
# É CHECK OBRIGATÓRIO. A régua, para reconferir em vez de acreditar nesta linha:
# $ gh api repos/melgarafael/DeskcommCRM/branches/main/protection \
@@ -76,7 +76,7 @@ jobs:
# "REBALANCEAMENTO DE 2026-09-04" mais abaixo — que também traz o comando que
# produz o número, porque número escrito envelhece e comando não.
#
# E há um ganho que não era o objetivo: a razão de existirem duas partes é
# E há um ganho que não era o objetivo: a razão de existirem partes separadas é
# "processo novo, contador de login zerado" — o teto por IP. Runners de
# matrix são máquinas diferentes, com IPs diferentes, então a separação
# deixa de depender de reiniciar o processo.
@@ -93,7 +93,7 @@ jobs:
# uma rodada por parte.
fail-fast: false
matrix:
parte: [1, 2]
parte: [1, 2, 3]
# ─────────────────────────────────────────────────────────────────────────
# A COBERTURA DEIXA DE SER PROSA DIGITADA À MÃO.
#
@@ -139,7 +139,7 @@ jobs:
# consertos são complementares — nenhum torna o outro dispensável:
#
# - `a60cae2d` (main, PR #220) desligou a telemetria NO AMBIENTE DO CI:
# `SENTRY_DSN: "off"` no build e nas duas partes (e no `perf.yml`). Ver o
# `SENTRY_DSN: "off"` no build e em todas as partes (e no `perf.yml`). Ver o
# comentário do passo "Build de produção" abaixo — a armadilha é que vazio NÃO
# desliga, seleciona o Sentry da comunidade.
# - `77b4a486` (este PR) consertou O PRODUTO: o SDK do browser mandava duas SESSÕES
@@ -272,6 +272,8 @@ jobs:
# 8-9 min é pago por parte, então três partes custam ~9 min de runner a mais
# e devolvem ~9 min de relógio a cada uma.
env:
# Provider controlado; preview/assistência executam core, ferramentas e gates reais.
INTERNAL_AGENT_RUN_STUB: "true"
SPECS_PARTE_1: >-
pre-go-live-whatsapp.spec.ts
smoke.spec.ts auth.spec.ts error-pages.spec.ts
@@ -312,10 +314,16 @@ jobs:
# spec, que passou a limpar as DUAS camadas de marca e por isso as specs
# seguintes deixaram de ver logo gravado.
#
# O que continua verdade: as duas partes rodam como passos do MESMO job,
# contra o MESMO banco, sem reset entre elas — o que uma spec deixa
# gravado, as seguintes enxergam. Quem protege contra isso é a limpeza da
# spec, não a posição dela nesta lista.
# O que continua verdade, e a frase precisou ser corrigida em 2026-09-07
# porque descrevia o mundo de ANTES da matrix: as specs de UMA MESMA parte
# rodam contra o MESMO banco, sem reset entre elas — o que uma spec deixa
# gravado, as seguintes daquela parte enxergam. Quem protege contra isso é
# a limpeza da spec, não a posição dela nesta lista.
#
# Entre partes DIFERENTES não há banco compartilhado: cada job da matrix é
# um runner próprio que paga o seu setup inteiro (~8,5 min, medidos iguais
# em cada um). É por isso que mover spec entre partes é risco: ela troca de
# vizinhança, e a vizinhança é que deixou o banco no estado que ela espera.
#
# ⚠️ Ela esteve no TOPO desta lista por três commits, pela razão errada.
# Eu media "carga de login por parte" com um regex que contava a PALAVRA
@@ -363,41 +371,38 @@ jobs:
# Se um dia uma spec de follow-up entrar na PARTE_1 com nome depois de
# `relogio-`, é aqui que se olha: ou ela vai para a PARTE_2, ou esta vai.
SPECS_PARTE_2: >-
agente-marca-consulta.spec.ts agenda-marcar-pela-tela.spec.ts agenda-remarcar-e-cancelar.spec.ts
agenda-tipos-de-agendamento.spec.ts agenda-grade-interativa.spec.ts
agente-novo-e-uso.spec.ts agente-organiza-operacao.spec.ts
central-de-avisos-capacidades.spec.ts escalacao-ciclo.spec.ts
olhar-telas-do-epico.spec.ts pipelines-gestao.spec.ts
qa-agente-usa-as-maos.spec.ts qa-selo-no-funil-usado.spec.ts
qa-telas-descobertas-w4.spec.ts retorno-anti-morte.spec.ts
followup-builder.spec.ts followup-queue.spec.ts
distribuicao-atendimento.spec.ts followup-journey.spec.ts
webhooks.spec.ts
agente-marca-consulta.spec.ts
agenda-marcar-pela-tela.spec.ts
agenda-remarcar-e-cancelar.spec.ts
agenda-tipos-de-agendamento.spec.ts
agenda-grade-interativa.spec.ts
agente-novo-e-uso.spec.ts
agente-organiza-operacao.spec.ts
central-de-avisos-capacidades.spec.ts
escalacao-ciclo.spec.ts
followup-builder.spec.ts
followup-queue.spec.ts
distribuicao-atendimento.spec.ts
followup-journey.spec.ts
capacidades-do-agente.spec.ts
escopo-de-funil-do-agente.spec.ts
followup-dossie.spec.ts followup-tempo-adaptativo.spec.ts
gatilho-de-etapa.spec.ts gatilho-de-caso.spec.ts
marca-logo.spec.ts
historico-de-captacao.spec.ts
followup-dossie.spec.ts
followup-tempo-adaptativo.spec.ts
gatilho-de-etapa.spec.ts
gatilho-de-caso.spec.ts
agenda-ocupacao-do-google-na-grade.spec.ts
agenda-ocupacao-do-google-no-historico.spec.ts
followup-publicado-abre-no-construtor.spec.ts
automacao-diz-a-verdade.spec.ts
inbox-quem-manda.spec.ts
acervo-de-conhecimento.spec.ts
agenda-kit-visual.spec.ts
agenda-conectar-google.spec.ts
agenda-painel-cabe-na-tela.spec.ts
agenda-ver-na-agenda.spec.ts
agenda-escopo-da-organizacao.spec.ts
troca-de-organizacao-tem-volta.spec.ts
agenda-caminho-ate-os-horarios.spec.ts
agenda-google-volta-do-consentimento.spec.ts
agenda-google-volta-nao-desloga.spec.ts
i18n-espanhol-na-tela.spec.ts
inbox-abas-espelham-o-comando.spec.ts
moeda-da-organizacao.spec.ts
zona-de-perigo-apaga-dados-de-teste.spec.ts
# `moeda-da-organizacao` entrou na PARTE_2 em 2026-09-04, ao lado de
# `navegacao` — mesmo domínio (Configurações) e a mesma ausência de
# dependência: sem WAHA, Resend, Nuvemshop ou Redis. Toca a organização
@@ -421,9 +426,9 @@ jobs:
#
# (2) `orgRow()` resolvia a organização com `.limit(1).single()` em
# `organizations`, SEM `where` — pressupondo banco de UMA organização
# só. O banco do CI é compartilhado entre as duas partes do job, e
# `signup-journey.spec.ts:32` cria uma segunda org sem cleanup: quando
# a parte 2 começa, já há ≥2.
# só. O banco de uma parte é compartilhado entre as specs dela, e
# `signup-journey.spec.ts:32` cria uma segunda org sem cleanup: depois
# que ela roda, já há ≥2 na mesma parte.
#
# ⚠️ ESTA LINHA DIZIA QUE "o `.single()` falha". É FALSO, e a frase
# errada é o motivo de a classe ter sobrevivido em quatro lugares:
@@ -542,6 +547,56 @@ jobs:
# com consentimento pré-aprovado. Sem ela o job pararia num
# `accounts.google.com` esperando login humano — e é por isto que ela
# fica FORA do CI em vez de ficar sem existir.
# ─── PARTE 3 — nasceu em 2026-09-07, e o teto de 30 min é quem a pediu ───
#
# A parte 2 foi CANCELADA aos 30 min neste PR (job 101799282999), com 55 dos
# 57 arquivos terminados. Não foi defeito de spec: foi a suíte crescendo —
# o PR acrescenta 10 specs e todas as 10 caíram na parte 2 (47 → 57).
#
# Duas partes não davam mais para equilibrar com segurança. Medido pelo
# instrumento desta seção no próprio run cancelado: 24,9 min de relógio na
# parte 2 contra 13,7 min na parte 1, e o setup custa ~8,5 min IGUAIS em
# cada job. Dividir 39,7 min de Playwright em dois daria 28,4 min por job —
# 1,6 min abaixo do teto, que é folga que a próxima spec consome. Em três,
# dá ~22 min, a mesma folga que a parte 1 tem hoje.
#
# O corte é o ponto onde o relógio ACUMULADO da parte 2 chega à metade, na
# ordem em que o Playwright executa — não por contagem de arquivos, que
# esta seção já registra como proxy que inverte o sinal. Ficaram
# 32 arquivos (13.3 min) na parte 2 e 25 (11.7 min) aqui.
#
# A parte 3 é a CAUDA contígua da parte 2, e isso é deliberado: as specs de
# uma parte compartilham o banco sem reset, então cada vizinhança rompida é
# risco de contaminação. Uma cauda rompe UMA fronteira; um sorteio romperia
# todas. Os 3 arquivos que o cancelamento impediu de medir vêm junto, porque
# eles são justamente o fim da fila.
SPECS_PARTE_3: >-
autonomia-assistida.spec.ts
roteamento-por-canal.spec.ts
central-avisos-destino.spec.ts
agenda-presenca-recuperacao.spec.ts
agenda-google-sync.spec.ts
agenda-google-meet.spec.ts
encerramento-atendimento.spec.ts
interface-por-vinculo.spec.ts
olhar-telas-do-epico.spec.ts
pipelines-gestao.spec.ts
qa-agente-usa-as-maos.spec.ts
qa-selo-no-funil-usado.spec.ts
qa-telas-descobertas-w4.spec.ts
retorno-anti-morte.spec.ts
webhooks.spec.ts
marca-logo.spec.ts
historico-de-captacao.spec.ts
inbox-quem-manda.spec.ts
troca-de-organizacao-tem-volta.spec.ts
organizacoes-criacao-convite-e-cache.spec.ts
suporte-temporario.spec.ts
i18n-espanhol-na-tela.spec.ts
inbox-abas-espelham-o-comando.spec.ts
moeda-da-organizacao.spec.ts
zona-de-perigo-apaga-dados-de-teste.spec.ts
FORA_DO_CI: >-
vps-fresh-onboarding.spec.ts
inbox-tempo-real.spec.ts
@@ -778,20 +833,25 @@ jobs:
# workers, dois specs gerando o mesmo código no mesmo intervalo de 30s
# fazem o segundo ser recusado como replay. Foi o que derrubava a
# `reset-password-mfa` (issue #80): ela passa sozinha e falha em paralelo.
- name: E2E — parte ${{ matrix.parte }} de 2
- name: E2E — parte ${{ matrix.parte }} de 3
run: |
set -euo pipefail
# A lista sai da matrix, e as duas variáveis continuam existindo no
# `env:` do job porque é DELAS que `e2e-cobertura-completa.test.ts` lê
# a cobertura. Ler por indireção aqui e não duplicar a lista mantém
# UMA fonte por parte.
if [ "${{ matrix.parte }}" = "1" ]; then LISTA="$SPECS_PARTE_1"; else LISTA="$SPECS_PARTE_2"; fi
case "${{ matrix.parte }}" in
1) LISTA="$SPECS_PARTE_1" ;;
2) LISTA="$SPECS_PARTE_2" ;;
3) LISTA="$SPECS_PARTE_3" ;;
*) echo "::error::parte ${{ matrix.parte }} sem lista"; exit 1 ;;
esac
# Falha alto se a variável vier vazia: sem isto, um typo no nome faria
# o playwright rodar a suíte INTEIRA (ou nenhuma) e o job ficaria
# verde medindo outra coisa.
[ -n "$LISTA" ] || { echo "::error::lista da parte ${{ matrix.parte }} vazia"; exit 1; }
# `--reporter=list` NÃO é preferência de leitura: é o INSTRUMENTO que
# mede o custo de cada spec, e sem ele a partição das duas listas volta
# mede o custo de cada spec, e sem ele a partição das listas volta
# a ser chutada.
#
# O reporter default em CI é o `dot`, e ele destrói justamente o dado
@@ -902,7 +962,7 @@ jobs:
dentro { exit }
' .github/workflows/e2e.yml | grep -oE "[a-z0-9-]+\.spec\.ts" | sort -u; }
NO_DISCO=$(ls tests/e2e/*.spec.ts | wc -l | tr -d " ")
RODOU=$( { bloco SPECS_PARTE_1; bloco SPECS_PARTE_2; } | sort -u | wc -l | tr -d " ")
RODOU=$( { bloco SPECS_PARTE_1; bloco SPECS_PARTE_2; bloco SPECS_PARTE_3; } | sort -u | wc -l | tr -d " ")
FORA_LISTA=$(bloco FORA_DO_CI)
FORA=$(echo "$FORA_LISTA" | grep -c . || true)
# E o recorte passa a ter CONTROLE, não só um piso.
@@ -923,7 +983,7 @@ jobs:
{
echo "## E2E — cobertura deste job"
echo ""
echo "**Rodou: ${RODOU} de ${NO_DISCO} specs**, em duas partes paralelas."
echo "**Rodou: ${RODOU} de ${NO_DISCO} specs**, em partes paralelas."
echo ""
echo "**Não rodou (${FORA}):**"
for s in $FORA_LISTA; do echo "- \`${s}\` — precisa de WAHA + Redis + Resend + Nuvemshop (P0 da doutrina de QA Visual)"; done
+2 -2
View File
@@ -21,7 +21,7 @@ quebra no clone fresco é um bug de produto, não um detalhe de ambiente.
Next.js 16 (App Router) · React 19 · TypeScript 6 estrito · Tailwind 4 ·
shadcn/ui · Supabase (Postgres + Auth + Realtime + Storage) · Upstash Redis ·
Vercel AI Gateway (`@ai-sdk/anthropic|openai|google`) · WAHA Plus (engine NOWEB) ·
Vercel AI Gateway (`@ai-sdk/anthropic|openai|google`) · WAHA 2026.7.2 (engine NOWEB; sem bloqueio por tier) ·
Zod 4 · Vitest 4 · Playwright 1 · Sentry 10.
Só a **major**, de propósito: é onde o idioma muda, e é o que
@@ -95,7 +95,7 @@ git show origin/main:.github/workflows/e2e.yml | grep -A4 'FORA_DO_CI:'
O que continua verdade e é o que importa: `vps-fresh-onboarding` está entre elas (WAHA + Redis
+ Resend + Nuvemshop) e é a **P0** da doutrina de QA — ou seja, `e2e` verde não prova a jornada
de instalação fresca. `followup-journey`, `webhooks` e `capacidades-do-agente` estiveram fora e
**voltaram**: rodam hoje (`e2e.yml`, listas `SPECS_PARTE_1`/`SPECS_PARTE_2`).
**voltaram**: rodam hoje (`e2e.yml`, listas `SPECS_PARTE_*` — são três desde 2026-09-07).
`.github/workflows/publish-image.yml`: `imagens-ok` = as três imagens Docker constroem. **Obrigatório
desde 2026-08-13.**
+1 -1
View File
@@ -100,7 +100,7 @@ DeskcommCRM é um sistema operacional de vendas open source com agentes de IA na
- Action audit obrigatória: `lgpd.data_request_received`, `lgpd.export_generated`, `lgpd.redact_executed`, `lgpd.consent_changed`
### WAHA
- Plus obrigatório (Core não suporta multi-tenant, sem retry, sem S3)
- Default fixo `devlikeapro/waha:latest-2026.7.2`, NOWEB. A prova local criou duas sessões CORE simultâneas até `SCAN_QR_CODE`; não prova pairing, duas contas `WORKING` nem envio. Não bloquear segunda sessão por tier: conferir resposta estruturada e pós-condição da operação.
- Engine NOWEB default; WEBJS apenas se precisar stickers animados / botões
- Auth: env do WAHA recebe **hash SHA512 hex** da api key; cliente envia plaintext em `X-Api-Key`
- Webhooks: HMAC SHA512 com `crypto.timingSafeEqual`
+1 -1
View File
@@ -21,7 +21,7 @@ export default async function MfaChallengePage({
const { data: factorsData } = await supabase.auth.mfa.listFactors();
const hasVerified = !!factorsData?.totp?.some((f) => f.status === "verified");
if (!hasVerified) redirect("/app/inbox");
if (!hasVerified) redirect("/app");
const idioma = normalizarIdioma(
(user.user_metadata?.locale as string | undefined) ?? null,
+3
View File
@@ -13,6 +13,7 @@
* sem "ativar", a verificação fica INALCANÇÁVEL; sem "desativar", ligá-la é uma
* porta sem volta.
*/
import { supportWriteError } from "@/lib/impersonate/support";
import { revalidatePath } from "next/cache";
import { audit } from "@/lib/audit";
@@ -35,6 +36,7 @@ export type ResultadoDaPolitica = { ok: true } | { ok: false; erro: string };
export async function definirExigenciaDeMfa(exigir: boolean): Promise<ResultadoDaPolitica> {
const user = await loadAuthUser();
if (!user) return { ok: false, erro: "Sua sessão expirou. Entre de novo." };
if (supportWriteError(user.support)) return { ok: false, erro: "Acompanhamento somente leitura ou encerrado." };
const org = await resolveActiveOrg(user);
if (!org) return { ok: false, erro: "Nenhuma empresa ativa." };
@@ -89,6 +91,7 @@ export async function definirExigenciaDeMfa(exigir: boolean): Promise<ResultadoD
export async function desativarMfaDaConta(): Promise<ResultadoDaPolitica> {
const user = await loadAuthUser();
if (!user) return { ok: false, erro: "Sua sessão expirou. Entre de novo." };
if (supportWriteError(user.support)) return { ok: false, erro: "Acompanhamento somente leitura ou encerrado." };
const org = await resolveActiveOrg(user);
if (!(await isMfaEnrolled())) return { ok: true };
+1 -1
View File
@@ -93,7 +93,7 @@ describe("recoverOrganization", () => {
vi.mocked(resolveActiveOrg).mockResolvedValue({ orgId: "existente" } as never);
const { recoverOrganization } = await import("./recoverOrganization");
await expect(recoverOrganization("Empresa Nova")).rejects.toThrow("NEXT_REDIRECT:/app/inbox");
await expect(recoverOrganization("Empresa Nova")).rejects.toThrow("NEXT_REDIRECT:/app");
expect(ensureTenantForUser).not.toHaveBeenCalled();
});
+2 -2
View File
@@ -58,7 +58,7 @@ export async function recoverOrganization(name: string): Promise<RecoverOrganiza
const user = await requireAuth();
const activeOrg = await resolveActiveOrg(user);
if (activeOrg) redirect("/app/inbox");
if (activeOrg) redirect("/app");
const supabase = await createClient();
const {
@@ -99,6 +99,6 @@ export async function recoverOrganization(name: string): Promise<RecoverOrganiza
return { ok: false, error: "provision_failed" };
}
revalidatePath("/app/inbox");
revalidatePath("/app");
redirect("/onboarding/welcome");
}
+2 -5
View File
@@ -32,10 +32,7 @@ export type SignInResult = {
*
* On failure: returns an error discriminator. Caller renders inline message.
*/
export async function signInWithPassword(
input: LoginInput,
next?: string,
): Promise<SignInResult> {
export async function signInWithPassword(input: LoginInput, next?: string): Promise<SignInResult> {
const parsed = loginSchema.safeParse(input);
if (!parsed.success) {
return {
@@ -107,5 +104,5 @@ export async function signInWithPassword(
});
// Server-side redirect ensures fresh session cookie is sent to browser.
redirect(safeNext(next, "/app/inbox"));
redirect(safeNext(next, "/app"));
}
+2 -2
View File
@@ -41,7 +41,7 @@ export async function verifyMfa(code: string, next?: string): Promise<VerifyMfaR
// Sanity-check: must have a verified TOTP factor.
const { data: factorsData } = await supabase.auth.mfa.listFactors();
const totp = factorsData?.totp?.find((f) => f.status === "verified");
if (!totp) redirect("/app/inbox");
if (!totp) redirect("/app");
// Lockout check (cookie counter).
const store = await cookies();
@@ -108,5 +108,5 @@ export async function verifyMfa(code: string, next?: string): Promise<VerifyMfaR
userAgent,
});
redirect(safeNext(next, "/app/inbox"));
redirect(safeNext(next, "/app"));
}
+3 -1
View File
@@ -9,6 +9,7 @@
* so the UI can render the "configure env" card without crashing.
*/
import { supportWriteError, authenticatedSessionId } from "@/lib/impersonate/support";
import { redirect } from "next/navigation";
import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server";
import { buildAuthorizeUrl } from "@/lib/nuvemshop/oauth";
@@ -22,6 +23,7 @@ export async function connectNuvemshop(): Promise<ConnectResult> {
const user = await loadAuthUser();
if (!user) return { ok: false, error: "auth_required" };
if (supportWriteError(user.support)) return { ok: false, error: "forbidden" };
const activeOrg = await resolveActiveOrg(user);
if (!activeOrg) return { ok: false, error: "no_active_org" };
@@ -34,7 +36,7 @@ export async function connectNuvemshop(): Promise<ConnectResult> {
const cfg = getConfig();
if (!cfg) return { ok: false, error: "not_configured" };
const state = issueState(activeOrg.orgId);
const state = issueState(activeOrg.orgId, { userId: user.id, authSessionId: await authenticatedSessionId() });
const url = buildAuthorizeUrl({ appId: cfg.appId, state });
redirect(url);
}
@@ -8,6 +8,7 @@
* may not have a valid token if the disconnect was triggered by token expiry).
*/
import { supportWriteError } from "@/lib/impersonate/support";
import { revalidatePath } from "next/cache";
import { audit } from "@/lib/audit";
import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server";
@@ -21,6 +22,7 @@ export async function disconnectNuvemshop(): Promise<DisconnectResult> {
const user = await loadAuthUser();
if (!user) return { ok: false, error: "auth_required" };
if (supportWriteError(user.support)) return { ok: false, error: "forbidden" };
const activeOrg = await resolveActiveOrg(user);
if (!activeOrg) return { ok: false, error: "no_active_org" };
+2
View File
@@ -4,6 +4,7 @@
* UPDATEs scoped explicitly by `organization_id` resolved from the validated
* session — no body-derived ids ever).
*/
import { supportWriteError } from "@/lib/impersonate/support";
import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server";
import { createAdminClient } from "@/lib/supabase/admin";
import type { OnboardingState } from "@/lib/schemas/onboarding";
@@ -35,6 +36,7 @@ export interface OnboardingCtx {
export async function requireOnboardingCtx(): Promise<OnboardingCtx> {
const user = await loadAuthUser();
if (!user) throw new OnboardingError("auth_required", "Auth required.");
if (supportWriteError(user.support)) throw new OnboardingError("forbidden", "Acompanhamento somente leitura ou encerrado.");
const activeOrg = await resolveActiveOrg(user);
if (!activeOrg) throw new OnboardingError("no_active_org", "Sem organização ativa.");
return {
+8 -226
View File
@@ -7,14 +7,11 @@
import { redirect } from "next/navigation";
import { z } from "zod";
import { publishFirstVersion } from "@/lib/ai/agents/first-publication";
import { audit } from "@/lib/audit";
import { listSelectableChannels, type SelectableChannel } from "@/lib/channels/selectable";
import { createAdminClient } from "@/lib/supabase/admin";
import { aiAgentDefaultSchema, type PromptTemplate } from "@/lib/schemas/onboarding";
import { capacidadesPadraoDoOnboarding } from "@/lib/ai/agents/capacidades-padrao";
import { publicarMemoriaDaOrg } from "@/lib/ai/memoria-da-org";
import { escolherModeloDoProvedor } from "@/lib/ai/agents/escolher-modelo";
import { chaveDePlataforma } from "@/lib/ai/runtime/agent";
import {
requireOnboardingCtx,
patchOnboardingState,
@@ -66,226 +63,6 @@ interface AgenteDoOnboarding {
* Colapsar os dois no mesmo `return` seria engolir erro — e engolir erro aqui
* significa terminar o onboarding com um agente mudo sem ninguém saber por quê.
*/
type PublishOutcome =
| { published: true }
| { published: false; reason: "no_channel" }
/**
* Há provedor e modelo, mas nenhuma chave utilizável: nem credencial validada
* da organização, nem chave da instalação no ambiente. Publicar assim entrega
* um funcionário que morre em toda mensagem.
*/
| { published: false; reason: "sem_chave"; provider: string }
| {
published: false;
reason: "no_model";
provider: string;
/**
* As duas causas pedem conselhos OPOSTOS: catálogo vazio pede esperar (ou
* forçar) a sincronização; catálogo cheio sem nenhum modelo que sirva pede
* trocar de provedor. Dizer "ainda não baixamos a lista" para quem já tem
* 400 modelos é o conselho que nunca resolve.
*/
motivo: "catalogo_vazio" | "nenhum_com_ferramentas";
}
| { published: false; reason: "failed"; message: string };
/**
* O provedor de IA que a instalação escolheu.
*
* O instalador pergunta "qual inteligência artificial vai atender seus
* clientes?" e grava a resposta em `organizations.settings.llm.provider`. Este
* passo publicava `"anthropic"` literal, e como o provider da VERSÃO vence o da
* organização em `resolveOrgLlmConfig`, quem escolheu outro terminava o wizard
* com um agente "Publicado" que morre em toda mensagem pedindo uma chave que
* ele nunca teve.
*
* `settings` é jsonb livre: leitura defensiva, igual à do agent-engine.
*/
function provedorDaInstalacao(settings: unknown): string {
const llm = (settings as { llm?: unknown } | null)?.llm;
const provider = (llm as { provider?: unknown } | null | undefined)?.provider;
return typeof provider === "string" && provider.trim() !== "" ? provider : "anthropic";
}
function mensagemDoErro(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* Publica a 1ª versão do agente criado no onboarding. **Nunca lança**: devolve
* o desfecho para quem chama decidir o que a tela mostra.
*
* Sem isso, o passo "Configurar IA" gravava só a linha em `ai_agents` — formato
* do `rag_bot` legado. Só que os dois runtimes atuais (o dispatcher do CRM e o
* agent-engine) resolvem o agente por
* `join ai_agent_versions on v.id = a.published_version_id`, então um agente
* sem versão publicada é invisível para ambos: a pessoa terminava o onboarding
* com um "Atendente IA" que nunca responderia uma única mensagem.
*/
async function publishFirstVersion(
admin: ReturnType<typeof createAdminClient>,
orgId: string,
agent: AgenteDoOnboarding,
systemPrompt: string,
userId: string,
): Promise<PublishOutcome> {
// Já publicado numa passagem anterior: republicar colidiria com
// `ai_agent_versions_unique_number` sem ganhar nada.
if (agent.published_version_id) return { published: true };
// Mesma lista que os seletores das telas de IA: canal arquivado não é destino
// válido de agente, e publicar uma versão apontando para um deixaria o
// onboarding terminar com um agente que nunca receberia uma mensagem.
//
// Ela LANÇA em erro de banco, e isso é correto lá: um seletor que devolve
// lista vazia quando a consulta falhou é indistinguível de "esta organização
// não tem número", e convida a parear de novo um aparelho que já está no ar.
// Aqui não é um seletor — é a decisão "publica ou fica rascunho", tomada
// DEPOIS de a linha em `ai_agents` já existir. Deixar o throw subir furava o
// `CreateAgentResult` (que trata todos os outros pontos de falha) e o passo
// terminava sem gravar estado, sem audit, sem evento e sem dizer nada na tela.
let canais: SelectableChannel[];
try {
canais = await listSelectableChannels(admin, orgId);
} catch (err) {
return { published: false, reason: "failed", message: mensagemDoErro(err) };
}
const [canal] = canais;
if (!canal) return { published: false, reason: "no_channel" };
// Erro de leitura aqui NÃO pode virar "assume anthropic": publicar sem saber
// qual provedor a instalação escolheu é exatamente o defeito de origem, com
// outra roupa.
const { data: org, error: orgErr } = await admin
.from("organizations")
.select("settings")
.eq("id", orgId)
.maybeSingle();
if (orgErr) return { published: false, reason: "failed", message: orgErr.message };
const provider = provedorDaInstalacao(org?.settings);
// O modelo daquele provedor. Não existe fallback literal: um id de outro
// provedor (ou inventado) produz o pior desfecho do produto — o agente
// responde texto plausível e nunca cria o lead nem move o card.
//
// Buscar SÓ o `is_default_for_provider` travava a OpenRouter, que é a opção
// [1] do instalador: medido num ambiente real, ela chega com 400 modelos
// sincronizados e NENHUM marcado como padrão, porque o cron de catálogo não
// escreve esse campo. A regra de escolha (com o requisito de ferramentas)
// vive em `escolherModeloDoProvedor`.
const { data: modelos } = await admin
.from("ai_models")
.select("model_id, is_default_for_provider, supports_tools, input_price_per_million_cents, output_price_per_million_cents")
.eq("provider", provider)
.is("deprecated_at", null);
const escolha = escolherModeloDoProvedor(
(modelos ?? []) as Parameters<typeof escolherModeloDoProvedor>[0],
);
if (!escolha.escolhido) {
return { published: false, reason: "no_model", provider, motivo: escolha.motivo };
}
const modelId = escolha.modelId;
// "Em que negócios ele pode mexer". Toda organização nasce com um funil de
// entrada, criado por gatilho no INSERT de `organizations`. Sem preencher
// isto, `pipeline_ids` fica vazio — e vazio significa NENHUM, então toda
// escrita de lead é recusada e o card nunca sai do lugar.
//
// Falha ou ausência = escopo vazio, nunca um funil chutado: mexer no funil
// errado é pior que não mexer em nenhum.
const { data: funil } = await admin
.from("crm_pipelines")
.select("id")
.eq("organization_id", orgId)
.eq("is_default", true)
.eq("is_archived", false)
.maybeSingle();
const pipelineIds = funil?.id ? [funil.id as string] : [];
// QUAL CHAVE ESTA VERSÃO USA — e as duas origens são legítimas.
//
// Credencial validada da organização vence (quem colou a chave no wizard, ou
// cadastrou pela tela); na falta dela, `credential_id: null` significa "a chave
// da instalação", que é o caso mais comum do kit. Sem NENHUMA das duas, não se
// publica: o agente responderia erro em toda mensagem e o dono só descobriria
// com o primeiro cliente.
//
// `validated_at` não nulo é exigência de `loadCredential`, não capricho: uma
// credencial que o provedor ainda não confirmou não é utilizável pelo turno.
const { data: credencial } = await admin
.from("ai_provider_credentials")
.select("id")
.eq("organization_id", orgId)
.eq("provider", provider)
.eq("is_active", true)
.not("validated_at", "is", null)
.limit(1)
.maybeSingle();
const credentialId = (credencial?.id as string | undefined) ?? null;
if (!credentialId && !chaveDePlataforma(provider)) {
return { published: false, reason: "sem_chave", provider };
}
const { data: version, error: versionErr } = await admin
.from("ai_agent_versions")
.insert({
organization_id: orgId,
agent_id: agent.id,
version_number: 1,
system_prompt: systemPrompt,
// Provedor e modelo saem SEMPRE da mesma origem — o par é indivisível.
// Emprestar só o id do modelo de outro provedor manda um nome que o
// endpoint não conhece.
provider,
model: modelId,
// Sem capacidades o turno não monta ferramenta nenhuma: o agente
// entregue conversa e não alcança contato, lead nem funil.
credential_id: credentialId,
tool_ids: capacidadesPadraoDoOnboarding(),
pipeline_ids: pipelineIds,
channel_session_id: canal.id,
status: "published",
published_at: new Date().toISOString(),
created_by: userId,
})
.select("id")
.single();
let versionId = version?.id ?? null;
if (!versionId && versionErr?.code === "23505") {
// A v1 já existe: uma passagem anterior gravou a versão e caiu antes de
// apontar o agente para ela. Repetir o passo passou a ser o que o usuário
// faz quando a tela pede — então ele não pode bater em "duplicate key"
// para sempre. Repontar é o conserto, não um novo INSERT.
const { data: existente } = await admin
.from("ai_agent_versions")
.select("id")
.eq("organization_id", orgId)
.eq("agent_id", agent.id)
.eq("version_number", 1)
.maybeSingle();
versionId = existente?.id ?? null;
}
if (!versionId) {
return {
published: false,
reason: "failed",
message: versionErr?.message ?? "ai_agent_versions_insert_sem_id",
};
}
const { error: pointErr } = await admin
.from("ai_agents")
.update({ published_version_id: versionId })
.eq("id", agent.id)
.eq("organization_id", orgId);
if (pointErr) return { published: false, reason: "failed", message: pointErr.message };
return { published: true };
}
export type CreateAgentResult =
/**
@@ -314,7 +91,11 @@ export type CreateAgentResult =
/** As regras da casa não foram gravadas — o agente existe assim mesmo. */
regras_nao_salvas?: string;
}
| { ok: false; error: "auth_required" | "no_active_org" | "invalid_input" | "db_error"; details?: unknown };
| {
ok: false;
error: "auth_required" | "no_active_org" | "invalid_input" | "db_error";
details?: unknown;
};
export async function createDefaultAgent(formData: FormData): Promise<CreateAgentResult> {
let ctx;
@@ -440,7 +221,8 @@ export async function createDefaultAgent(formData: FormData): Promise<CreateAgen
ai: { agent_id: agent.id, prompt_template: input.prompt_template },
});
} catch (err) {
if (err instanceof OnboardingError) return { ok: false, error: "db_error", details: err.message };
if (err instanceof OnboardingError)
return { ok: false, error: "db_error", details: err.message };
throw err;
}
@@ -1,5 +1,6 @@
"use server";
import { supportWriteError } from "@/lib/impersonate/support";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { z } from "zod";
@@ -60,6 +61,7 @@ export async function apagarDadosOperacionaisDaOrganizacao(input: {
const authUser = await loadAuthUser();
if (!authUser) return { ok: false, error: "unauthenticated" };
if (supportWriteError(authUser.support)) return { ok: false, error: "forbidden_role" };
const activeOrg = await resolveActiveOrg(authUser);
if (!activeOrg) return { ok: false, error: "forbidden_tenant" };
if (!authUser.is_platform_admin && ROLE_RANK[activeOrg.role] < ROLE_RANK.admin) {
@@ -1,5 +1,6 @@
"use server";
import { supportWriteError } from "@/lib/impersonate/support";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { z } from "zod";
@@ -96,6 +97,7 @@ export async function updateAdInsightsConnection(
const authUser = await loadAuthUser();
if (!authUser) return { ok: false, error: "unauthenticated" };
if (supportWriteError(authUser.support)) return { ok: false, error: "forbidden_role" };
const activeOrg = await resolveActiveOrg(authUser);
if (!activeOrg) return { ok: false, error: "forbidden_tenant" };
if (!authUser.is_platform_admin && ROLE_RANK[activeOrg.role] < ROLE_RANK.admin) {
@@ -195,6 +197,7 @@ export async function updateAdInsightsConnection(
export async function disconnectAdInsights(): Promise<UpdateAdInsightsConnectionResult> {
const authUser = await loadAuthUser();
if (!authUser) return { ok: false, error: "unauthenticated" };
if (supportWriteError(authUser.support)) return { ok: false, error: "forbidden_role" };
const activeOrg = await resolveActiveOrg(authUser);
if (!activeOrg) return { ok: false, error: "forbidden_tenant" };
if (!authUser.is_platform_admin && ROLE_RANK[activeOrg.role] < ROLE_RANK.admin) {
@@ -1,5 +1,6 @@
"use server";
import { supportWriteError } from "@/lib/impersonate/support";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { z } from "zod";
@@ -91,6 +92,7 @@ export async function updateAdPlatformConnection(
const authUser = await loadAuthUser();
if (!authUser) return { ok: false, error: "unauthenticated" };
if (supportWriteError(authUser.support)) return { ok: false, error: "forbidden_role" };
const activeOrg = await resolveActiveOrg(authUser);
if (!activeOrg) return { ok: false, error: "forbidden_tenant" };
if (!authUser.is_platform_admin && ROLE_RANK[activeOrg.role] < ROLE_RANK.admin) {
@@ -1,5 +1,6 @@
"use server";
import { supportWriteError } from "@/lib/impersonate/support";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
@@ -90,6 +91,7 @@ export async function updateMarcaDaOrganizacao(
const authUser = await loadAuthUser();
if (!authUser) return { ok: false, error: "unauthenticated" };
if (supportWriteError(authUser.support)) return { ok: false, error: "forbidden_role" };
const activeOrg = await resolveActiveOrg(authUser);
if (!activeOrg) return { ok: false, error: "forbidden_tenant" };
if (!authUser.is_platform_admin && ROLE_RANK[activeOrg.role] < ROLE_RANK.admin) {
@@ -1,5 +1,6 @@
"use server";
import { supportWriteError } from "@/lib/impersonate/support";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
@@ -30,6 +31,7 @@ export async function updatePipelineConfig(
const authUser = await loadAuthUser();
if (!authUser) return { ok: false, error: "unauthenticated" };
if (supportWriteError(authUser.support)) return { ok: false, error: "forbidden" };
const activeOrg = await resolveActiveOrg(authUser);
if (!activeOrg) return { ok: false, error: "forbidden_tenant" };
if (!authUser.is_platform_admin && ROLE_RANK[activeOrg.role] < ROLE_RANK.admin) {
+1 -1
View File
@@ -63,7 +63,7 @@ export async function updateProfile(input: ProfileInput): Promise<UpdateProfileR
});
// Best-effort emit (event_log is org-scoped; skip if no org).
if (activeOrg) {
if (activeOrg && authUser.support?.access_mode !== "support_readonly") {
await supabase
.rpc("emit_event", {
p_event_type: "user.profile_updated",
+2
View File
@@ -1,5 +1,6 @@
"use server";
import { supportWriteError } from "@/lib/impersonate/support";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
@@ -22,6 +23,7 @@ export async function updateTenant(input: TenantInput): Promise<UpdateTenantResu
const authUser = await loadAuthUser();
if (!authUser) return { ok: false, error: "unauthenticated" };
if (supportWriteError(authUser.support)) return { ok: false, error: "forbidden" };
const activeOrg = await resolveActiveOrg(authUser);
if (!activeOrg) return { ok: false, error: "forbidden_tenant" };
if (!authUser.is_platform_admin && ROLE_RANK[activeOrg.role] < ROLE_RANK.admin) {
+20 -12
View File
@@ -1,24 +1,32 @@
"use server";
import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";
import { loadAuthUser } from "@/lib/auth/server";
import { z } from "zod";
import { loadAuthUser, mfaEmDivida } from "@/lib/auth/server";
import { createClient } from "@/lib/supabase/server";
import { cookieSecure } from "@/lib/supabase/cookie-secure";
import { audit } from "@/lib/audit";
export async function setActiveOrg(orgId: string): Promise<{ ok: boolean; error?: string }> {
if (!z.string().uuid().safeParse(orgId).success) return { ok: false, error: "invalid_organization" };
const user = await loadAuthUser();
if (!user) return { ok: false, error: "auth_required" };
const isMember = user.organizations.some((o) => o.organization_id === orgId);
if (!isMember && !user.is_platform_admin) {
return { ok: false, error: "forbidden" };
}
if (user.support) return { ok: false, error: "Encerre o acompanhamento antes de trocar de organização." };
if (await mfaEmDivida()) return { ok: false, error: "mfa_required" };
// Consulta fresca: não usa status de membership serializado no browser.
const db = await createClient();
const { data: membership, error } = await db.from("user_organizations")
.select("organization_id, organizations!inner(status)")
.eq("organization_id", orgId).eq("user_id", user.id)
.is("revoked_at", null).not("accepted_at", "is", null)
.eq("organizations.status", "active").maybeSingle();
if (error || !membership) return { ok: false, error: "forbidden" };
const store = await cookies();
const previous = store.get("active_org")?.value;
store.set("active_org", orgId, {
httpOnly: true,
sameSite: "strict",
secure: cookieSecure(),
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true, sameSite: "strict", secure: cookieSecure(), path: "/", maxAge: 60 * 60 * 24 * 30,
});
revalidatePath("/app", "layout");
await audit({ action: "organization.switched", actorUserId: user.id,
organizationId: orgId, resourceType: "organization", resourceId: orgId,
metadata: { previous_organization_id: z.string().uuid().safeParse(previous).success ? previous : null } });
return { ok: true };
}
+39 -58
View File
@@ -6,10 +6,13 @@
* 1. Verify HMAC token (signature + expiry).
* 2. Get current authenticated user from cookie session.
* 3. Email mismatch → return error (UI tells user to sign out / use the right account).
* 4. INSERT user_organizations (organization_id, user_id, role, accepted_at, invited_by=null).
* If a revoked row already exists for (user, org), reactivate it instead.
* 4. INSERT user_organizations (organization_id, user_id, role, accepted_at, invited_by assinado).
* Replay preserva vínculo ativo; revogado exige convite posterior à revogação.
* 5. Audit `member.accepted` and redirect to /app/inbox.
*/
import { readSupportContext } from "@/lib/impersonate/support";
import { cookies } from "next/headers";
import { cookieSecure } from "@/lib/supabase/cookie-secure";
import { redirect } from "next/navigation";
import { audit } from "@/lib/audit";
@@ -19,7 +22,12 @@ import { createClient } from "@/lib/supabase/server";
export type AcceptInviteResult =
| { ok: true }
| { ok: false; error: "invalid_or_expired" | "email_mismatch" | "not_authenticated" | "internal_error"; message?: string; expectedEmail?: string };
| {
ok: false;
error: "invalid_or_expired" | "email_mismatch" | "not_authenticated" | "internal_error";
message?: string;
expectedEmail?: string;
};
export async function acceptInviteAction(token: string): Promise<AcceptInviteResult> {
const payload = verifyInviteToken(token);
@@ -31,73 +39,46 @@ export async function acceptInviteAction(token: string): Promise<AcceptInviteRes
} = await supabase.auth.getUser();
if (!user) return { ok: false, error: "not_authenticated" };
if (await readSupportContext(supabase))
return {
ok: false,
error: "internal_error",
message: "Saia do acompanhamento antes de aceitar o convite.",
};
const userEmail = (user.email ?? "").trim().toLowerCase();
const inviteEmail = payload.email.trim().toLowerCase();
if (userEmail !== inviteEmail) {
return { ok: false, error: "email_mismatch", expectedEmail: payload.email };
}
// Reactivate or insert. RLS em user_organizations exige admin da org para
// INSERT/UPDATE (user_orgs_insert/update) — o convidado ainda NÃO é membro, então
// não pode se auto-inserir. A escrita da membership usa o service role. Autorização:
// token HMAC verificado + email do usuário autenticado === email do convite; org e
// role vêm do token assinado (fonte confiável), nunca do body.
const db = createAdminClient();
const { data: existing, error: fetchErr } = await db
.from("user_organizations")
.select("id, revoked_at")
.eq("user_id", user.id)
.eq("organization_id", payload.organization_id)
.maybeSingle();
if (fetchErr) {
return { ok: false, error: "internal_error", message: fetchErr.message };
}
const nowIso = new Date().toISOString();
if (existing?.id) {
const { error: updErr } = await db
.from("user_organizations")
.update({
role: payload.role,
revoked_at: null,
accepted_at: existing.revoked_at ? nowIso : (nowIso),
updated_at: nowIso,
})
.eq("id", existing.id);
if (updErr) return { ok: false, error: "internal_error", message: updErr.message };
// Org/papel/convidador vêm exclusivamente do token assinado; usuário do JWT.
const { data: result, error } = await createAdminClient().rpc("fn_accept_team_invite", {
p_interface_settings: payload.interface_settings ?? { preset: "completa" },
p_user: user.id,
p_org: payload.organization_id,
p_role: payload.role,
p_invited_by: payload.invited_by ?? null,
p_issued_at: payload.iat ? new Date(payload.iat * 1000).toISOString() : null,
p_invited_at: new Date((payload.iat ?? payload.exp - 86400) * 1000).toISOString(),
});
if (error)
return { ok: false, error: error.code === "42501" ? "invalid_or_expired" : "internal_error" };
if (result.changed) {
await audit({
action: "member.accepted",
actorUserId: user.id,
organizationId: payload.organization_id,
resourceType: "membership",
resourceId: existing.id,
metadata: { invite_id: payload.invite_id, role: payload.role, reactivated: !!existing.revoked_at },
});
} else {
const { data: inserted, error: insErr } = await db
.from("user_organizations")
.insert({
user_id: user.id,
organization_id: payload.organization_id,
role: payload.role,
invited_at: new Date(payload.exp * 1000 - 24 * 60 * 60 * 1000).toISOString(),
accepted_at: nowIso,
})
.select("id")
.single();
if (insErr) return { ok: false, error: "internal_error", message: insErr.message };
await audit({
action: "member.accepted",
actorUserId: user.id,
organizationId: payload.organization_id,
resourceType: "membership",
resourceId: inserted.id,
resourceId: result.id,
metadata: { invite_id: payload.invite_id, role: payload.role },
});
}
redirect("/app/inbox");
(await cookies()).set("active_org", payload.organization_id, {
httpOnly: true,
sameSite: "strict",
secure: cookieSecure(),
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
redirect("/app");
}
+88 -28
View File
@@ -1,9 +1,16 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { InterfaceEditor } from "@/components/team/InterfaceEditor";
import {
INTERFACE_COMPLETA,
interfaceSettingsSchema,
interfaceTemDestino,
} from "@/lib/navigation/interface";
import { tenantCreationFields } from "@/lib/schemas/tenant-creation";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -16,26 +23,17 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useCreateTenant } from "@/hooks/useCreateTenant";
import { type CreateTenantResponse, useCreateTenant } from "@/hooks/useCreateTenant";
import { ApiError } from "@/lib/api/types";
import { useT } from "@/hooks/i18n/useT";
import { useIdioma } from "@/lib/i18n/IdiomaProvider";
import { copyToClipboard } from "@/lib/clipboard";
// ---------------------------------------------------------------------------
// Schema (mirrors server Zod; client keeps it in sync)
// ---------------------------------------------------------------------------
const formSchema = z.object({
display_name: z.string().min(2, "Mínimo 2 caracteres").max(120, "Máximo 120 caracteres"),
slug: z
.string()
.min(2, "Mínimo 2 caracteres")
.max(40, "Máximo 40 caracteres")
.regex(/^[a-z0-9-]+$/, "Apenas letras minúsculas, números e hífens"),
legal_name: z.string().min(2).max(255).optional().or(z.literal("")),
cnpj: z.string().optional().or(z.literal("")),
plan: z.enum(["standard", "pro", "enterprise"]),
owner_email: z.string().email("E-mail inválido"),
});
const formSchema = z.object(tenantCreationFields);
type FormValues = z.infer<typeof formSchema>;
@@ -61,8 +59,7 @@ function maskCnpj(value: string): string {
const digits = value.replace(/\D/g, "").slice(0, 14);
if (digits.length <= 2) return digits;
if (digits.length <= 5) return `${digits.slice(0, 2)}.${digits.slice(2)}`;
if (digits.length <= 8)
return `${digits.slice(0, 2)}.${digits.slice(2, 5)}.${digits.slice(5)}`;
if (digits.length <= 8) return `${digits.slice(0, 2)}.${digits.slice(2, 5)}.${digits.slice(5)}`;
if (digits.length <= 12)
return `${digits.slice(0, 2)}.${digits.slice(2, 5)}.${digits.slice(5, 8)}/${digits.slice(8)}`;
return `${digits.slice(0, 2)}.${digits.slice(2, 5)}.${digits.slice(5, 8)}/${digits.slice(8, 12)}-${digits.slice(12)}`;
@@ -74,9 +71,12 @@ function maskCnpj(value: string): string {
export function NewTenantForm() {
const t = useT();
const idioma = useIdioma();
const router = useRouter();
const createTenant = useCreateTenant();
const [ownerInterface, setOwnerInterface] = useState(INTERFACE_COMPLETA);
const [slugLocked, setSlugLocked] = useState(false);
const [created, setCreated] = useState<CreateTenantResponse["data"] | null>(null);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
@@ -94,7 +94,6 @@ export function NewTenantForm() {
register,
handleSubmit,
setValue,
watch,
formState: { errors, isSubmitting },
} = form;
@@ -125,10 +124,11 @@ export function NewTenantForm() {
cnpj: values.cnpj || undefined,
plan: values.plan,
owner_email: values.owner_email,
owner_interface_settings: ownerInterface,
});
toast.success(t("Tenant criado com sucesso!"));
router.push(`/admin/tenants/${result.data.id}`);
setCreated(result.data);
} catch (err) {
if (err instanceof ApiError) {
if (err.code === "conflict") {
@@ -142,20 +142,69 @@ export function NewTenantForm() {
}
});
const planValue = watch("plan");
const planValue = useWatch({ control: form.control, name: "plan" });
if (created)
return (
<Card className="mx-auto max-w-2xl">
<CardHeader>
<CardTitle>{t("Organização criada")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p>
{t("Você já é administrador de")} {created.display_name}.
</p>
{created.owner_invitation && (
<div className="space-y-3">
<p>
{created.owner_invitation.email_dispatched
? t("Convite enviado por e-mail.")
: t(
"O envio por e-mail não foi confirmado. Copie o link e compartilhe com o responsável.",
)}
</p>
<Label htmlFor="owner-invite">{t("Link do convite")}</Label>
<Input id="owner-invite" readOnly value={created.owner_invitation.accept_url} />
<p>
{t("Válido até")}{" "}
{new Date(created.owner_invitation.expires_at).toLocaleString(idioma)}.
</p>
<Button
onClick={async () => {
if (await copyToClipboard(created.owner_invitation!.accept_url))
toast.success(t("Link copiado"));
else toast.error(t("Selecione e copie o link acima."));
}}
>
{t("Copiar convite")}
</Button>
<p className="text-sm text-muted-foreground">
{t("Se o convite vencer, abra Equipe na organização para gerar outro.")}
</p>
</div>
)}
<Button asChild>
<a href="/app">{t("Voltar ao aplicativo")}</a>
</Button>
<Button variant="outline" asChild>
<a href={`/admin/tenants/${created.id}`}>{t("Ver organização")}</a>
</Button>
</CardContent>
</Card>
);
return (
<div className="mx-auto max-w-2xl space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t("Novo Tenant")}</h1>
<p className="text-sm text-muted-foreground mt-1">
{t("Cria um novo tenant com status")} <em>onboarding</em>.
<h1 className="text-2xl font-semibold tracking-tight">{t("Nova organização")}</h1>
<p className="mt-1 text-sm text-muted-foreground">
{t("Você terá acesso como administrador e poderá concluir a configuração inicial.")}
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("Dados do tenant")}</CardTitle>
<CardTitle className="text-base">{t("Dados da organização")}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-5" noValidate>
@@ -234,9 +283,7 @@ export function NewTenantForm() {
<Label htmlFor="plan">{t("Plano")}</Label>
<Select
value={planValue}
onValueChange={(v) =>
setValue("plan", v as "standard" | "pro" | "enterprise")
}
onValueChange={(v) => setValue("plan", v as "standard" | "pro" | "enterprise")}
>
<SelectTrigger id="plan" aria-label={t("Plano")}>
<SelectValue />
@@ -269,10 +316,23 @@ export function NewTenantForm() {
)}
</div>
<InterfaceEditor
value={ownerInterface}
onChange={setOwnerInterface}
role="admin"
disabled={isSubmitting}
/>
{/* Actions */}
<div className="flex items-center gap-3 pt-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? t("Criando...") : t("Criar tenant")}
<Button
type="submit"
disabled={
isSubmitting ||
!interfaceSettingsSchema.safeParse(ownerInterface).success ||
!interfaceTemDestino(ownerInterface, "admin")
}
>
{isSubmitting ? t("Criando...") : t("Criar organização")}
</Button>
<Button
type="button"
+23 -54
View File
@@ -1,62 +1,31 @@
/**
* POST /api/v1/admin/impersonate/end (S-11.07)
*
* Terminates the active platform-admin impersonation session by deleting the
* `deskcomm-impersonate` cookie. Audits the end event with the tenant id
* derived from the cookie (best-effort; cookie is verified before audit).
*
* Idempotent: if no cookie present, still returns 200 with `ended: false`.
*/
import { cookies } from "next/headers";
import { randomUUID } from "node:crypto";
import { requirePlatformAdmin } from "@/lib/auth/requirePlatformAdmin";
import { z } from "zod";
import { createClient } from "@/lib/supabase/server";
import { createAdminClient } from "@/lib/supabase/admin";
import { cookieSecure } from "@/lib/supabase/cookie-secure";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import {
IMPERSONATE_COOKIE_NAME,
verifyImpersonateCookie,
} from "@/lib/impersonate/cookie";
import { IMPERSONATE_COOKIE_NAME } from "@/lib/impersonate/cookie";
/** A própria sessão pode sair depois de expiração/revogação de plataforma. */
export async function POST() {
const requestId = randomUUID();
let adminCtx: Awaited<ReturnType<typeof requirePlatformAdmin>>;
try {
adminCtx = await requirePlatformAdmin();
} catch {
return fail("forbidden", "Platform admin required", 403, { requestId });
}
const cookieStore = await cookies();
const raw = cookieStore.get(IMPERSONATE_COOKIE_NAME)?.value ?? null;
// Always clear, even if invalid — defence-in-depth against stale cookies.
cookieStore.delete(IMPERSONATE_COOKIE_NAME);
if (!raw) {
return ok({ ended: false, tenant_id: null }, { requestId });
}
const result = verifyImpersonateCookie(raw);
const tenantId = result.valid && result.payload ? result.payload.tenantId : null;
void audit({
action: "platform_admin.impersonate_ended",
actorUserId: adminCtx.user.id,
actingAsPlatformAdmin: true,
bypassedRls: true,
organizationId: tenantId,
resourceType: "organization",
resourceId: tenantId,
requestId,
metadata: {
tenant_id: tenantId,
platform_admin_id: adminCtx.user.id,
cookie_valid: result.valid,
reason: result.reason ?? null,
},
});
return ok({ ended: true, tenant_id: tenantId }, { requestId });
const db = await createClient();
const { data: { user } } = await db.auth.getUser();
if (!user) return fail("unauthenticated", "Entre novamente.", 401, { requestId });
const { data: claims } = await db.auth.getClaims();
const sessionId = claims?.claims.session_id;
if (!z.string().uuid().safeParse(sessionId).success) return fail("unauthenticated", "Sessão inválida.", 401, { requestId });
const { data: session, error } = await createAdminClient().rpc("fn_end_support", { p_actor: user.id, p_session: sessionId });
if (error) return fail("upstream_unavailable", "Não foi possível encerrar o acompanhamento. Tente novamente.", 503, { requestId });
const store = await cookies();
store.delete(IMPERSONATE_COOKIE_NAME);
if (session?.previous_organization_id) store.set("active_org", session.previous_organization_id,
{ httpOnly: true, secure: cookieSecure(), sameSite: "strict", path: "/", maxAge: 60*60*24*30 });
if (session?.id) await audit({ action: "platform_admin.impersonate_ended", actorUserId: user.id,
actingAsPlatformAdmin: true, bypassedRls: true, organizationId: session.organization_id,
resourceType: "organization", resourceId: session.organization_id, requestId,
metadata: { auth_session_id: sessionId, support_session_id: session.id, access_mode: session.access_mode } });
return ok({ ended: !!session?.id, redirect_url: "/app/inbox" }, { requestId });
}
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/admin/incidents/[id]/resolve (S-11.11)
*
@@ -26,6 +27,9 @@ export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await params;
@@ -193,14 +193,17 @@ function methodNotAllowed() {
}
export function POST() {
return methodNotAllowed();
}
export function PATCH() {
return methodNotAllowed();
}
export function DELETE() {
return methodNotAllowed();
}
@@ -1,146 +1,52 @@
/**
* POST /api/v1/admin/tenants/[id]/impersonate (S-11.07)
*
* Starts a platform-admin impersonation session for a tenant. Issues a signed
* `deskcomm-impersonate` cookie (HMAC-SHA256, 1h TTL, HttpOnly+Secure+Lax).
* Audits start + emits cross-tenant `event_log` row.
*
* Security:
* - requirePlatformAdmin enforces auth + MFA AAL2 + active platform_admins row
* - Cookie is opaque to the client (HttpOnly); only middleware/server can read
* - 503 returned if `IMPERSONATE_COOKIE_SECRET` not configured
* - Cookie is additive — the platform admin's actual Supabase session is
* untouched; downstream code reads the cookie to add tenant-scope to queries
*/
import { type NextRequest } from "next/server";
import { cookies } from "next/headers";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { requirePlatformAdmin } from "@/lib/auth/requirePlatformAdmin";
import { loadAuthUser, mfaEmDivida, resolveActiveOrg } from "@/lib/auth/server";
import { createClient } from "@/lib/supabase/server";
import { createAdminClient } from "@/lib/supabase/admin";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { cookieSecure } from "@/lib/supabase/cookie-secure";
import {
IMPERSONATE_COOKIE_NAME,
IMPERSONATE_TTL_SECONDS,
isImpersonateSecretReady,
signImpersonateCookie,
} from "@/lib/impersonate/cookie";
import { IMPERSONATE_COOKIE_NAME, IMPERSONATE_TTL_SECONDS, isImpersonateSecretReady, signImpersonateCookie } from "@/lib/impersonate/cookie";
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const inputSchema = z.object({ access_mode: z.enum(["full", "support_readonly"]).default("full") });
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = randomUUID();
const { id: tenantId } = await params;
let adminCtx: Awaited<ReturnType<typeof requirePlatformAdmin>>;
try {
adminCtx = await requirePlatformAdmin();
} catch {
return fail("forbidden", "Platform admin required", 403, { requestId });
}
// Misconfiguration guard — refuse to mint cookies we can't verify later.
if (!isImpersonateSecretReady()) {
void audit({
action: "platform_admin.impersonate_misconfigured",
actorUserId: adminCtx.user.id,
actingAsPlatformAdmin: true,
bypassedRls: true,
organizationId: tenantId,
resourceType: "organization",
resourceId: tenantId,
requestId,
metadata: { reason: "secret_missing_or_short" },
});
return fail(
"upstream_unavailable",
"Impersonate flow is not configured on this environment",
503,
{ requestId },
);
}
if (!z.string().uuid().safeParse(tenantId).success) return fail("validation_failed", "Organização inválida.", 422, { requestId });
const input = inputSchema.safeParse(await req.json().catch(() => ({})));
if (!input.success) return fail("validation_failed", "Modo de acompanhamento inválido.", 422, { requestId });
let ctx;
try { ctx = await requirePlatformAdmin(); }
catch { return fail("forbidden", "Administração da plataforma necessária.", 403, { requestId }); }
if (await mfaEmDivida()) return fail("mfa_required", "Confirme a verificação em duas etapas.", 403, { requestId });
if (!isImpersonateSecretReady()) return fail("upstream_unavailable", "Acompanhamento não configurado nesta instalação.", 503, { requestId });
const user = await loadAuthUser();
if (!user) return fail("unauthenticated", "Entre novamente.", 401, { requestId });
if (user.support) return fail("state_conflict", "Encerre o acompanhamento atual primeiro.", 409, { requestId });
const previous = await resolveActiveOrg(user);
const db = await createClient();
const { data: claims } = await db.auth.getClaims();
const sessionId = claims?.claims.session_id;
if (!z.string().uuid().safeParse(sessionId).success) return fail("unauthenticated", "Sessão inválida.", 401, { requestId });
const admin = createAdminClient();
const { data: org, error: orgError } = await admin
.from("organizations")
.select("id, slug, display_name, status")
.eq("id", tenantId)
.maybeSingle();
if (orgError || !org) {
return fail("not_found", "Tenant not found", 404, { requestId });
}
if (org.status === "redacted") {
return fail(
"state_conflict",
"Cannot impersonate a redacted tenant",
409,
{ requestId },
);
}
const nowSec = Math.floor(Date.now() / 1000);
const exp = nowSec + IMPERSONATE_TTL_SECONDS;
const token = signImpersonateCookie({
tenantId: org.id,
platformAdminId: adminCtx.user.id,
exp,
const { data: id, error } = await admin.rpc("fn_start_support", {
p_actor: ctx.user.id, p_session: sessionId, p_org: tenantId,
p_previous: previous?.orgId ?? null, p_mode: input.data.access_mode, p_ttl: IMPERSONATE_TTL_SECONDS,
});
const cookieStore = await cookies();
cookieStore.set(IMPERSONATE_COOKIE_NAME, token, {
httpOnly: true,
secure: cookieSecure(),
sameSite: "lax",
maxAge: IMPERSONATE_TTL_SECONDS,
path: "/",
});
// Audit start (acting_as_platform_admin=true is the tell-tale signal).
void audit({
action: "platform_admin.impersonate_started",
actorUserId: adminCtx.user.id,
actingAsPlatformAdmin: true,
bypassedRls: true,
organizationId: org.id,
resourceType: "organization",
resourceId: org.id,
requestId,
metadata: {
tenant_id: org.id,
tenant_slug: org.slug,
platform_admin_id: adminCtx.user.id,
exp,
},
});
// Emit a cross-tenant domain event for downstream consumers (e.g. SIEM,
// anomaly detection). Service-role admin bypasses RLS — this is intentional.
await admin.from("event_log").insert({
organization_id: org.id,
entity_kind: "platform_admin_session",
entity_id: adminCtx.user.id,
event_type: "platform_admin.impersonate_started",
payload: {
tenant_id: org.id,
platform_admin_id: adminCtx.user.id,
exp,
},
});
return ok(
{
redirect_url: "/app/inbox",
tenant: {
id: org.id,
slug: org.slug,
display_name: org.display_name,
},
expires_at: new Date(exp * 1000).toISOString(),
},
{ requestId },
);
if (error || !id) return fail("state_conflict", "Não foi possível iniciar. Confira o acesso e o estado da organização.", 409, { requestId });
const { data: session } = await admin.from("platform_support_sessions").select("expires_at, access_mode")
.eq("id", id).eq("actor_user_id", ctx.user.id).eq("organization_id", tenantId).single();
if (!session) return fail("upstream_unavailable", "Acompanhamento iniciado. Recarregue para confirmar o estado.", 503, { requestId });
const exp = Math.floor(new Date(session.expires_at).getTime()/1000);
(await cookies()).set(IMPERSONATE_COOKIE_NAME, signImpersonateCookie({
tenantId, platformAdminId: ctx.user.id, sessionId: id, exp,
}), { httpOnly: true, secure: cookieSecure(), sameSite: "strict", path: "/", maxAge: 60*60*24*30 });
await audit({ action: "platform_admin.impersonate_started", actorUserId: ctx.user.id,
actingAsPlatformAdmin: true, bypassedRls: true, organizationId: tenantId,
resourceType: "organization", resourceId: tenantId, requestId,
metadata: { auth_session_id: sessionId, support_session_id: id, access_mode: session.access_mode, expires_at: session.expires_at } });
return ok({ redirect_url: "/app/inbox", expires_at: session.expires_at, support_session_id: id }, { requestId });
}
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/admin/tenants/[id]/reactivate (S-11.08)
*
@@ -26,6 +27,9 @@ export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const supportDenied = await requireSupportWrite((await params).id);
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id: tenantId } = await params;
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/admin/tenants/[id]/suspend (S-11.08)
*
@@ -26,6 +27,9 @@ export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const supportDenied = await requireSupportWrite((await params).id);
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id: tenantId } = await params;
+73 -65
View File
@@ -1,10 +1,14 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
import { createTenantSchema } from "@/lib/schemas/tenant-creation";
import { issueInvite } from "@/lib/auth/issue-invite";
import { mfaEmDivida } from "@/lib/auth/server";
import { type NextRequest } from "next/server";
import { z } from "zod";
import { requirePlatformAdmin } from "@/lib/auth/requirePlatformAdmin";
import { createAdminClient } from "@/lib/supabase/admin";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
// ---------------------------------------------------------------------------
// Schemas
@@ -17,19 +21,6 @@ const querySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(30),
});
const createSchema = z.object({
display_name: z.string().min(2).max(120),
slug: z
.string()
.min(2)
.max(40)
.regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"),
legal_name: z.string().min(2).max(255).optional(),
cnpj: z.string().optional(),
plan: z.enum(["standard", "pro", "enterprise"]).default("standard"),
owner_email: z.string().email(),
});
// ---------------------------------------------------------------------------
// Cursor helpers
// ---------------------------------------------------------------------------
@@ -65,9 +56,7 @@ export async function GET(req: NextRequest) {
return fail("forbidden", "Platform admin required", 403, { requestId });
}
const parsed = querySchema.safeParse(
Object.fromEntries(req.nextUrl.searchParams.entries()),
);
const parsed = querySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams.entries()));
if (!parsed.success) {
return fail("validation_error", "Invalid query params", 400, {
requestId,
@@ -108,9 +97,7 @@ export async function GET(req: NextRequest) {
}
if (q) {
query = query.or(
`display_name.ilike.%${q}%,slug::text.ilike.%${q}%,cnpj.ilike.%${q}%`,
);
query = query.or(`display_name.ilike.%${q}%,slug::text.ilike.%${q}%,cnpj.ilike.%${q}%`);
}
if (cursorPayload) {
@@ -164,6 +151,9 @@ export async function GET(req: NextRequest) {
// ---------------------------------------------------------------------------
export async function POST(req: NextRequest) {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
let adminCtx: Awaited<ReturnType<typeof requirePlatformAdmin>>;
@@ -173,6 +163,18 @@ export async function POST(req: NextRequest) {
return fail("forbidden", "Platform admin required", 403, { requestId });
}
if (adminCtx.platformAdmin.scope !== "full") {
return fail("forbidden", "Seu acesso de suporte não permite criar organizações", 403, {
requestId,
});
}
if (await mfaEmDivida())
return fail("mfa_required", "Confirme a verificação em duas etapas", 403, { requestId });
const key = req.headers.get("Idempotency-Key") ?? randomUUID();
if (!z.string().uuid().safeParse(key).success) {
return fail("validation_error", "Idempotency-Key deve ser UUID", 400, { requestId });
}
let body: unknown;
try {
body = await req.json();
@@ -180,7 +182,7 @@ export async function POST(req: NextRequest) {
return fail("validation_error", "Invalid JSON body", 400, { requestId });
}
const parsed = createSchema.safeParse(body);
const parsed = createTenantSchema.safeParse(body);
if (!parsed.success) {
return fail("validation_error", "Invalid request body", 400, {
requestId,
@@ -188,58 +190,64 @@ export async function POST(req: NextRequest) {
});
}
const { display_name, slug, legal_name, cnpj, plan, owner_email } = parsed.data;
const admin = createAdminClient();
const { data: org, error: insertError } = await admin
.from("organizations")
.insert({
display_name,
slug,
legal_name: legal_name ?? null,
cnpj: cnpj ?? null,
// A check constraint de organizations.status não tem 'onboarding' — o
// marcador de onboarding é onboarded_at null (mesmo modelo do signup).
status: "active",
settings: { plan },
created_by: adminCtx.user.id,
})
.select("id, slug, display_name")
.single();
if (insertError) {
if (insertError.code === "23505") {
return fail("conflict", "Slug already exists", 409, { requestId });
const request = { ...parsed.data, owner_email: parsed.data.owner_email.trim().toLowerCase() };
const { data: org, error } = await admin.rpc("fn_create_tenant_with_owner", {
p_actor: adminCtx.user.id,
p_key: key,
p_request: request,
p_hash: createHash("sha256").update(JSON.stringify(request)).digest("hex"),
});
if (error) {
if (error.code === "23505" || error.code === "22023") {
return fail("conflict", "Slug já existe ou a chave foi usada com outros dados", 409, {
requestId,
});
}
return fail("internal_error", "Failed to create tenant", 500, {
return fail("internal_error", "Não foi possível criar a organização", 500, { requestId });
}
if (org.created) {
await audit({
action: "tenant.created_by_platform_admin",
actorUserId: adminCtx.user.id,
actingAsPlatformAdmin: true,
bypassedRls: true,
organizationId: org.id,
resourceType: "organization",
resourceId: org.id,
requestId,
details: insertError.message,
metadata: {
slug: org.slug,
display_name: org.display_name,
plan: request.plan,
creator_role: "admin",
},
});
}
void audit({
action: "tenant.created_by_platform_admin",
actorUserId: adminCtx.user.id,
actingAsPlatformAdmin: true,
bypassedRls: true,
organizationId: org.id,
resourceType: "organization",
resourceId: org.id,
requestId,
metadata: {
const ownerInvitation =
request.owner_email === adminCtx.user.email?.trim().toLowerCase()
? null
: await issueInvite({
email: request.owner_email,
role: "admin",
interfaceSettings: request.owner_interface_settings,
organizationId: org.id,
orgName: org.display_name,
inviterId: adminCtx.user.id,
inviterName:
adminCtx.user.user_metadata?.full_name ?? adminCtx.user.email ?? "Administrador",
requestId,
inviteId: org.invite_id,
issuedAt: org.issued_at,
dispatch: org.created,
});
return ok(
{
id: org.id,
slug: org.slug,
display_name: org.display_name,
plan,
owner_email_hash: owner_email
? Buffer.from(owner_email.trim().toLowerCase())
.toString("hex")
.slice(0, 12) + "..."
: null,
owner_invitation: ownerInvitation,
},
});
return ok(
{ id: org.id, slug: org.slug, display_name: org.display_name },
{ status: 201, requestId },
);
}
@@ -0,0 +1,68 @@
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { requireRole } from "@/lib/auth/require-role";
import { requireSupportWrite } from "@/lib/impersonate/support";
import { createClient } from "@/lib/supabase/server";
import { googleRpc } from "@/lib/agenda/google/sync-store";
import { ok, fail } from "@/lib/api/wrappers";
import { logger } from "@/lib/logger";
import { audit } from "@/lib/audit";
export async function meetingAction(
req: Request,
context: { params: Promise<{ id: string }> },
action: "retry" | "deliver",
) {
const denied = await requireSupportWrite();
if (denied) return denied;
const requestId = randomUUID();
const auth = await requireRole("agent", { requestId, resource: "agenda" });
if (!auth.ok) return auth.response;
const { id } = await context.params;
const parsed = z
.object({
revision: z.string().regex(/^\d+$/),
request_id: z.uuid().nullable(),
conversation_id: z.uuid().optional(),
})
.strict()
.safeParse(await req.json().catch(() => null));
if (
!z.uuid().safeParse(id).success ||
!parsed.success ||
(action === "deliver" && !parsed.data.conversation_id)
)
return fail(
"validation_failed",
"Atualize o compromisso e escolha a conversa de destino.",
422,
{ requestId },
);
try {
const changed = await googleRpc(await createClient(), "fn_meet_action", {
p_org: auth.org.orgId,
p_id: id,
p_revision: parsed.data.revision,
p_request: parsed.data.request_id,
p_action: action,
p_conversation: parsed.data.conversation_id ?? null,
});
if (changed)
await audit({
action: "agenda.meet_action_requested",
organizationId: auth.org.orgId,
actorUserId: auth.user.id,
resourceType: "calendar_appointment",
resourceId: id,
requestId,
metadata: { action },
});
return ok({ pending: true, changed: Boolean(changed) }, { requestId });
} catch (error) {
const code = error && typeof error === "object" && "code" in error ? error.code : null;
if (code === "40001") return fail("conflict", "O compromisso ou atendimento mudou. Atualize e tente novamente.", 409, { requestId });
if (code === "42501") return fail("forbidden", "Esta ação exige o responsável pelo compromisso e uma conversa disponível.", 403, { requestId });
logger.error("agenda.meet_action_failed", { requestId, action, code: "internal_error" });
return fail("internal_error", "Não foi possível registrar a ação. Atualize e tente novamente em instantes.", 500, { requestId });
}
}
@@ -0,0 +1,7 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
import { meetingAction } from "../_action";
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
const denied = await requireSupportWrite();
if (denied) return denied;
return meetingAction(req, context, "deliver");
}
@@ -0,0 +1,7 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
import { meetingAction } from "../_action";
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
const denied = await requireSupportWrite();
if (denied) return denied;
return meetingAction(req, context, "retry");
}
@@ -0,0 +1,24 @@
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { requireRole } from "@/lib/auth/require-role";
import { requireSupportWrite } from "@/lib/impersonate/support";
import { createClient } from "@/lib/supabase/server";
import { googleRpc } from "@/lib/agenda/google/sync-store";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
const denied = await requireSupportWrite();if (denied) return denied;
const requestId = randomUUID();const auth = await requireRole("agent", { requestId, resource: "agenda" });if (!auth.ok) return auth.response;
const { id } = await context.params;
const parsed = z.object({ expected_domain_revision: z.string(), expected_google_local_revision: z.string(), etag: z.string().nullable(), choice: z.enum(["google", "local", "preserve_remote", "retry"]) }).strict().safeParse(await req.json().catch(() => null));
if (!z.uuid().safeParse(id).success || !parsed.success) return fail("validation_failed", "Atualize o compromisso antes de decidir.", 422, { requestId });
try {
await googleRpc(await createClient(), "fn_google_resolve", { p_org: auth.org.orgId, p_id: id, p_revision: parsed.data.expected_domain_revision,
p_local_revision: parsed.data.expected_google_local_revision, p_etag: parsed.data.etag, p_choice: parsed.data.choice });
void audit({ action: "agenda.google_resolution_requested", organizationId: auth.org.orgId, actorUserId: auth.user.id, resourceType: "calendar_appointment", resourceId: id, requestId, metadata: { choice: parsed.data.choice } });
return ok({ pending: true }, { requestId });
} catch (e) {
const stale = e instanceof Error && "code" in e && e.code === "40001";
return fail(stale ? "conflict" : "forbidden", stale ? "O compromisso mudou. Atualize a comparação antes de decidir." : "Esta decisão pertence ao responsável pelo compromisso.", stale ? 409 : 403, { requestId });
}
}
@@ -0,0 +1,17 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
import { POST as resolve } from "../resolver/route";
/** Retentativa usa exatamente as mesmas guardas; nunca faz HTTP no request humano. */
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
const denied = await requireSupportWrite();
if (denied) return denied;
const body: unknown = await req.json().catch(() => null);
return resolve(
new Request(req, {
body: JSON.stringify({
...(typeof body === "object" && body !== null ? body : {}),
choice: "retry",
}),
}),
context,
);
}
@@ -0,0 +1,82 @@
import { readServiceBoundarySupabase } from "@/lib/atendimento/origem";
import { meetingDeliverySchema, meetingAuthorizationCurrent } from "@/lib/agenda/google/meet";
import { conflictSchema } from "@/lib/agenda/google/sync-model";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { requireRole } from "@/lib/auth/require-role";
import { createClient } from "@/lib/supabase/server";
import { createAdminClient } from "@/lib/supabase/admin";
import { ok, fail } from "@/lib/api/wrappers";
import { tagDeIdioma } from "@/lib/i18n/datas";
import { traduzir } from "@/lib/i18n/dicionario";
export async function GET(_req: Request, context: { params: Promise<{ id: string }> }) {
const requestId = randomUUID();
const auth = await requireRole("viewer", { requestId, resource: "agenda" });
if (!auth.ok) return auth.response;
const { id } = await context.params;
if (!z.uuid().safeParse(id).success)
return fail("not_found", "Compromisso indisponível.", 404, { requestId });
const db = await createClient();
const org = auth.org.orgId;
const { data, error } = await db
.from("calendar_appointments")
.select(
"id,title,location_kind,meeting_state,meeting_url,meeting_request_id,meeting_last_error,meeting_delivery,meeting_delivery_job_id,contact_id,conversation_id,starts_at,ends_at,time_zone,status,revision,revision_started_at,outcome_user_id,outcome_source_kind,outcome_message_id,outcome_recorded_at,confirmation_next_at,cancellation_reason,owner_user_id,google_domain_revision:revision::text,google_local_revision::text,google_synced_local_revision::text,google_etag,google_synced_at,google_sync_error,google_conflict",
)
.eq("organization_id", org)
.eq("id", id)
.maybeSingle();
if (error)
return fail("internal_error", "Não foi possível carregar o compromisso.", 500, { requestId });
if (!data)
return fail("not_found", "Este compromisso está indisponível para você.", 404, { requestId });
// Só depois de provar a entidade pela sessão. Recibo privado não é listável.
const recovery = await createAdminClient()
.from("appointment_recovery_receipts")
.select("result,enrollment_id,invalidated_at,recorded_at")
.eq("organization_id", org)
.eq("appointment_id", id)
.eq("appointment_revision", data.revision)
.maybeSingle();
if (recovery.error)
return fail("internal_error", "Não foi possível carregar o próximo passo.", 500, { requestId });
const enrollment=recovery.data?.enrollment_id ? await createAdminClient().from("followup_enrollments")
.select("status,cancel_reason").eq("organization_id",org).eq("id",recovery.data.enrollment_id).maybeSingle() : {data:null,error:null};
if(enrollment.error) return fail("internal_error","Não foi possível carregar o acompanhamento.",500,{requestId});
const messages = data.contact_id
? await db
.from("messages")
.select("id,body,sent_at")
.eq("organization_id", org)
.eq("contact_id", data.contact_id)
.eq("direction", "inbound")
.not("service_revision", "is", null)
.gte("created_at", data.revision_started_at)
.order("sent_at", { ascending: false })
.limit(20)
: { data: [], error: null };
if (messages.error)
return fail("internal_error", "Não foi possível carregar as mensagens de confirmação.", 500, {
requestId,
});
const delivery = meetingDeliverySchema.safeParse(data.meeting_delivery);
const deliveryJob = delivery.success && delivery.data.state === "queued" && data.meeting_delivery_job_id
? await db.from("job_queue").select("status").eq("organization_id",org).eq("id",data.meeting_delivery_job_id).maybeSingle()
: {data:null,error:null};
if (deliveryJob.error) return fail("internal_error","Não foi possível conferir o envio do link.",500,{requestId});
const deliveryState = delivery.success ? delivery.data.state === "queued" && !["pending","running"].includes(deliveryJob.data?.status ?? "") ? "failed" : delivery.data.state : "none";
const destinations = data.contact_id && data.owner_user_id === auth.user.id ? await db.from("conversations").select("id,created_at,channel_session_id,contacts(name,phone_number)").eq("organization_id",org).eq("contact_id",data.contact_id).eq("is_group",false).not("status","in","(closed,resolved,archived)").order("created_at",{ascending:false}).limit(20) : {data:[],error:null};
if (destinations.error) return fail("internal_error","Não foi possível carregar as conversas de destino.",500,{requestId});
const originalBoundary = delivery.success ? delivery.data.service_boundary ?? null : null;
let authorizationCurrent = false;
if (originalBoundary && destinations.data?.some(d => d.id === originalBoundary.conversation_id && delivery.success && d.channel_session_id === delivery.data.channel_session_id)) {
try { authorizationCurrent = meetingAuthorizationCurrent(originalBoundary, await readServiceBoundarySupabase(createAdminClient(), org, originalBoundary.conversation_id)); }
catch { return fail("internal_error", "Não foi possível conferir o atendimento do envio.", 500, { requestId }); }
}
const meeting = data.location_kind === "google_meet" ? {state:data.meeting_state,url:data.meeting_state === "ready" ? data.meeting_url : null,request_id:data.meeting_request_id,error:data.meeting_last_error,delivery_state:deliveryState,delivery_error:delivery.success ? delivery.data.error ?? null : null,delivery_authorization_current:authorizationCurrent,delivery_conversation_id:delivery.success ? delivery.data.service_boundary?.conversation_id ?? null : null,can_manage:data.owner_user_id === auth.user.id,destinations:(destinations.data ?? []).map(d=>{const contact=Array.isArray(d.contacts)?d.contacts[0]:d.contacts;return {id:d.id,label:`${contact?.name ?? traduzir("Contato", auth.user.idioma)}${contact?.phone_number ? ` — ${contact.phone_number}` : ""} — ${new Date(d.created_at).toLocaleDateString(tagDeIdioma(auth.user.idioma))}`};})} : null;
const { meeting_delivery_job_id: _deliveryJob, meeting_delivery: _privateDelivery, meeting_request_id: _request, meeting_last_error: _error, meeting_state: _state, meeting_url: _url, google_domain_revision, google_conflict, google_local_revision, google_synced_local_revision, google_etag, google_synced_at, google_sync_error, owner_user_id, ...detail } = data;
const conflict = conflictSchema.safeParse(google_conflict);
return ok({ ...detail, meeting, google_sync: { revision: google_domain_revision, local_revision: String(google_local_revision), etag: google_etag, synced_at: google_synced_at, error: google_sync_error,
pending: BigInt(google_local_revision) > BigInt(google_synced_local_revision), conflict: conflict.success ? conflict.data : null, can_resolve: owner_user_id === auth.user.id }, recovery: recovery.data ? {...recovery.data,enrollment_status:enrollment.data?.status??null,cancel_reason:enrollment.data?.cancel_reason??null} : null, evidence_messages: messages.data }, { requestId });
}
+55 -65
View File
@@ -1,3 +1,5 @@
import { randomUUID } from "node:crypto";
import type { Json } from "@/lib/database.types";
/**
* A REGRA de marcar, remarcar e cancelar — fora da rota, de propósito.
*
@@ -25,7 +27,6 @@ import { horariosLivresDaOrg } from "@/lib/agenda/consulta";
import {
atividadeDaTransicao,
autorParaTimeline,
precisaEmpurrarAoGoogle,
type SituacaoAnterior,
type Transicao,
} from "@/lib/agenda/laco";
@@ -56,6 +57,7 @@ export interface MarcarInput {
starts_at: string;
owner_user_id?: string;
contact_id?: string;
conversation_id?: string;
title?: string;
notes?: string;
/**
@@ -70,6 +72,9 @@ export interface MarcarInput {
export interface AlterarInput {
id: string;
revision?: number;
outcome_message_id?: string;
confirmation_next_at?: string;
starts_at?: string;
status?: "confirmed" | "completed" | "no_show";
notes?: string;
@@ -79,6 +84,7 @@ export interface AlterarInput {
export interface CancelarInput {
id: string;
revision?: number;
reason: string;
}
@@ -157,6 +163,12 @@ export async function marcarAgendamentoHandler(
fim,
});
const booking = tipo.location_kind === "google_meet" ? ctx.meetingBooking : undefined;
if (booking && (booking.boundary.organization_id !== ctx.organization_id || booking.boundary.contact_id !== input.contact_id || ctx.actor.type !== "ai_agent")) {
throw new ApiError(403,"forbidden",undefined,ctx.requestId,"A conversa deste atendimento mudou.");
}
const delivery = booking ? { state:"waiting_for_link",generation:randomUUID(),service_boundary:booking.boundary,source_operation_id:booking.sourceJobId,
booking_claim:booking.claim,authorized_by:{kind:ctx.actor.type,id:ctx.actor.id} } : {state:"none"};
const { data: criado, error: erroInsert } = await supabase
.from("calendar_appointments")
.insert({
@@ -171,6 +183,8 @@ export async function marcarAgendamentoHandler(
status: tipo.requires_confirmation ? "pending" : "confirmed",
owner_user_id: donoId,
contact_id: input.contact_id ?? null,
conversation_id: booking?.boundary.conversation_id ?? input.conversation_id ?? null,
meeting_delivery: delivery as unknown as Json,
location_kind: tipo.location_kind,
location_details: tipo.location_details,
notes: input.notes ?? null,
@@ -182,7 +196,7 @@ export async function marcarAgendamentoHandler(
created_by_user_id: ctx.actor.type === "user" ? ctx.actor.id : null,
source: ctx.actor.type === "user" ? "ui" : "mcp",
})
.select("id, starts_at, ends_at, status, time_zone")
.select("id, starts_at, ends_at, status, time_zone, revision, meeting_state, meeting_url")
.single();
if (erroInsert) {
throw new ApiError(500, "internal_error", undefined, ctx.requestId, erroInsert.message);
@@ -194,7 +208,6 @@ export async function marcarAgendamentoHandler(
contactId: input.contact_id ?? null,
atividade: atividadeDaTransicao(null, transicao),
transicao,
empurrarAoGoogle: precisaEmpurrarAoGoogle(null, transicao),
fusoDoCompromisso: criado.time_zone,
nomeDoTipo: tipo.name,
});
@@ -234,6 +247,7 @@ export async function alterarAgendamentoHandler(
): Promise<Record<string, unknown>> {
const atual = await exigeAgendamento(supabase, ctx, input.id, [
"id",
"revision",
"event_type_id",
"owner_user_id",
"contact_id",
@@ -242,6 +256,7 @@ export async function alterarAgendamentoHandler(
"time_zone",
]);
if (input.revision !== undefined && input.revision !== Number(atual.revision)) throw new ApiError(409,"conflict",undefined,ctx.requestId,"O compromisso mudou. Recarregue antes de confirmar.");
if (atual.status === "cancelled") {
throw new ApiError(
422,
@@ -253,12 +268,17 @@ export async function alterarAgendamentoHandler(
}
const mudanca: Record<string, unknown> = {};
if (input.status === "completed" || input.status === "no_show") {
if (ctx.actor.type !== "user") throw new ApiError(403,"forbidden",undefined,ctx.requestId,"Peça à equipe para confirmar a presença no compromisso. Uma interpretação de texto não registra o fato.");
if (input.outcome_message_id) mudanca.outcome_message_id=input.outcome_message_id;
}
if(input.confirmation_next_at) mudanca.confirmation_next_at=input.confirmation_next_at;
if (input.notes !== undefined) mudanca.notes = input.notes;
// Trocar SÓ o convidado não é remarcação nem mudança de situação, então não
// produz `transicao` — e não deveria: a timeline do lead não ganha notícia
// por causa de um e-mail digitado. Quem leva a mudança ao Google é a coluna
// gerada `needs_google_push` (migration 0200), que passa a `true` sozinha
// porque o trigger de `updated_at` disparou neste mesmo UPDATE.
// gerada `needs_google_push` (migration 0225), que compara a revisão
// publicável com o último aceite; notes e metadata não criam intenção.
if (input.guest_email !== undefined) mudanca.guest_email = input.guest_email || null;
let transicao: Transicao | null = null;
@@ -334,16 +354,7 @@ export async function alterarAgendamentoHandler(
if (Object.keys(mudanca).length === 0) return { id: atual.id, inalterado: true };
const { data: salvo, error: erroUpdate } = await supabase
.from("calendar_appointments")
.update(mudanca)
.eq("organization_id", ctx.organization_id)
.eq("id", atual.id as string)
.select("id, starts_at, ends_at, status, time_zone")
.single();
if (erroUpdate) {
throw new ApiError(500, "internal_error", undefined, ctx.requestId, erroUpdate.message);
}
const salvo = await alteraComRevisao(supabase,ctx,input.id,input.revision ?? Number(atual.revision),mudanca);
if (transicao) {
await fecharOLaco(supabase, ctx, {
@@ -351,27 +362,21 @@ export async function alterarAgendamentoHandler(
contactId: (atual.contact_id as string | null) ?? null,
atividade: atividadeDaTransicao(atual.status as SituacaoAnterior, transicao),
transicao,
empurrarAoGoogle: precisaEmpurrarAoGoogle(atual.status as SituacaoAnterior, transicao),
fusoDoCompromisso: salvo.time_zone,
fusoDoCompromisso: String(salvo.time_zone),
nomeDoTipo: "Agendamento",
outcome: {revision:salvo.revision,source_kind:salvo.outcome_source_kind,message_id:salvo.outcome_message_id,recorded_at:salvo.outcome_recorded_at},
});
// ⚠️ `completed` e `no_show` NÃO são auditados, por decisão do maestro: não
// são mutação de intenção, são registro de fato já consumado, e vivem na
// timeline. Procurar o tipo de audit deles e não achar é o esperado.
if (transicao === "rescheduled") {
void audit({
action: "agenda.appointment_rescheduled",
actorUserId: ctx.actor.type === "user" ? ctx.actor.id : null,
organizationId: ctx.organization_id,
resourceType: "calendar_appointment",
resourceId: atual.id as string,
requestId: ctx.requestId,
metadata: { de: atual.starts_at, para: salvo.starts_at },
});
}
void audit({action: transicao === "rescheduled" ? "agenda.appointment_rescheduled" : transicao === "completed" || transicao === "no_show" ? "agenda.appointment_outcome_recorded" : "agenda.appointment_updated",
actorUserId:ctx.actor.type === "user" ? ctx.actor.id : null,organizationId:ctx.organization_id,
resourceType:"calendar_appointment",resourceId:input.id,requestId:ctx.requestId,
metadata:{status:salvo.status,revision:salvo.revision,outcome_source_kind:salvo.outcome_source_kind,outcome_message_id:salvo.outcome_message_id}});
}
if (!transicao) void audit({action:"agenda.appointment_updated",actorUserId:ctx.actor.type==="user"?ctx.actor.id:null,
organizationId:ctx.organization_id,resourceType:"calendar_appointment",resourceId:input.id,requestId:ctx.requestId,
metadata:{revision:salvo.revision,confirmation_next_at:salvo.confirmation_next_at}});
return salvo as Record<string, unknown>;
}
@@ -391,6 +396,7 @@ export async function cancelarAgendamentoHandler(
): Promise<Record<string, unknown>> {
const atual = await exigeAgendamento(supabase, ctx, input.id, [
"id",
"revision",
"contact_id",
"status",
"time_zone",
@@ -398,31 +404,20 @@ export async function cancelarAgendamentoHandler(
// Idempotente: cancelar o que já está cancelado devolve o estado, não erro —
// quem chamou queria o compromisso desmarcado, e ele está.
if (input.revision !== undefined && input.revision !== Number(atual.revision)) throw new ApiError(409,"conflict",undefined,ctx.requestId,"O compromisso mudou. Recarregue antes de confirmar.");
if (atual.status === "cancelled") {
return { id: atual.id, status: "cancelled", ja_estava: true };
}
const { data: salvo, error: erroUpdate } = await supabase
.from("calendar_appointments")
.update({
status: "cancelled",
cancelled_at: new Date().toISOString(),
cancellation_reason: input.reason,
})
.eq("organization_id", ctx.organization_id)
.eq("id", atual.id as string)
.select("id, status, cancelled_at, cancellation_reason")
.single();
if (erroUpdate) {
throw new ApiError(500, "internal_error", undefined, ctx.requestId, erroUpdate.message);
}
const salvo = await alteraComRevisao(supabase,ctx,input.id,input.revision ?? Number(atual.revision),{
status:"cancelled",cancellation_reason:input.reason,
});
await fecharOLaco(supabase, ctx, {
appointmentId: atual.id as string,
contactId: (atual.contact_id as string | null) ?? null,
atividade: atividadeDaTransicao(atual.status as SituacaoAnterior, "cancelled"),
transicao: "cancelled",
empurrarAoGoogle: precisaEmpurrarAoGoogle(atual.status as SituacaoAnterior, "cancelled"),
fusoDoCompromisso: atual.time_zone as string,
nomeDoTipo: "Agendamento",
});
@@ -513,11 +508,11 @@ function autorParaCriacao(actor: Actor): string {
}
/**
* Os TRÊS emissores do laço, no mesmo fluxo da mutação.
* Vínculo e atividade no mesmo fluxo da mutação.
*
* `crm_lead_links` faz o compromisso PERTENCER ao negócio (é por ele que o
* dossiê o lista); `crm_lead_activities` é o que aparece na TIMELINE; `event_log`
* é o que leva o compromisso ao Google. Só o vínculo e nada aparece na tela; só
* dossiê o lista); `crm_lead_activities` aparece na timeline. A pendência Google
* vem da revisão publicável persistida. Só o vínculo e nada aparece na tela; só
* a atividade e o dossiê não acha o compromisso.
*
* ⚠️ `crm_lead_activities.lead_id` é NOT NULL: agendamento de contato que ainda
@@ -532,26 +527,12 @@ async function fecharOLaco(
contactId: string | null;
atividade: string | null;
transicao: Transicao;
empurrarAoGoogle: boolean;
fusoDoCompromisso: string;
nomeDoTipo: string;
outcome?: Record<string,unknown>;
},
): Promise<void> {
if (args.empurrarAoGoogle) {
// ⚠️ AINDA SEM CONSUMIDOR. `agenda.appointment.push_to_google` não é
// declarado por handler nenhum, e `drain.ts:54` filtra por
// `.in("event_type", handledTypes)` — a linha nunca é SELECIONADA e fica
// `pending` para sempre: não vira `dead`, não conta tentativa, não acende
// aviso. E não há sonda sobre `event_log` parado. O consumidor é da frente
// do Google; o contrato e o payload (com o fuso) já estão prontos aqui.
await supabase.from("event_log").insert({
organization_id: ctx.organization_id,
event_type: "agenda.appointment.push_to_google",
entity_kind: "calendar_appointment",
entity_id: args.appointmentId,
payload: { appointment_id: args.appointmentId, time_zone: args.fusoDoCompromisso },
});
}
// Pendência Google é derivada da revisão publicável; não emite evento sem consumer.
const leadId = args.contactId ? await leadAtivoDoContato(supabase, ctx, args.contactId) : null;
@@ -610,6 +591,7 @@ async function fecharOLaco(
sourceId: args.appointmentId,
actor: ctx.actor,
reason: `${args.nomeDoTipo} — ${args.atividade}`,
payload: args.outcome ? {outcome:args.outcome} : {},
// ⚠️ `sync` não existe no CHECK de `actor_kind`; `autorParaTimeline` mapeia.
actorKind: autorParaTimeline(ctx.actor.type),
} as never);
@@ -648,3 +630,11 @@ async function leadAtivoDoContato(
});
return rota.routed ? rota.leadId : null;
}
async function alteraComRevisao(supabase:SB,ctx:HandlerCtx,id:string,revision:number,patch:Record<string,unknown>):Promise<Record<string,unknown>> {
const {data,error}=await supabase.rpc("fn_appointment_change",{p_org:ctx.organization_id,p_id:id,p_revision:revision,p_patch:patch});
if(error) throw new ApiError(error.code === "40001" ? 409 : error.code === "42501" ? 403 : error.code === "P0002" ? 404 : 422,
error.code === "40001" ? "conflict" : error.code === "42501" ? "forbidden" : error.code === "P0002" ? "not_found" : "validation_failed",undefined,ctx.requestId,
error.code === "40001" ? "Este compromisso mudou. Atualize os dados antes de confirmar novamente." : "Não foi possível alterar este compromisso. Confira a presença, o horário e a mensagem vinculada.");
return data as Record<string,unknown>;
}
+17 -1
View File
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* `/api/v1/agenda/agendamentos` — a rota, FINA.
*
@@ -78,6 +79,7 @@ const marcarSchema = z.object({
starts_at: z.string().datetime({ offset: true }),
owner_user_id: z.string().uuid().optional(),
contact_id: z.string().uuid().optional(),
conversation_id: z.string().uuid().optional(),
title: z.string().min(1).max(200).optional(),
notes: z.string().max(2000).optional(),
guest_email: emailDoConvidado.optional(),
@@ -86,6 +88,9 @@ const marcarSchema = z.object({
const alterarSchema = z
.object({
id: z.string().uuid(),
revision: z.number().int().positive().optional(),
outcome_message_id: z.string().uuid().optional(),
confirmation_next_at: z.string().datetime({offset:true}).optional(),
/** Remarcar: o novo início. A duração vem do tipo, como na criação. */
starts_at: z.string().datetime({ offset: true }).optional(),
/**
@@ -98,6 +103,7 @@ const alterarSchema = z
})
.refine(
(c) =>
c.confirmation_next_at !== undefined ||
c.starts_at !== undefined ||
c.status !== undefined ||
c.notes !== undefined ||
@@ -109,6 +115,7 @@ const alterarSchema = z
const cancelarSchema = z.object({
id: z.string().uuid(),
revision: z.number().int().positive().optional(),
/**
* ⚠️ OBRIGATÓRIO, e não é burocracia: é o que a equipe lê ao ver o horário
* vago. "Cancelado" sem motivo faz alguém ligar para o cliente perguntando o
@@ -203,7 +210,7 @@ export async function GET(req: NextRequest): Promise<Response> {
const externos: AgendamentoDaResposta[] = [];
if (parsed.data.de && parsed.data.ate) {
const { data: ocupacao, error: erroOcupacao } = await supabase
.from("calendar_external_events")
.from("calendar_selected_external_events")
.select("id, starts_at, ends_at, calendar_connections!inner(user_id)")
.eq("organization_id", activeOrg.orgId)
.gte("starts_at", parsed.data.de)
@@ -249,14 +256,23 @@ export async function GET(req: NextRequest): Promise<Response> {
}
export async function POST(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
return despachar(req, marcarSchema, marcarAgendamentoHandler, 201);
}
export async function PATCH(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
return despachar(req, alterarSchema, alterarAgendamentoHandler, 200);
}
export async function DELETE(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
return despachar(req, cancelarSchema, cancelarAgendamentoHandler, 200);
}
+55
View File
@@ -0,0 +1,55 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
import { randomUUID } from "node:crypto";
import { requireRole } from "@/lib/auth/require-role";
import { createClient } from "@/lib/supabase/server";
import { agendaSettingsSchema, agendaSettingsWriteSchema } from "@/lib/schemas/settings";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
export async function GET() {
const requestId = randomUUID();
const auth = await requireRole("viewer", { requestId, resource: "agenda" });
if (!auth.ok) return auth.response;
const { data, error } = await (
await createClient()
)
.from("organizations")
.select("settings")
.eq("id", auth.org.orgId)
.single();
if (error)
return fail("internal_error", "Não foi possível carregar os prazos.", 500, { requestId });
return ok(agendaSettingsSchema.parse(data.settings?.agenda), { requestId });
}
export async function PATCH(req: Request) {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const auth = await requireRole("manager", { requestId, resource: "agenda" });
if (!auth.ok) return auth.response;
const parsed = agendaSettingsWriteSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success)
return fail("validation_failed", "Confira os prazos de confirmação e proteção.", 422, {
requestId,
});
const { data, error } = await (
await createClient()
).rpc("fn_agenda_settings", { p_org: auth.org.orgId, p_config: parsed.data });
if (error)
return fail(
error.code === "42501" ? "forbidden" : "internal_error",
"Não foi possível alterar os prazos desta organização.",
error.code === "42501" ? 403 : 500,
{ requestId },
);
void audit({
action: "agenda.settings_updated",
organizationId: auth.org.orgId,
actorUserId: auth.user.id,
requestId,
resourceType: "organization",
resourceId: auth.org.orgId,
metadata: parsed.data,
});
return ok(data, { requestId });
}
@@ -0,0 +1,24 @@
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { requireRole } from "@/lib/auth/require-role";
import { requireSupportWrite } from "@/lib/impersonate/support";
import { createAdminClient } from "@/lib/supabase/admin";
import { createClient } from "@/lib/supabase/server";
import { refreshCatalog } from "@/lib/agenda/google/calendar-executor";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
export async function POST(req: Request) {
const denied = await requireSupportWrite();if (denied) return denied;
const requestId = randomUUID();const auth = await requireRole("agent", { requestId, resource: "agenda" });if (!auth.ok) return auth.response;
const parsed = z.object({ connection_id: z.uuid() }).strict().safeParse(await req.json().catch(() => null));
if (!parsed.success) return fail("validation_failed", "Confira a conexão escolhida.", 422, { requestId });
const { data, error } = await (await createClient()).from("calendar_connections").select("id").eq("organization_id", auth.org.orgId).eq("user_id", auth.user.id).eq("id", parsed.data.connection_id).maybeSingle();
if (error || !data) return fail("not_found", "Conexão indisponível.", 404, { requestId });
try {
await refreshCatalog(createAdminClient(), auth.org.orgId, data.id);
void audit({ action: "agenda.google_catalog_updated", organizationId: auth.org.orgId, actorUserId: auth.user.id, resourceType: "calendar_connection", resourceId: data.id, requestId });
return ok({ refreshed: true }, { requestId });
} catch {
return fail("conflict", "Não foi possível atualizar. Confira a conexão e tente novamente.", 409, { requestId });
}
}
@@ -0,0 +1,110 @@
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { requireRole } from "@/lib/auth/require-role";
import { requireSupportWrite } from "@/lib/impersonate/support";
import { createClient } from "@/lib/supabase/server";
import { coverageSchema } from "@/lib/agenda/google/sync-model";
import { googleRpc } from "@/lib/agenda/google/sync-store";
import { canReadCalendar, canWriteCalendar } from "@/lib/agenda/google/transport";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
export async function GET() {
const requestId = randomUUID();
const auth = await requireRole("agent", { requestId, resource: "agenda" });
if (!auth.ok) return auth.response;
const db = await createClient();
const org = auth.org.orgId;
const { data: connections, error } = await db
.from("calendar_connections")
.select("id,account_email,status,last_sync_error,calendar_selection_revision::text")
.eq("organization_id", org)
.eq("user_id", auth.user.id)
.eq("provider", "google_calendar")
.order("id");
if (error)
return fail("internal_error", "Não foi possível carregar suas agendas.", 500, { requestId });
if (!connections?.length) return ok({ connections: [], calendars: [] }, { requestId });
const { data: calendars, error: ce } = await db
.from("calendar_connection_calendars")
.select(
"id,connection_id,name,time_zone,is_primary,counts_for_conflicts,is_destination,access_role,allowed_conference_types,available,last_sync_at,sync_error,sync_coverage,sync_cursor,catalog_checked_at",
)
.eq("organization_id", org)
.in(
"connection_id",
connections.map((c) => c.id),
)
.order("name");
if (ce)
return fail("internal_error", "Não foi possível carregar suas agendas.", 500, { requestId });
return ok(
{
connections: connections.map((c) => ({
...c,
calendar_selection_revision: String(c.calendar_selection_revision),
})),
calendars: (calendars ?? []).map((c) => ({
...c,
sync_cursor: undefined,
sync_coverage: coverageSchema.safeParse(c.sync_coverage).success
? coverageSchema.parse(c.sync_coverage)
: null,
reading: !!c.sync_cursor,
can_read:
c.available &&
connections.some((x) => x.id === c.connection_id && x.status === "healthy") &&
canReadCalendar(c.access_role ?? ""),
can_write:
c.available &&
connections.some((x) => x.id === c.connection_id && x.status === "healthy") &&
canWriteCalendar(c.access_role ?? ""),
})),
},
{ requestId },
);
}
const selectionSchema = z
.object({
revisions: z
.array(z.object({ connection_id: z.uuid(), revision: z.string().regex(/^\d+$/) }).strict())
.max(100),
sources: z.array(z.uuid()).max(1000),
destination: z.uuid(),
})
.strict();
export async function PATCH(req: Request) {
const denied = await requireSupportWrite();
if (denied) return denied;
const requestId = randomUUID();
const auth = await requireRole("agent", { requestId, resource: "agenda" });
if (!auth.ok) return auth.response;
const parsed = selectionSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success)
return fail("validation_failed", "Confira as agendas escolhidas.", 422, { requestId });
try {
await googleRpc(await createClient(), "fn_google_selection", {
p_org: auth.org.orgId,
p_revisions: parsed.data.revisions,
p_sources: parsed.data.sources,
p_destination: parsed.data.destination,
});
void audit({
action: "agenda.google_selection_updated",
organizationId: auth.org.orgId,
actorUserId: auth.user.id,
requestId,
metadata: { sources: parsed.data.sources, destination: parsed.data.destination },
});
return ok({ saved: true }, { requestId });
} catch (e) {
const stale = e instanceof Error && "code" in e && e.code === "40001";
return fail(
stale ? "conflict" : "forbidden",
stale
? "Suas agendas mudaram. Atualize a lista antes de salvar."
: "Escolha agendas disponíveis da sua própria conexão.",
stale ? 409 : 403,
{ requestId },
);
}
}
@@ -1,3 +1,4 @@
import { supportCallbackWriteAllowed } from "@/lib/impersonate/support";
/**
* GET /api/v1/agenda/google/callback — a volta do consentimento do Google.
*
@@ -221,6 +222,7 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
// `code` do Google — que é de uso único — antes de descobrir que o `state`
// era repetido, e quem apresentasse o legítimo receberia "código já usado",
// um erro que aponta para o Google e não para o replay.
if (!(await supportCallbackWriteAllowed(organizationId, userId, estado.authSessionId))) return voltar("erro=retorno_nao_verificavel");
const admin = createAdminClient();
const { error: erroDoNonce } = await admin.from("calendar_oauth_nonces").insert({
nonce: estado.nonce,
@@ -430,6 +432,8 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
}
await audit({
actorUserId: userId,
actorAuthSessionId: estado.authSessionId,
action: "agenda.google.conexao_concluida",
organizationId,
metadata: { user_id: userId, account_email: conta.conta.email, fuso: conta.conta.fuso },
+4 -1
View File
@@ -1,3 +1,4 @@
import { requireSupportWrite, authenticatedSessionId } from "@/lib/impersonate/support";
/**
* GET /api/v1/agenda/google/connect — começa a conexão da agenda do Google.
*
@@ -49,6 +50,8 @@ function voltarComErro(codigo: string): NextResponse {
}
export async function GET(req: NextRequest): Promise<NextResponse> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = req.headers.get("x-request-id") ?? undefined;
const autorizado = await requireRole("agent", { requestId, resource: "calendar_connections" });
@@ -71,7 +74,7 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
let state: string;
try {
state = emitirEstado(
{ organizationId: org.orgId, userId: user.id },
{ organizationId: org.orgId, userId: user.id, authSessionId: await authenticatedSessionId() },
{ segredo: env.INTERNAL_SECRET, agora: new Date(), nonce },
);
} catch {
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* DESCONECTAR A AGENDA DO GOOGLE.
*
@@ -60,6 +61,9 @@ const corpo = z.object({
});
export async function DELETE(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = req.headers.get("x-request-id") ?? undefined;
const autorizado = await requireRole("agent", { requestId, resource: "calendar_connections" });
@@ -134,6 +134,7 @@ export async function GET(req: NextRequest): Promise<Response> {
// Fechado na ação, aberto na informação: o horário fica bloqueado, e a
// tela pode dizer desde quando a agenda conectada parou de atualizar.
fontes_defasadas: consulta.fontesDefasadas,
google_cobertura_parcial: consulta.googleCoberturaParcial,
// Diferente de `fontes_defasadas`: lá a conexão já trouxe eventos e parou
// de atualizar; aqui ela nunca trouxe nada, e a grade pode estar mentindo
// por inteiro. Sai da mesma função que serve às ferramentas MCP, para a
+13
View File
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* TIPOS DE AGENDAMENTO — criar, alterar e desativar pela API.
*
@@ -126,6 +127,9 @@ export async function GET(req: NextRequest): Promise<Response> {
}
export async function POST(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = req.headers.get("x-request-id") ?? undefined;
const autorizado = await requireRole("manager", { requestId, resource: "calendar_event_types" });
if (!autorizado.ok) return autorizado.response;
@@ -152,6 +156,7 @@ export async function POST(req: NextRequest): Promise<Response> {
}
await audit({
actorUserId: autorizado.user.id,
action: "agenda.tipo_criado",
organizationId: autorizado.org.orgId,
resourceType: "calendar_event_types",
@@ -162,6 +167,9 @@ export async function POST(req: NextRequest): Promise<Response> {
}
export async function PATCH(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = req.headers.get("x-request-id") ?? undefined;
const autorizado = await requireRole("manager", { requestId, resource: "calendar_event_types" });
if (!autorizado.ok) return autorizado.response;
@@ -191,6 +199,7 @@ export async function PATCH(req: NextRequest): Promise<Response> {
if (!data) return fail("not_found", t("Tipo de agendamento não encontrado."), 404, { requestId });
await audit({
actorUserId: autorizado.user.id,
action: "agenda.tipo_alterado",
organizationId: autorizado.org.orgId,
resourceType: "calendar_event_types",
@@ -201,6 +210,9 @@ export async function PATCH(req: NextRequest): Promise<Response> {
}
export async function DELETE(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = req.headers.get("x-request-id") ?? undefined;
const autorizado = await requireRole("manager", { requestId, resource: "calendar_event_types" });
if (!autorizado.ok) return autorizado.response;
@@ -222,6 +234,7 @@ export async function DELETE(req: NextRequest): Promise<Response> {
if (!data) return fail("not_found", t("Tipo de agendamento não encontrado."), 404, { requestId });
await audit({
actorUserId: autorizado.user.id,
action: "agenda.tipo_desativado",
organizationId: autorizado.org.orgId,
resourceType: "calendar_event_types",
+42
View File
@@ -0,0 +1,42 @@
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { requireRole } from "@/lib/auth/require-role";
import { createClient } from "@/lib/supabase/server";
import { ok, fail } from "@/lib/api/wrappers";
export async function GET(req: Request) {
const requestId = randomUUID();
const auth = await requireRole("agent", { requestId, resource: "agenda" });
if (!auth.ok) return auth.response;
const input = z
.object({ contact_id: z.uuid().optional(), q: z.string().max(100).optional() })
.safeParse(Object.fromEntries(new URL(req.url).searchParams));
if (!input.success) return fail("validation_failed", "Confira o contato.", 422, { requestId });
const db = await createClient();
let contacts = db
.from("contacts")
.select("id,name")
.eq("organization_id", auth.org.orgId)
.eq("is_anonymized", false)
.order("name")
.limit(30);
if (input.data.contact_id) contacts = contacts.eq("id", input.data.contact_id);
else if (input.data.q)
contacts = contacts.ilike("name", `%${input.data.q.replace(/[%_]/g, "")}%`);
const result = await contacts;
if (result.error)
return fail("internal_error", "Não foi possível carregar os contatos.", 500, { requestId });
const conversations =
input.data.contact_id && result.data.length
? await db
.from("conversations")
.select("id,created_at,status")
.eq("organization_id", auth.org.orgId)
.eq("contact_id", input.data.contact_id)
.eq("is_group", false)
.order("created_at", { ascending: false })
.limit(30)
: { data: [], error: null };
if (conversations.error)
return fail("internal_error", "Não foi possível carregar as conversas.", 500, { requestId });
return ok({ contacts: result.data, conversations: conversations.data }, { requestId });
}
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/agents/:id/duplicate (admin)
*
@@ -24,6 +25,9 @@ const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
type Ctx = { params: Promise<{ id: string }> };
export async function POST(_req: NextRequest, ctx: Ctx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
if (!UUID_RX.test(id)) return fail("invalid_request", "id inválido.", 400, { requestId });
+9 -11
View File
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/agents/:id/pause (admin)
*
@@ -21,6 +22,9 @@ const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
type Ctx = { params: Promise<{ id: string }> };
export async function POST(_req: NextRequest, ctx: Ctx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
if (!UUID_RX.test(id)) return fail("invalid_request", "id inválido.", 400, { requestId });
@@ -45,18 +49,9 @@ export async function POST(_req: NextRequest, ctx: Ctx): Promise<Response> {
const previousVersionId = existing.published_version_id as string | null;
if (previousVersionId) {
await admin
.from("ai_agent_versions")
.update({ status: "superseded", superseded_at: new Date().toISOString() })
.eq("id", previousVersionId)
.eq("organization_id", activeOrg.orgId)
.eq("status", "published");
}
const { error } = await admin
.from("ai_agents")
.update({ published_version_id: null, updated_at: new Date().toISOString() })
.update({ paused_at: new Date().toISOString(), updated_at: new Date().toISOString() })
.eq("id", id)
.eq("organization_id", activeOrg.orgId);
@@ -72,5 +67,8 @@ export async function POST(_req: NextRequest, ctx: Ctx): Promise<Response> {
metadata: { previous_version_id: previousVersionId },
});
return ok({ id, published_version_id: null }, { requestId });
return ok(
{ id, published_version_id: previousVersionId, paused_at: new Date().toISOString() },
{ requestId },
);
}
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* Épico Operação Visível (F3) — POST: aplica uma proposta do flywheel como
* versão NOVA do agente via publish-por-ponteiro (o clique É o gate humano).
@@ -28,6 +29,9 @@ const HTTP_BY_CODE: Record<ApplyProposalErrorCode, number> = {
type Ctx = { params: Promise<{ id: string; pid: string }> };
export async function POST(_req: NextRequest, ctx: Ctx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id, pid } = await ctx.params;
if (!UUID_RX.test(id) || !UUID_RX.test(pid)) {
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/agents/:id/publish body: { version_id }
*
@@ -31,6 +32,9 @@ const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
type Ctx = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, ctx: Ctx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
if (!UUID_RX.test(id)) {
@@ -0,0 +1,91 @@
import {recordLegacyNotice,legacyRecoveryCause,legacyRecoveryMessage} from '@/lib/ai/agents/legacy-notice';
import { randomUUID } from "node:crypto";
import { z } from "zod";
import type { NextRequest } from "next/server";
import { requireSupportWrite } from "@/lib/impersonate/support";
import { requireRole } from "@/lib/auth/require-role";
import { createAdminClient } from "@/lib/supabase/admin";
import { publishFirstVersion } from "@/lib/ai/agents/first-publication";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
const input = z.object({
channel_id: z.uuid(),
provider: z.string().min(1).max(80),
model: z.string().min(1).max(200),
credential_id: z.uuid().nullable(),
});
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const denied = await requireSupportWrite();
if (denied) return denied;
const requestId = randomUUID(),
auth = await requireRole("admin", { requestId, resource: "ai_agents" });
if (!auth.ok) return auth.response;
const { id } = await ctx.params,
parsed = input.safeParse(await req.json().catch(() => null));
if (!z.uuid().safeParse(id).success || !parsed.success)
return fail("validation_failed", "Escolha canal, modelo e credencial.", 422, { requestId });
const admin = createAdminClient();
const { data: agent } = await admin
.from("ai_agents")
.select("id,kind,system_prompt,published_version_id,archived_at")
.eq("organization_id", auth.org.orgId)
.eq("id", id)
.maybeSingle();
if (!agent || agent.kind === "mcp_agent" || agent.archived_at)
return fail("not_found", "Agente legado indisponível.", 404, { requestId });
if (agent.published_version_id) {
await recordLegacyNotice(admin,auth.org.orgId,id,'pronto');
return ok({ published: true }, { requestId });
}
const { data: versions, error } = await admin
.from("ai_agent_versions")
.select("id,provisioning_origin")
.eq("organization_id", auth.org.orgId)
.eq("agent_id", id)
.limit(2);
if (error)
return fail("internal_error", "Não foi possível conferir as versões.", 500, { requestId });
if (
versions?.length &&
!(versions.length === 1 && versions[0]?.provisioning_origin === "legacy_reconciliation")
)
return fail(
"existing_version_requires_review",
"Já existe uma versão. Revise e publique essa versão no editor.",
409,
{ requestId },
);
const p = parsed.data,
result = await publishFirstVersion(
admin,
auth.org.orgId,
agent,
agent.system_prompt ?? "",
auth.user.id,
{
channelId: p.channel_id,
provider: p.provider,
model: p.model,
credentialId: p.credential_id,
},
);
const cause=legacyRecoveryCause(result);
await recordLegacyNotice(admin,auth.org.orgId,id,cause);
void audit({
action: "ai_agent.reconciled",
actorUserId: auth.user.id,
organizationId: auth.org.orgId,
resourceType: "ai_agent",
resourceId: id,
requestId,
metadata: { published: result.published },
});
if (!result.published)
return fail(
cause,
legacyRecoveryMessage(cause),
422,
{ requestId },
);
return ok(result, { requestId });
}
+14 -2
View File
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* GET /api/v1/ai/agents/:id — fetch um agent (manager+)
* PATCH /api/v1/ai/agents/:id — atualiza campos (admin)
@@ -23,7 +24,7 @@ import {
export const dynamic = "force-dynamic";
const AGENT_COLUMNS =
"id, organization_id, name, description, model, system_prompt, is_active, is_default, kind, priority, published_version_id, archived_at, config, guardrails, active_kb_version_id, created_at, updated_at";
"id, organization_id, name, description, model, system_prompt, is_active, is_default, kind, priority, published_version_id, paused_at, operation_mode, operation_revision, archived_at, config, guardrails, active_kb_version_id, created_at, updated_at";
type RouteCtx = { params: Promise<{ id: string }> };
@@ -69,6 +70,9 @@ export async function GET(_req: NextRequest, ctx: RouteCtx): Promise<Response> {
// ---------------------------------------------------------------------------
export async function PATCH(req: NextRequest, ctx: RouteCtx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
@@ -154,7 +158,10 @@ export async function PATCH(req: NextRequest, ctx: RouteCtx): Promise<Response>
`Mudança de conteúdo (${barrados.join(", ")}) = versão draft nova; publica. ` +
`Edite pela aba Modelo do editor de versões.`,
409,
{ requestId, details: { campos: barrados, published_version_id: existing.published_version_id } },
{
requestId,
details: { campos: barrados, published_version_id: existing.published_version_id },
},
);
}
}
@@ -162,6 +169,8 @@ export async function PATCH(req: NextRequest, ctx: RouteCtx): Promise<Response>
// Build UPDATE payload. Para `config`, faz merge preservando defaults.
const update: Record<string, unknown> = {};
if (patch.operation_mode !== undefined) update.operation_mode = patch.operation_mode;
if (patch.paused_at !== undefined) update.paused_at = patch.paused_at;
if (patch.name !== undefined) update.name = patch.name;
if (patch.description !== undefined) update.description = patch.description;
if (patch.is_active !== undefined) update.is_active = patch.is_active;
@@ -205,6 +214,9 @@ export async function PATCH(req: NextRequest, ctx: RouteCtx): Promise<Response>
// ---------------------------------------------------------------------------
export async function DELETE(_req: NextRequest, ctx: RouteCtx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* GET /api/v1/ai/agents/:id/versions/:vid — fetch (manager+).
* PATCH /api/v1/ai/agents/:id/versions/:vid — edit (admin) — apenas se status='draft'.
@@ -18,7 +19,7 @@ import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
const VERSION_COLUMNS =
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids";
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -51,6 +52,9 @@ export async function GET(_req: NextRequest, ctx: Ctx): Promise<Response> {
}
export async function PATCH(req: NextRequest, ctx: Ctx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id, vid } = await ctx.params;
if (!UUID_RX.test(id) || !UUID_RX.test(vid)) {
@@ -1,11 +1,9 @@
/**
* Runtime real do "Testar agente" (issue #71).
*
* O default do `INTERNAL_AGENT_RUN_STUB` virou `false`, então o caminho que
* roda numa instalação nova é o runtime de verdade — e o modo de falha comum
* dessa instalação é não ter credencial de IA. Este teste fixa o contrato
* desse erro: a pessoa lê o porquê na tela, em vez de um 500 mudo que só
* aparece no log do servidor.
* O preview usa o mesmo core e as mesmas dependências de um turno normal. Este
* teste fixa o contrato de falha desse caminho: o run guarda um checkpoint
* útil e a pessoa recebe orientação legível, sem detalhes internos do provider.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
@@ -13,22 +11,27 @@ import { NextRequest } from "next/server";
import { requireRole } from "@/lib/auth/require-role";
import { createAdminClient } from "@/lib/supabase/admin";
import { ROLE_RANK, type AuthUser, type Role } from "@/lib/auth/types";
import { testAgentVersion } from "@/lib/agent-engine/agent/sandbox";
import { requestTurnDeps } from "@/lib/agent-engine/agent/request-deps";
import { getRequestPool } from "@/lib/agent-engine/db/request-pool";
vi.mock("@/lib/auth/require-role", () => ({ requireRole: vi.fn() }));
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
vi.mock("@/lib/audit", () => ({ audit: vi.fn(async () => undefined) }));
vi.mock("@/lib/ai/runtime/agent", () => ({
runAgent: vi.fn(async () => {
vi.mock("@/lib/agent-engine/agent/sandbox", () => ({
testAgentVersion: vi.fn(async () => {
throw new Error("AI_GATEWAY_API_KEY ausente");
}),
}));
vi.mock("@/lib/agent-engine/agent/request-deps", () => ({ requestTurnDeps: vi.fn() }));
vi.mock("@/lib/agent-engine/db/request-pool", () => ({ getRequestPool: vi.fn() }));
const ORG = "22222222-2222-4222-8222-222222222222";
const USER = "11111111-1111-4111-8111-111111111111";
const AGENT = "33333333-3333-4333-8333-333333333333";
const VERSION = "44444444-4444-4444-8444-444444444444";
function stubAdmin() {
function stubAdmin(atualizacoes: Record<string, unknown>[]) {
return {
from: (table: string) => {
if (table === "ai_agent_versions") {
@@ -64,14 +67,27 @@ function stubAdmin() {
insert: () => ({
select: () => ({ single: async () => ({ data: { id: "run-1" }, error: null }) }),
}),
update: () => ({ eq: async () => ({ error: null }) }),
update: (payload: Record<string, unknown>) => {
atualizacoes.push(payload);
const chain = {
eq: () => chain,
then: (ok: (value: { error: null }) => unknown) =>
Promise.resolve({ error: null }).then(ok),
};
return chain;
},
};
},
};
}
describe("POST .../versions/:vid/test — runtime real", () => {
describe("POST .../versions/:vid/test — core compartilhado", () => {
const atualizacoes: Record<string, unknown>[] = [];
const requestPool = { query: vi.fn() };
const turnDeps = {};
beforeEach(() => {
atualizacoes.length = 0;
const user: AuthUser = {
id: USER,
email: "a@example.com",
@@ -86,10 +102,12 @@ describe("POST .../versions/:vid/test — runtime real", () => {
? { ok: true, user, org: { orgId: ORG, name: "Org", role: "admin" } }
: ({ ok: false, response: null } as never),
);
vi.mocked(createAdminClient).mockReturnValue(stubAdmin() as never);
vi.mocked(createAdminClient).mockReturnValue(stubAdmin(atualizacoes) as never);
vi.mocked(getRequestPool).mockReturnValue(requestPool as never);
vi.mocked(requestTurnDeps).mockReturnValue(turnDeps as never);
});
it("falha do runtime vira mensagem legível, não 500 mudo", async () => {
it("falha do core vira checkpoint e orientação legível", async () => {
const { POST } = await import("./route");
const req = new NextRequest("http://localhost/x", {
method: "POST",
@@ -98,10 +116,35 @@ describe("POST .../versions/:vid/test — runtime real", () => {
});
const res = await POST(req, { params: Promise.resolve({ id: AGENT, vid: VERSION }) });
const body = (await res.json()) as { error?: { message?: string } };
const body = (await res.json()) as { error?: { code?: string; message?: string } };
expect(res.status).toBe(500);
expect(body.error?.message).toContain("Não consegui executar o agente");
expect(body.error?.message).toContain("AI_GATEWAY_API_KEY ausente");
expect(testAgentVersion).toHaveBeenCalledWith(
requestPool,
turnDeps,
expect.objectContaining({
organizationId: ORG,
agentId: AGENT,
versionId: VERSION,
runId: "run-1",
sampleMessage: "oi",
}),
);
expect(res.status).toBe(422);
expect(body.error).toMatchObject({
code: "preview_failed",
message: "Não foi possível executar o teste. Confira modelo, credencial e materiais do agente.",
});
expect(body.error?.message).not.toContain("AI_GATEWAY_API_KEY");
expect(atualizacoes).toContainEqual(expect.objectContaining({
status: "error",
error_code: "preview_failed",
}));
});
});
// Este teste isola o handler; autoridade de suporte é exercitada na suíte própria.
vi.mock("@/lib/impersonate/support", async (importOriginal) => ({
...await importOriginal<typeof import("@/lib/impersonate/support")>(),
requireSupportWrite: vi.fn(async () => null),
authenticatedSessionId: vi.fn(async () => "f2200000-0000-4000-8000-000000000099"),
}));
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/agents/:id/versions/:vid/test (admin)
*
@@ -20,11 +21,13 @@ import { type NextRequest } from "next/server";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { env } from "@/lib/env";
import { requireRole } from "@/lib/auth/require-role";
import { createAdminClient } from "@/lib/supabase/admin";
import { testRunSchema } from "@/lib/ai/agents/validation";
import { avaliarRespostaDeTeste } from "@/lib/ai/agents/avaliar-resposta-de-teste";
import { testAgentVersion } from "@/lib/agent-engine/agent/sandbox";
import { requestTurnDeps } from "@/lib/agent-engine/agent/request-deps";
import { getRequestPool } from "@/lib/agent-engine/db/request-pool";
import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
@@ -34,6 +37,9 @@ const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
type Ctx = { params: Promise<{ id: string; vid: string }> };
export async function POST(req: NextRequest, ctx: Ctx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id, vid } = await ctx.params;
if (!UUID_RX.test(id) || !UUID_RX.test(vid)) {
@@ -99,37 +105,52 @@ export async function POST(req: NextRequest, ctx: Ctx): Promise<Response> {
let resultPayload: Record<string, unknown>;
if (env.INTERNAL_AGENT_RUN_STUB) {
resultPayload = await runStubbedTest({
runId: runRow.id,
orgId: activeOrg.orgId,
try {
const result = await testAgentVersion(getRequestPool(), requestTurnDeps(), {
organizationId: activeOrg.orgId,
agentId: id,
versionId: vid,
runId: runRow.id,
sampleMessage: parsed.data.sample_message,
sampleContact: parsed.data.sample_contact,
version,
startedAt,
channelId: version.channel_session_id,
});
} else {
// Runtime real (S-13.08). Falha aqui é o caso comum de instalação nova —
// credencial de IA ausente. Um 500 cru mandaria a pessoa pro log do
// servidor; devolvemos o porquê legível na própria tela.
try {
resultPayload = await callInternalRuntime({
runId: runRow.id,
orgId: activeOrg.orgId,
versionId: vid,
sampleMessage: parsed.data.sample_message,
sampleContact: parsed.data.sample_contact,
});
} catch (err) {
const detalhe = err instanceof Error ? err.message : String(err);
return fail(
"internal_error",
`Não consegui executar o agente: ${detalhe}. Confira as credenciais de IA da organização.`,
500,
{ requestId },
);
}
const finalText = result.candidates.map((c) => c.body).join("\n\n");
resultPayload = {
run_id: runRow.id,
status: result.candidates.length ? "ok" : "blocked",
latency_ms: Date.now() - startedAt.getTime(),
final_text: finalText,
tool_calls: result.proposals,
...result,
stub: process.env.INTERNAL_AGENT_RUN_STUB === "true",
guardrails: avaliarRespostaDeTeste(finalText),
};
await admin
.from("ai_agent_runs")
.update({
status: "ok",
completed_at: new Date().toISOString(),
tool_calls: JSON.parse(JSON.stringify(result.proposals)),
})
.eq("organization_id", activeOrg.orgId)
.eq("id", runRow.id);
} catch {
await admin
.from("ai_agent_runs")
.update({
status: "error",
completed_at: new Date().toISOString(),
error_code: "preview_failed",
})
.eq("organization_id", activeOrg.orgId)
.eq("id", runRow.id);
return fail(
"preview_failed",
t("Não foi possível executar o teste. Confira modelo, credencial e materiais do agente."),
422,
{ requestId },
);
}
void audit({
@@ -144,105 +165,3 @@ export async function POST(req: NextRequest, ctx: Ctx): Promise<Response> {
return ok(resultPayload, { requestId });
}
interface StubArgs {
runId: string;
orgId: string;
versionId: string;
sampleMessage: string;
sampleContact?: { name?: string; phone?: string };
version: {
system_prompt: string;
provider: string;
model: string;
channel_session_id: string;
tool_ids: unknown;
};
startedAt: Date;
}
async function runStubbedTest(args: StubArgs): Promise<Record<string, unknown>> {
const finishedAt = new Date();
const latencyMs = finishedAt.getTime() - args.startedAt.getTime();
// Trace fake plausível pra UI testar render. Nada disso é executado.
const toolCalls = [
{
step: 1,
tool_name: "(stub)",
args: { sample_message: args.sampleMessage },
result: { ok: true, note: "INTERNAL_AGENT_RUN_STUB=true — runtime real chega na S-13.08." },
started_at: args.startedAt.toISOString(),
ended_at: finishedAt.toISOString(),
},
];
const finalText = `[STUB] Resposta simulada para "${args.sampleMessage.slice(0, 80)}".`;
const admin = createAdminClient();
await admin
.from("ai_agent_runs")
.update({
status: "completed",
tokens_in: 0,
tokens_out: 0,
cost_cents: 0,
latency_ms: latencyMs,
steps_count: 1,
tool_calls: toolCalls,
completed_at: finishedAt.toISOString(),
})
.eq("id", args.runId)
.eq("organization_id", args.orgId);
return {
run_id: args.runId,
status: "completed",
final_text: finalText,
// O stub também passa pela avaliação: um caminho que não a tivesse voltaria
// a ser o "verde que não olhou para nada" — só que mais difícil de notar,
// porque conviveria com um caminho que olha.
guardrails: avaliarRespostaDeTeste(finalText),
tool_calls: toolCalls,
tokens_in: 0,
tokens_out: 0,
cost_cents: 0,
latency_ms: latencyMs,
would_send_to: {
session: args.version.channel_session_id,
chat_id: args.sampleContact?.phone ?? null,
},
stub: true,
};
}
async function callInternalRuntime(args: {
runId: string;
orgId: string;
versionId: string;
sampleMessage: string;
sampleContact?: { name?: string; phone?: string };
}): Promise<Record<string, unknown>> {
// S-13.08 wires the real runtime. We invoke `runAgent` in-process to avoid
// a fetch loopback (no cold-start, no INTERNAL_SECRET required in dev).
// The run row is already in is_dry_run=true mode so the runtime bypasses
// WAHA dispatch + outbound message insert.
const { runAgent } = await import("@/lib/ai/runtime/agent");
const result = await runAgent({
runId: args.runId,
override: {
sampleMessage: args.sampleMessage,
sampleContact: args.sampleContact,
},
});
// O runtime desta rota é o `@deprecated`, e ele NÃO importa `runBeforeSend` —
// a cadeia de guardrails vive no processo do worker e está ausente do build do
// app. Sem a linha abaixo, o botão "Testar" mostra uma resposta que nenhum
// gate examinou, e o self-hoster publica achando que viu o comportamento real.
//
// A avaliação cobre o que é decidível só com o texto e DECLARA o resto (ver
// lib/ai/agents/avaliar-resposta-de-teste.ts): fabricar o estado do turno para
// rodar a cadeia toda daria um veredito inventado, que é pior do que um
// "não avaliado" visível.
return { ...result, stub: false, guardrails: avaliarRespostaDeTeste(result.final_text) };
}
+5 -1
View File
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* GET /api/v1/ai/agents/:id/versions — list (manager+).
* POST /api/v1/ai/agents/:id/versions — create new draft (admin) = "Save".
@@ -22,7 +23,7 @@ import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
const VERSION_COLUMNS =
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids";
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -65,6 +66,9 @@ export async function GET(_req: NextRequest, ctx: Ctx): Promise<Response> {
}
export async function POST(req: NextRequest, ctx: Ctx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
if (!UUID_RX.test(id)) return fail("invalid_request", "id inválido.", 400, { requestId });
+12 -9
View File
@@ -60,7 +60,7 @@ export async function GET(_req: NextRequest): Promise<Response> {
const supabase = await createClient();
const { data: agents, error } = await supabase
.from("ai_agents")
.select("id, name, published_version_id, kind, is_active, archived_at")
.select("id, name, published_version_id, kind, is_active, paused_at, archived_at")
.eq("organization_id", orgId)
.is("archived_at", null)
.order("priority", { ascending: false })
@@ -68,14 +68,17 @@ export async function GET(_req: NextRequest): Promise<Response> {
if (error) return fail("internal_error", error.message, 500, { requestId });
const rows = ((agents ?? []) as Array<{
id: string;
name: string;
published_version_id: string | null;
kind: string | null;
is_active: boolean | null;
archived_at: string | null;
}>).filter(agenteAtende);
const rows = (
(agents ?? []) as Array<{
id: string;
name: string;
published_version_id: string | null;
kind: string | null;
is_active: boolean | null;
paused_at: string | null;
archived_at: string | null;
}>
).filter(agenteAtende);
const publishedIds = rows.map((a) => a.published_version_id).filter((v): v is string => !!v);
const versionById = new Map<string, number>();
+18 -15
View File
@@ -1,6 +1,7 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* GET /api/v1/ai/agents — list agents da org ativa (manager+).
* Inclui kind, priority, published_version_id, archived_at,
* Inclui kind, priority, published_version_id, paused_at, operation_mode, operation_revision, archived_at,
* e o provider/model da VERSÃO PUBLICADA (ver abaixo).
* Filtro `?include_archived=true` opcional.
* POST /api/v1/ai/agents — create agent (admin).
@@ -27,7 +28,7 @@ import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
const AGENT_COLUMNS =
"id, organization_id, name, description, model, system_prompt, is_active, is_default, kind, priority, published_version_id, archived_at, config, guardrails, active_kb_version_id, created_at, updated_at";
"id, organization_id, name, description, model, system_prompt, is_active, is_default, kind, priority, published_version_id, paused_at, operation_mode, operation_revision, archived_at, config, guardrails, active_kb_version_id, created_at, updated_at";
/**
* As mesmas colunas MAIS o join da versão publicada — só para a LISTAGEM.
@@ -46,7 +47,7 @@ const AGENT_COLUMNS_COM_VERSAO =
", versao_publicada:ai_agent_versions!ai_agents_published_version_id_fkey(provider, model)";
const VERSION_COLUMNS =
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids";
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
// ---------------------------------------------------------------------------
// GET — list
@@ -82,6 +83,9 @@ export async function GET(req: NextRequest): Promise<Response> {
// ---------------------------------------------------------------------------
export async function POST(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const authz = await requireRole("admin", { requestId, resource: "ai_agents" });
@@ -136,18 +140,17 @@ export async function POST(req: NextRequest): Promise<Response> {
return fail("internal_error", "Erro ao criar agent.", 500, { requestId });
}
// O escopo aponta para coisas que EXISTEM nesta organização. Sem esta
// conferência, um id de outra organização (ou de um material apagado) entra no
// array, a versão é publicada, e o assistente não acha nada — sem erro, com a
// tela mostrando a marcação como se estivesse valendo.
const escopo = await validarEscopoDaVersao(admin, activeOrg.orgId, {
pipeline_ids: v.pipeline_ids,
knowledge_source_ids: v.knowledge_source_ids,
});
if (!escopo.ok) {
return fail("validation_failed", mensagemDoEscopo(escopo), 422, { requestId });
}
// O escopo aponta para coisas que EXISTEM nesta organização. Sem esta
// conferência, um id de outra organização (ou de um material apagado) entra no
// array, a versão é publicada, e o assistente não acha nada — sem erro, com a
// tela mostrando a marcação como se estivesse valendo.
const escopo = await validarEscopoDaVersao(admin, activeOrg.orgId, {
pipeline_ids: v.pipeline_ids,
knowledge_source_ids: v.knowledge_source_ids,
});
if (!escopo.ok) {
return fail("validation_failed", mensagemDoEscopo(escopo), 422, { requestId });
}
const { data: versionRow, error: versionErr } = await admin
.from("ai_agent_versions")
.insert({
+1 -1
View File
@@ -47,7 +47,7 @@ export async function GET(_req: NextRequest): Promise<Response> {
// SQL — que é como ela se desencontrou da primeira vez.
const { data, error } = await supabase
.from("ai_agents")
.select("kind, is_active, published_version_id, archived_at")
.select("kind, is_active, paused_at, published_version_id, archived_at")
.eq("organization_id", authz.org.orgId)
.is("archived_at", null);
+4
View File
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* GET /api/v1/ai/budget — o estado do orçamento da org ativa (manager+).
* PATCH /api/v1/ai/budget — muda teto, limiar e MODO (admin).
@@ -96,6 +97,9 @@ export async function GET(_req: NextRequest): Promise<Response> {
}
export async function PATCH(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const authz = await requireRole("admin", { requestId, resource: "ai_budget" });
if (!authz.ok) return authz.response;
+24 -13
View File
@@ -1,3 +1,7 @@
import { withServiceBoundary } from "@/lib/atendimento/fronteira-server";
import { parseServiceBoundary, StaleServiceBoundaryError } from "@/lib/atendimento/fronteira";
import { registrarRespostaDeCasoObsoleto } from "@/lib/atendimento/aviso-caso-obsoleto";
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/cases/:id/reply — a ação do humano sobre um caso aberto
* (spec 15 §7/§9, Wave 5). Três ações: `resolved` (fecha e repassa ao lead),
@@ -62,6 +66,7 @@ const NEW_STATUS: Record<z.infer<typeof bodySchema>["action"], string> = {
};
interface CaseRow {
context_snapshot: Record<string, unknown> | null;
status: string;
title: string;
summary: string;
@@ -71,6 +76,9 @@ interface CaseRow {
}
export async function POST(req: NextRequest, { params }: RouteParams): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const authz = await requireRole("agent", { requestId, resource: "agent_cases" });
if (!authz.ok) return authz.response;
@@ -101,7 +109,7 @@ export async function POST(req: NextRequest, { params }: RouteParams): Promise<R
}
const { rows } = await pool.query<CaseRow>(
`select ac.status, ac.title, ac.summary, ac.blocker, ac.conversation_id, conv.contact_id
`select ac.status, ac.title, ac.summary, ac.blocker, ac.conversation_id, ac.context_snapshot, conv.contact_id
from agent_cases ac
join conversations conv
on conv.id = ac.conversation_id and conv.organization_id = ac.organization_id
@@ -128,23 +136,16 @@ export async function POST(req: NextRequest, { params }: RouteParams): Promise<R
const { conversation_id: conversationId, contact_id: contactId } = caseRow;
if (action === "escalate") {
// AVISA O LEAD ANTES DE SILENCIAR.
//
// Até aqui o automático estava CONVERSANDO com o cliente — abrir um caso não
// silencia ninguém (`CASES_SYSTEM_BLOCK`: "você CONTINUA conversando"). O
// `performHumanHandoff` da linha seguinte é que corta, e corta de vez. Sem
// esta mensagem, do lado de fora, o atendimento simplesmente para no meio.
//
// Emissor do lado do CRM (`avisarLeadDoCrm`), e não o do motor: aqui não há
// job da fila, e `runBeforeSend` grava o ledger por `(job_id, seq)`. Forjar
// um job para mandar uma frase seria pior que perder os gates de pacing —
// que, neste caminho, protegem contra um risco que não existe: é UMA
// mensagem, disparada por um clique humano, dentro de uma conversa aberta.
const boundary = parseServiceBoundary(caseRow.context_snapshot?.service_boundary);
try {
if (boundary && (boundary.organization_id !== org.orgId || boundary.contact_id !== contactId || boundary.conversation_id !== conversationId)) throw new StaleServiceBoundaryError();
await withServiceBoundary(pool, boundary, async () => {
const aviso = await avisarLeadDoCrm(createAdminClient(), {
organizationId: org.orgId,
conversationId,
contactId,
reason: body,
serviceBoundary: boundary!,
});
// O handoff roda ANTES de fechar o caso, e nesta ordem de propósito: ele é
@@ -162,6 +163,16 @@ export async function POST(req: NextRequest, { params }: RouteParams): Promise<R
log: createLogger(),
},
);
});
} catch (error) {
if (!(error instanceof StaleServiceBoundaryError)) throw error;
// A resposta humana fica registrada, mas não altera o atendimento novo.
const registered = await registrarRespostaDeCasoObsoleto(pool, org.orgId, caseId, user.id, body);
if (!registered) return fail("invalid_state", "Este caso já foi respondido por outra pessoa.", 409, { requestId });
await audit({ action: "ai.case_replied", actorUserId: user.id, organizationId: org.orgId,
resourceType: "agent_case", resourceId: caseId, requestId, metadata: { case_action: action, service_stale: true } });
return ok({ status: "resolved", delivery: "service_stale" }, { requestId });
}
const escalated = await escalateCase(pool, org.orgId, caseId, user.id, body);
if (!escalated) {
// Corrida perdida entre a leitura e o update. O handoff já aconteceu (e é
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/credentials/:id/revalidate (admin)
*
@@ -25,6 +26,9 @@ export async function POST(
_req: NextRequest,
ctx: { params: Promise<{ id: string }> },
): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
+4
View File
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* DELETE /api/v1/ai/credentials/:id (admin)
*
@@ -22,6 +23,9 @@ export async function DELETE(
_req: NextRequest,
ctx: { params: Promise<{ id: string }> },
): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
+4
View File
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* GET /api/v1/ai/credentials — lista credentials da org ativa (manager+).
* Lê da view `ai_provider_credentials_safe`,
@@ -57,6 +58,9 @@ export async function GET(): Promise<Response> {
}
export async function POST(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const authz = await requireRole("admin", { requestId, resource: "ai_credentials" });
if (!authz.ok) return authz.response;
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/followup-flows/:id/disable — status='disabled' (manager+).
* No-op ok (200, sem novo audit) se já estava disabled.
@@ -18,6 +19,9 @@ const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
type RouteCtx = { params: Promise<{ id: string }> };
export async function POST(_req: NextRequest, ctx: RouteCtx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
if (!UUID_RX.test(id)) {
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/followup-flows/:id/publish — valida o draft_graph
* (validateFlowForPublish, Task 2.2) e, se válido, publica atomicamente via
@@ -29,6 +30,9 @@ const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
type RouteCtx = { params: Promise<{ id: string }> };
export async function POST(_req: NextRequest, ctx: RouteCtx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
if (!UUID_RX.test(id)) {
@@ -61,8 +65,9 @@ export async function POST(_req: NextRequest, ctx: RouteCtx): Promise<Response>
//
// Kind entra neste conjunto só DEPOIS de ter motor de enrollment vivo:
// `manual`/`webhook` (POST enroll + ação de regra), `silence` (silence-sweep),
// `stage_change` (gatilho-etapa) e `case_opened` (gatilho-caso).
const KINDS_COM_MOTOR = new Set(["manual", "webhook", "silence", "stage_change", "case_opened"]);
// `stage_change` (gatilho-etapa), `case_opened` (gatilho-caso) e
// `appointment_no_show` (followup-gatilho-presenca.v1, confirmação humana).
const KINDS_COM_MOTOR = new Set(["manual", "webhook", "silence", "stage_change", "case_opened", "appointment_no_show"]);
const trigger = (pointer.trigger_config ?? { kind: "manual" }) as {
kind?: string;
params?: { stage_id?: string };
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/followup-flows/:id/rollback body: { version_id }
* Aponta o pointer pra uma version já existente (manager+), sem criar uma
@@ -28,6 +29,9 @@ const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
type RouteCtx = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, ctx: RouteCtx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
if (!UUID_RX.test(id)) {
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* GET /api/v1/ai/followup-flows/:id — pointer completo (draft_graph,
* trigger_config, handoff_policy) — any org member.
@@ -83,6 +84,9 @@ export async function GET(_req: NextRequest, ctx: RouteCtx): Promise<Response> {
}
export async function PATCH(req: NextRequest, ctx: RouteCtx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
if (!UUID_RX.test(id)) {
@@ -166,6 +170,9 @@ export async function PATCH(req: NextRequest, ctx: RouteCtx): Promise<Response>
}
export async function DELETE(_req: NextRequest, ctx: RouteCtx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
if (!UUID_RX.test(id)) {
+4
View File
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* GET /api/v1/ai/followup-flows — lista pointers da org ativa (any member).
* POST /api/v1/ai/followup-flows — cria draft (manager+). Nasce status='draft',
@@ -34,6 +35,9 @@ export async function GET(_req?: NextRequest): Promise<Response> {
}
export async function POST(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const authz = await requireRole("manager", { requestId, resource: "followup_flows" });
if (!authz.ok) return authz.response;
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/followups/enrollments/:id/cancel (manager+) — encerra um
* enrollment VIVO (active|waiting_reply|paused_handoff) manualmente pela
@@ -29,6 +30,9 @@ const LIVE_STATUSES = ["active", "waiting_reply", "paused_handoff", "paused_manu
type RouteCtx = { params: Promise<{ id: string }> };
export async function POST(_req: NextRequest, ctx: RouteCtx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
if (!UUID_RX.test(id)) {
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/followups/enrollments/:id/pause (manager+) — segura o
* follow-up sem matá-lo. O enrollment sai do relógio (`paused_manual`, sem
@@ -25,6 +26,9 @@ export const dynamic = "force-dynamic";
type RouteCtx = { params: Promise<{ id: string }> };
export async function POST(_req: NextRequest, ctx: RouteCtx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
const invalido = validaIdDaRota(id, requestId);
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/followups/enrollments/:id/resume (manager+) — devolve ao
* relógio o follow-up que uma pessoa pausou.
@@ -26,6 +27,9 @@ export const dynamic = "force-dynamic";
type RouteCtx = { params: Promise<{ id: string }> };
export async function POST(_req: NextRequest, ctx: RouteCtx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
const invalido = validaIdDaRota(id, requestId);
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/followups/enrollments/:id/skip (manager+) — pula o passo em
* que o follow-up está e o coloca no passo seguinte, para ser avaliado no
@@ -30,6 +31,9 @@ const bodySchema = z.object({ edge_id: z.string().min(1).max(200).optional() });
type RouteCtx = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, ctx: RouteCtx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
const invalido = validaIdDaRota(id, requestId);
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/followups/enrollments/:id/snooze (manager+) — remarca o
* próximo passo para o horário que a pessoa escolheu, sem tirar o follow-up do
@@ -28,6 +29,9 @@ const bodySchema = z.object({ next_eval_at: z.string().min(1) });
type RouteCtx = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, ctx: RouteCtx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
const invalido = validaIdDaRota(id, requestId);
+6 -2
View File
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* GET /api/v1/ai/followups/enrollments — lista enrollments da org ativa
* (any member), filtro opcional `?status=`.
@@ -9,6 +10,7 @@ import type { NextRequest } from "next/server";
import { ok, fail } from "@/lib/api/wrappers";
import { requireRole } from "@/lib/auth/require-role";
import { createAdminClient } from "@/lib/supabase/admin";
import { createClient } from "@/lib/supabase/server";
import { createFollowupEnrollmentSchema } from "@/lib/followup/api-schemas";
import { ENROLLMENT_LIST_COLUMNS, enrollFollowupFlow } from "@/lib/followup/enroll";
@@ -52,6 +54,9 @@ export async function GET(req: NextRequest): Promise<Response> {
}
export async function POST(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const authz = await requireRole("manager", { requestId, resource: "followup_enrollments" });
if (!authz.ok) return authz.response;
@@ -73,8 +78,7 @@ export async function POST(req: NextRequest): Promise<Response> {
});
}
const supabase = await createClient();
const result = await enrollFollowupFlow(supabase, {
const result = await enrollFollowupFlow(createAdminClient(), {
organizationId: activeOrg.orgId,
pointerId: parsed.data.pointer_id,
contactId: parsed.data.contact_id,
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/followups/promises/:id/cancel (manager+) — a pessoa desmarca
* um RETORNO agendado (a promessa avulsa, `cron_jobs`), não um enrollment do
@@ -32,6 +33,9 @@ const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
type RouteCtx = { params: Promise<{ id: string }> };
export async function POST(_req: NextRequest, ctx: RouteCtx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
if (!UUID_RX.test(id)) {
+4
View File
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* GET/PUT /api/v1/ai/guardrail-layers — as duas verificações que custam dinheiro.
*
@@ -89,6 +90,9 @@ const corpoDoPut = z.object({
});
export async function PUT(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const authz = await requireRole("admin", { resource: "ai_guardrail_layers" });
if (!authz.ok) return authz.response;
const t = (texto: string) => traduzir(texto, authz.user.idioma);
+4
View File
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* Épico Operação Visível (F1) — transição de status de um aviso do agente.
* PATCH { status: 'ack' | 'resolved' | 'open' } — org-scoped, auditado.
@@ -21,6 +22,9 @@ const bodySchema = z.object({ status: z.enum(["open", "ack", "resolved"]) }).str
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(req: NextRequest, ctx: Ctx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
if (!UUID_RX.test(id)) {
+4 -1
View File
@@ -11,6 +11,8 @@ import { z } from "zod";
import { ok, fail } from "@/lib/api/wrappers";
import { requireRole } from "@/lib/auth/require-role";
import { createAdminClient } from "@/lib/supabase/admin";
import { createClient } from "@/lib/supabase/server";
import { resolverDestinosDosAvisos } from "@/lib/ai/inbox-destino";
import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
@@ -59,5 +61,6 @@ export async function GET(req: NextRequest): Promise<Response> {
.eq("organization_id", org.orgId)
.eq("status", "open");
return ok({ items: data ?? [], open_count: openCount ?? 0 }, { requestId });
const items = await resolverDestinosDosAvisos(await createClient(), org.orgId, org.role, data ?? []);
return ok({ items, open_count: openCount ?? 0 }, { requestId });
}
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/knowledge/sources/:id/reindex
*
@@ -33,6 +34,9 @@ export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await params;
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* PATCH /api/v1/ai/knowledge/sources/[id] — update knowledge source
* DELETE /api/v1/ai/knowledge/sources/[id] — soft-delete (status='archived')
@@ -101,6 +102,9 @@ export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id: sourceId } = await params;
@@ -246,6 +250,9 @@ export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id: sourceId } = await params;
+4
View File
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* GET /api/v1/ai/knowledge/sources — os materiais da organização
* POST /api/v1/ai/knowledge/sources — cadastra um material
@@ -104,6 +105,9 @@ export async function GET(_req: NextRequest): Promise<Response> {
// ---------------------------------------------------------------------------
export async function POST(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const authz = await requireRole("manager", { requestId, resource: "ai_knowledge" });
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/ai/knowledge/sources/upload
*
@@ -47,6 +48,9 @@ const nameSchema = z.string().trim().min(2).max(120);
const agentIdSchema = z.string().uuid();
export async function POST(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const authz = await requireRole("manager", { requestId, resource: "ai_knowledge" });
@@ -128,3 +128,10 @@ describe("PATCH /api/v1/ai/memory/entries/[id]", () => {
expect(body.error.code).toBe("validation_failed");
});
});
// Este teste isola o handler; autoridade de suporte é exercitada na suíte própria.
vi.mock("@/lib/impersonate/support", async (importOriginal) => ({
...await importOriginal<typeof import("@/lib/impersonate/support")>(),
requireSupportWrite: vi.fn(async () => null),
authenticatedSessionId: vi.fn(async () => "f2200000-0000-4000-8000-000000000099"),
}));
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* Épico Operação Visível (F1) — PATCH: arquiva/reativa uma entrada de memória
* da org (migration 0067). Filtro `organization_id` sempre (admin client
@@ -21,6 +22,9 @@ const patchSchema = z.object({ status: z.enum(["archived", "active"]) });
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(req: NextRequest, ctx: Ctx): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const { id } = await ctx.params;
if (!UUID_RX.test(id)) {
@@ -109,3 +109,10 @@ describe("POST /api/v1/ai/memory/entries", () => {
expect(body.error.code).toBe("validation_failed");
});
});
// Este teste isola o handler; autoridade de suporte é exercitada na suíte própria.
vi.mock("@/lib/impersonate/support", async (importOriginal) => ({
...await importOriginal<typeof import("@/lib/impersonate/support")>(),
requireSupportWrite: vi.fn(async () => null),
authenticatedSessionId: vi.fn(async () => "f2200000-0000-4000-8000-000000000099"),
}));
+4
View File
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* Épico Operação Visível (F1) — POST: cria entrada manual de memória da org
* (migration 0067). source='manual', status='active' direto (sem gate de
@@ -21,6 +22,9 @@ const postSchema = z.object({
});
export async function POST(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const authz = await requireRole("manager", { requestId, resource: "org_memory" });
if (!authz.ok) return authz.response;
+7
View File
@@ -234,3 +234,10 @@ describe("POST /api/v1/ai/memory", () => {
expect(body.error.code).toBe("validation_failed");
});
});
// Este teste isola o handler; autoridade de suporte é exercitada na suíte própria.
vi.mock("@/lib/impersonate/support", async (importOriginal) => ({
...await importOriginal<typeof import("@/lib/impersonate/support")>(),
requireSupportWrite: vi.fn(async () => null),
authenticatedSessionId: vi.fn(async () => "f2200000-0000-4000-8000-000000000099"),
}));
+4
View File
@@ -1,3 +1,4 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* Épico Operação Visível (F1) — memória geral da org (spec harness, migration
* 0067): documento versionado (versões imutáveis + ponteiro, mesmo padrão de
@@ -84,6 +85,9 @@ export async function GET(_req: NextRequest): Promise<Response> {
}
export async function POST(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const authz = await requireRole("admin", { requestId, resource: "org_memory" });
if (!authz.ok) return authz.response;

Some files were not shown because too many files have changed in this diff Show More