mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Opus 5
parent
305ee87922
commit
d69d708d8d
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"),
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
}));
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user