* 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>
22 KiB
Guia de Setup — DeskcommCRM
Pra quem é este doc? Você acabou de clonar o repo, copiou
.env.examplepra.env.local, abriu o arquivo e bateu o desespero: "o que é cada uma dessas chaves e onde eu pego?". Este guia resolve isso. Sem pular etapas, sem assumir que você já configurou nada antes.Tempo estimado: 60–90 minutos pra preencher tudo do zero. Você pode fazer em partes — o app sobe com algumas chaves vazias (veja Ordem recomendada).
Custo: R$ 0 pra rodar em dev. Todos os serviços listados têm free tier generoso. Marcamos com 💳 onde a plataforma pede cartão (mesmo no plano grátis) só pra validar identidade.
Índice
- Antes de começar
- Ordem recomendada
- Supabase — banco + auth + storage
- Upstash Redis — rate limit + idempotência
- WAHA — WhatsApp
- Anthropic + Vercel AI Gateway — IA
- OpenAI — embeddings do RAG
- Sentry — monitoramento de erros
- Resend — email transacional
- Nuvemshop — integração e-commerce
- Chaves geradas localmente
- Verificação final
- Troubleshooting
- Próximos passos
Antes de começar
O que você precisa ter instalado:
- Node.js 22 — recomendamos via nvm. No repo, rode
nvm usee ele puxa a versão certa. A suítepnpm test:dbexige Node 22+: os testes instanciam o cliente do Supabase, que precisa doWebSocketglobal (nativo só a partir do 22). - Docker Desktop — pra rodar o WAHA local. Download.
- pnpm —
npm install -g pnpm(gerenciador de pacotes que usamos). - Git — você já tem se clonou o repo.
- Conta de email principal — vai usar pra criar contas em vários SaaS.
- Cartão de crédito 💳 — alguns serviços pedem só pra "comprovar identidade" mesmo no plano grátis (Supabase, Sentry). Se ficar dentro do free tier, não cobram nada.
Como o .env.local funciona:
- Fica na raiz do projeto:
/seu-caminho/DeskcommCRM/.env.local. - Cada linha é
NOME_DA_VARIAVEL=valor— sem espaço antes/depois do=. - Strings com caracteres especiais: envolva em aspas duplas (
"valor com espaço"). - Variáveis com
NEXT_PUBLIC_no nome são expostas no browser — nunca coloque secret aí. - O resto fica server-only.
Regra de ouro: nunca commite o .env.local. Já está no .gitignore, mas confira com git status antes de qualquer push.
Ordem recomendada
Se você quer rodar o app o mais rápido possível com o mínimo viável:
🟢 Mínimo pra pnpm dev subir sem erro fatal (~15 min):
- Supabase — sem isso nada funciona (auth + DB).
- Chaves geradas localmente —
INTERNAL_SECRET, encryption keys. - Upstash Redis — rate limit é gate de várias rotas.
🟡 Pra testar features de IA (+10 min): 4. Anthropic ou Vercel AI Gateway. 5. OpenAI — embeddings do RAG.
🟡 Pra testar WhatsApp (+15 min): 6. WAHA + ngrok (precisa URL pública).
⚪ Pode ficar vazio em dev (degradam graciosamente):
- Sentry — não monitora erros, mas app sobe.
- Resend — emails não saem (vão pro console.log), mas app sobe.
- Nuvemshop — UI mostra "Integração não configurada".
1. Supabase — banco + auth + storage
O que é: Backend-as-a-service. Aqui mora seu Postgres, autenticação, storage de mídia e realtime. Sem isso, nada funciona. Free tier: 500MB DB + 1GB storage + 50k MAU. Suficiente pra dev e protótipos.
Passo a passo
- Acesse https://supabase.com → Start your project → faça login com GitHub.
- No dashboard, clique New project.
- Name:
deskcomm-dev(ou o que quiser). - Database password: clique no ícone de dado pra gerar. Salve essa senha num gerenciador (1Password, Bitwarden) — você vai precisar pra rodar migrations e nunca verá ela de novo no dashboard.
- Region:
South America (São Paulo)— latência mínima pro Brasil. - Pricing plan: Free.
- Name:
- Clique Create new project. Aguarde ~2 minutos enquanto provisiona.
- Quando carregar, vá em Project Settings (engrenagem no menu lateral) → API.
Onde pegar as 3 chaves
Na tela Project Settings → API:
| Campo no Supabase | Variável no .env.local |
Detalhe |
|---|---|---|
Project URL (ex: https://abc123.supabase.co) |
NEXT_PUBLIC_SUPABASE_URL |
URL pública, pode ir pro browser |
Project API keys → anon public |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Chave pública. RLS protege os dados |
Project API keys → service_role secret |
SUPABASE_SERVICE_ROLE_KEY |
CRÍTICA. Bypassa RLS. Nunca exponha. Nunca commite. |
⚠️ Aviso de segurança: a
service_roleé o equivalente a senha de root do banco. Se vazar, qualquer pessoa lê/escreve tudo. Em prod, configure rotação trimestral.
Rodar as migrations
Depois de preencher as 3 variáveis acima:
# Instale o CLI do Supabase
brew install supabase/tap/supabase # macOS
# Ou siga https://supabase.com/docs/guides/cli/getting-started pra Windows/Linux
# Login (abre o browser)
supabase login
# Conecte ao seu projeto (project-ref está na URL do dashboard)
supabase link --project-ref <seu-project-ref>
# Num projeto Supabase NOVO, habilite antes as extensões que o schema usa —
# sem elas o baseline para em `type public.vector does not exist`.
psql "$SUPABASE_DB_URL" -v ON_ERROR_STOP=1 -c \
'create extension if not exists vector with schema public;
create extension if not exists citext with schema public;
create extension if not exists pg_trgm with schema public;'
# Aplica o SCHEMA — o baseline, não a cadeia de migrations.
# Re-aplicar é seguro e não erra: o arquivo é idempotente (issue #184).
psql "$SUPABASE_DB_URL" -v ON_ERROR_STOP=1 -f supabase/baseline.sql
⚠️ Não use
supabase db pushnum banco novo. As migrations0001–0009e0013são stubsSELECT 1;— o schema fundacional não está nelas, e a cadeia não sobe do zero. Odb pushpassa sem erro e te deixa com um banco vazio, e você só descobre muito depois, num erro que não aponta pra cá. Osupabase/baseline.sqlé o schema real e é exatamente o que ohostgator-setup-kit/install.shaplica na VPS.As migrations continuam sendo a fonte da verdade para quem já tem um banco e está atualizando — é o baseline que serve pra criar do zero.
Se não conseguir usar o CLI, abra SQL Editor → New query no dashboard e cole o conteúdo
de supabase/baseline.sql.
Storage bucket
No menu lateral → Storage → New bucket:
- Name:
whatsapp-media - Public bucket: NÃO (deixe desmarcado — usamos URLs assinadas)
2. Upstash Redis — rate limit + idempotência
O que é: Redis serverless. Usado pra rate limit de API e cache de idempotency keys. Free tier: 10k commands/dia, 256MB. Mais que suficiente pra dev.
Passo a passo
- Acesse https://upstash.com → Sign up com GitHub.
- No dashboard, clique Create Database.
- Name:
deskcomm-dev - Type: Regional (mais barato que Global pra dev)
- Region:
sa-east-1(São Paulo) — ouus-east-1se SP não estiver disponível no free tier. - Eviction: habilitado (default).
- Name:
- Clique Create.
- Na tela do banco criado, role até a seção REST API. Você vai ver:
| Campo no Upstash | Variável no .env.local |
|---|---|
| UPSTASH_REDIS_REST_URL (botão de copy) | UPSTASH_REDIS_REST_URL |
| UPSTASH_REDIS_REST_TOKEN (clique no olhinho pra revelar) | UPSTASH_REDIS_REST_TOKEN |
💡 O Upstash mostra os snippets prontos em vários formatos. Use a aba
.envque ele já formata certo — é só colar.
3. WAHA — WhatsApp
O que é: Servidor que se conecta ao WhatsApp e expõe API HTTP. O default fixo é devlikeapro/waha:latest-2026.7.2, engine NOWEB. A prova local desta versão criou duas sessões CORE simultâneas até SCAN_QR_CODE; não houve pairing nem envio real. Versão/engine e pós-condição da operação determinam a compatibilidade; o tier sozinho não bloqueia um segundo número.
Passo 1 — gerar a API key (plaintext + hash)
WAHA tem um esquema de auth particular: o container guarda o hash SHA512 da chave; a app envia o plaintext em cada request. Por isso você precisa dos dois.
# 1. Gere uma string aleatória forte (no terminal)
openssl rand -hex 32
# → cola algo tipo: 7a3f9b2c1d4e5f...
Esse é o plaintext. Copie. Agora gere o hash SHA512 hex dele:
# 2. Hash do plaintext
echo -n "7a3f9b2c1d4e5f..." | shasum -a 512 | awk '{print $1}'
# → cola algo tipo (longão, ~128 chars): 9f8e7d6c...
⚠️ Erro #1 de quem clona o projeto: confundir plaintext com hash. Memoriza:
- O container WAHA recebe o HASH → vai em
WAHA_API_KEY_SHA512.- O app Next.js envia o PLAINTEXT no header
X-Api-Key→ vai emWAHA_API_KEY.
Passo 2 — gerar o HMAC secret pro webhook
WAHA assina cada webhook com HMAC SHA512. Geramos um segundo secret pra isso:
openssl rand -hex 32
# → cola em WAHA_HMAC_SECRET
Passo 3 — preencher .env.local
# Plaintext que a app Next envia no header X-Api-Key
WAHA_API_KEY=<plaintext-do-passo-1>
# Hash SHA512 do plaintext acima — usado pelo docker-compose pra configurar o container
WAHA_API_KEY_SHA512=<hash-do-passo-1>
# HMAC do webhook
WAHA_HMAC_SECRET=<plaintext-do-passo-2>
# WAHA roda em localhost:3030 (mapeamento do docker-compose, host:3030 → container:3000)
WAHA_API_BASE_URL=http://localhost:3030
# URL pública que o WAHA chama de volta — preenchido no Passo 4
WAHA_WEBHOOK_BASE_URL=
Passo 4 — URL pública pra webhook (ngrok)
WAHA precisa chamar nossa app de volta quando chega mensagem. Localhost não serve — precisa de URL HTTPS pública.
# Instale ngrok
brew install ngrok
# Cadastre conta grátis em https://ngrok.com e pegue seu authtoken
ngrok config add-authtoken <seu-token>
# Em outro terminal, expõe a porta 3000 (onde o Next.js vai rodar)
ngrok http 3000
O ngrok mostra: Forwarding https://abc-123-456.ngrok-free.app -> http://localhost:3000.
Copie a URL https://... e cole em:
WAHA_WEBHOOK_BASE_URL=https://abc-123-456.ngrok-free.app
⚠️ A URL do ngrok muda toda vez que você reinicia (no plano free). Pague $8/mês pelo subdomínio fixo se for trabalhar muito com WAHA, ou use Cloudflare Tunnel (gratuito com domínio próprio).
Passo 5 — subir o WAHA
docker compose up -d
Confira em http://localhost:3030/dashboard/ que o WAHA está respondendo (painel do WAHA). Pra criar sessão e escanear QR, veja a doc oficial: https://waha.devlikeapro.com/docs/overview/quick-start/.
4. Anthropic + Vercel AI Gateway — IA
O que é: O cérebro da IA conversacional (Claude). Usamos o Vercel AI Gateway preferencialmente (fallback automático entre provedores, observability, zero data retention) e o Anthropic direto como fallback. Custo: pay-per-use. Anthropic dá $5 de crédito grátis ao cadastrar.
Opção A — Vercel AI Gateway (recomendado)
- Acesse https://vercel.com → faça login.
- No dashboard → AI (no menu lateral) → Get started with AI Gateway.
- Clique Create API Key → nome
deskcomm-dev→ copie a chave.
AI_GATEWAY_API_KEY=<chave-do-gateway>
AI_GATEWAY_BASE_URL=https://ai-gateway.vercel.sh/v1
VERCEL_AI_GATEWAY_URL=https://ai-gateway.vercel.sh/v1
💡 Com o Gateway, o código usa strings tipo
"anthropic/claude-sonnet-4-6"— o Gateway resolve qual provedor chamar. Se Anthropic estiver fora, ele tenta o backup automaticamente.
Opção B — Anthropic direto (fallback ou se preferir)
- Acesse https://console.anthropic.com → Sign Up. 💳
- Adicione método de pagamento (eles dão $5 de crédito grátis).
- Settings → API Keys → Create Key → nome
deskcomm-dev→ copie.
ANTHROPIC_API_KEY=sk-ant-api03-...
⚠️ Se as duas chaves estiverem vazias, o worker
ai-response-workerpula comskip="ai_gateway_key_missing"— o app sobe normal, só não responde com IA. Em dev tá ok. Em prod, configure pelo menos uma das duas.
5. OpenAI — embeddings do RAG
O que é: Usado só pra gerar embeddings (vetores) das bases de conhecimento dos tenants pro chatbot RAG. Não usamos GPT pra gerar texto — esse trabalho é do Claude. Custo: baratíssimo. text-embedding-3-small = $0.02 / 1M tokens.
- Acesse https://platform.openai.com → Sign up. 💳
- Adicione método de pagamento (eles não dão mais crédito grátis em conta nova).
- API Keys → Create new secret key → nome
deskcomm-dev-embeddings→ copie.
OPENAI_API_KEY=sk-proj-...
6. Sentry — monitoramento de erros
O que é: Captura erros, stack traces e performance. Sem isso, você só sabe que o app quebrou quando o cliente reclama. Free tier: 5k erros/mês, 10k performance units/mês.
- Acesse https://sentry.io → Sign up com GitHub. 💳
- Crie um workspace (ou use o pessoal) → Create Project.
- Platform:
Next.js - Alert frequency: "Alert me on every new issue"
- Project name:
deskcomm-dev
- Platform:
- Após criar, o Sentry mostra o DSN numa tela de quickstart. É uma URL tipo
https://abc123@o456.ingest.sentry.io/789. - Se você fechou a tela: Project Settings → Client Keys (DSN) → copie o "DSN" público.
SENTRY_DSN=https://abc123@o456.ingest.sentry.io/789
💡 O DSN é considerado "público o suficiente" — pode ir no client. Mas mantenha como server var por padrão (já está em
.env.local).
7. Resend — email transacional
O que é: Serviço de envio de email. Usado pra magic links, reset de senha, exports LGPD, notificações. Free tier: 3k emails/mês, 100/dia. Suficiente pra dev e MVP.
- Acesse https://resend.com → Sign up com GitHub.
- API Keys → Create API Key:
- Name:
deskcomm-dev - Permission:
Sending access(nãoFull access). - Domain:
All domains(em dev) — em prod, restrinja ao domínio verificado.
- Name:
- Copie a chave (começa com
re_...). Ela só aparece uma vez.
RESEND_API_KEY=re_...
RESEND_FROM_EMAIL=onboarding@resend.dev
💡 O domínio
onboarding@resend.devé compartilhado e funciona no plano free pra testes. Em prod, verifique seu próprio domínio no Resend (DNS records SPF + DKIM) e usenoreply@seudominio.com.ℹ️ Se você não configurar o Resend, o app sobe normal — só faz
console.logem vez de enviar emails de verdade. Bom pra dev sem precisar gastar quota.
8. Nuvemshop — integração e-commerce
O que é: Plataforma de e-commerce brasileira. Nossa integração OAuth importa pedidos, produtos, clientes pro CRM.
ℹ️ Pode pular em dev. Se essas vars ficarem vazias, a UI mostra "Integração não configurada" e você toca o resto do app normal.
Passo a passo
- Acesse https://partners.tiendanube.com/ → Sign up como parceiro (gratuito).
- No dashboard de parceiro → Apps → Create new app.
- App name:
DeskcommCRM Dev. - Redirect URI:
https://<sua-url-ngrok>.ngrok-free.app/api/v1/integrations/nuvemshop/callback(mesmo ngrok do WAHA, ou outro). - Scopes: marque tudo relacionado a
read_orders,read_customers,read_products,write_orders(pra atualizar status).
- App name:
- Após criar, a tela do app mostra:
| Campo no portal | Variável no .env.local |
|---|---|
App ID (na URL: partners.tiendanube.com/apps/12345) |
NUVEMSHOP_APP_ID (= 12345) |
| Client ID | NUVEMSHOP_CLIENT_ID |
| Client Secret (clique pra revelar) | NUVEMSHOP_CLIENT_SECRET |
NUVEMSHOP_APP_ID=12345
NUVEMSHOP_CLIENT_ID=...
NUVEMSHOP_CLIENT_SECRET=...
- Configure também a URL pública do app:
NEXT_PUBLIC_APP_URL=https://<sua-url-ngrok>.ngrok-free.app
A URL do callback OAuth precisa bater exatamente com a Redirect URI cadastrada no portal — incluindo https, sem barra final.
9. Chaves geradas localmente — encryption + secrets
Estas você gera você mesmo — não tem dashboard, não tem login. São strings aleatórias usadas pra criptografia interna e segredos da app.
# Rode 6x e cole cada saída numa variável diferente
openssl rand -hex 32
Distribua nas variáveis:
# Bearer secret pros endpoints /api/v1/cron/* (DIFERENTE do service role key)
INTERNAL_SECRET=<saída-1>
# Criptografia de PII (LGPD)
CPF_ENCRYPTION_KEY=<saída-2>
# Criptografia de tokens OAuth Nuvemshop
NUVEMSHOP_OAUTH_ENCRYPTION_KEY=<saída-3>
# Criptografia de credenciais BYO-WAHA (cliente que roda WAHA próprio)
WAHA_BYO_ENCRYPTION_KEY=<saída-4>
# HMAC do cookie de impersonate (super-admin) — mínimo 32 chars
IMPERSONATE_COOKIE_SECRET=<saída-5>
# Assinatura de URLs de export LGPD
LGPD_SIGNING_KEY=<saída-6>
⚠️ NUNCA reutilize a mesma string em produção. Cada uma criptografa uma coisa diferente — se vazar uma, queremos blast radius limitado.
⚠️ NUNCA mude
CPF_ENCRYPTION_KEYouNUVEMSHOP_OAUTH_ENCRYPTION_KEYdepois que tiver dados em prod — você não consegue mais descriptografar o que foi salvo. Rotação dessas chaves exige migration de re-encryption.
Outras vars opcionais
# DPO oficial (LGPD) — vai como reply-to em emails de data request
LGPD_DPO_EMAIL=dpo@seudominio.com
# Validade dos links de export LGPD (default 72h)
LGPD_EXPORT_EXPIRES_HOURS=72
# URLs canônicas (em dev geralmente localhost; em prod aponta pra domínio)
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000
# Workers — ritmo com que o worker roda os handlers do event_log. Vazio = os
# defaults (2s com trabalho, 10s ocioso, 50 por lote). Não há mais opt-in: o
# `EVENT_LOG_WORKER_ENABLED` que ficava aqui nunca teve leitor e saiu em
# 2026-08-25, junto com a chegada do laço de verdade no worker.
EVENT_LOG_DRAIN_INTERVAL_MS=2000
EVENT_LOG_DRAIN_IDLE_INTERVAL_MS=10000
EVENT_LOG_DRAIN_BATCH_SIZE=50
Verificação final
Depois de preencher tudo, valide:
# 1. Type-check (vai reclamar de env faltando)
pnpm typecheck
# 2. Sobe o app
pnpm dev
# 3. Em outro terminal, bate no health check
curl http://localhost:3000/api/v1/health
A resposta deve ser tipo:
{
"data": {
"supabase": "ok",
"redis": "ok",
"waha": "ok"
}
}
Se algum service vier "degraded" ou "down", abra o terminal do pnpm dev e veja o erro — geralmente é variável faltando ou typo no valor.
Troubleshooting
Variáveis de ambiente inválidas no boot
O Zod (lib/env.ts) valida no startup. Olha a lista de erros que ele imprime — fala exatamente qual var está faltando ou com formato errado.
Error: supabaseUrl is required
Você esqueceu de preencher NEXT_PUBLIC_SUPABASE_URL ou tem espaço/aspa errada. Confira se a linha é exatamente NEXT_PUBLIC_SUPABASE_URL=https://abc.supabase.co (sem aspas, sem espaço antes do =).
Invalid JWT ao chamar Supabase
A anon key ou service role key foi colada errada (cortou no meio). JWTs do Supabase são longos (~200 chars). Volte no dashboard e use o botão Copy em vez de selecionar manualmente.
WAHA retorna 401 Unauthorized
Provável: você botou o hash em WAHA_API_KEY em vez do plaintext. Confira: a app envia o que tá no .env.local no header — o container WAHA é quem tem o hash (em WAHA_API_KEY_SHA512). Refaça o passo 1 do WAHA.
Webhook do WAHA não chega
- O ngrok está rodando? (
ngrok http 3000) - A URL do ngrok atual está em
WAHA_WEBHOOK_BASE_URL? (muda a cada restart no plano free). - Você reiniciou o
pnpm devdepois de mudar o.env.local? Variáveis de ambiente são lidas no boot. - Confira logs do container:
docker logs deskcomm-waha.
Porta 3000 já em uso
Algum outro processo rodando. Mata com lsof -ti:3000 | xargs kill -9 ou roda o Next em outra porta: pnpm dev -- -p 3001 (e atualize WAHA_WEBHOOK_BASE_URL no ngrok pra apontar pra nova porta).
RESEND_API_KEY is undefined (mas o app sobe)
Esperado em dev se você ainda não configurou o Resend. Emails caem no console.log. Só configure se for testar fluxos de email (LGPD export, magic link).
Migrations não rodam
Confira se você está logado: supabase login — vai abrir o browser pra autorizar. Depois supabase link --project-ref <ref> de novo.
Esqueci a senha do banco do Supabase
Project Settings → Database → Reset database password. Lembrando que isso invalida conexões existentes.
Docker compose não sobe o WAHA
- Docker Desktop está rodando? Ícone na barra de menus.
- Em Mac M1/M2/M3, o
platform: linux/amd64no docker-compose pode dar warning — é normal, só roda mais devagar via emulação. Funciona.
Próximos passos
Com tudo verde no /api/v1/health:
- Crie usuários de teste rodando
pnpm tsx scripts/seed-e2e-credentials.ts— gera.e2e-creds.jsoncom admin/manager/agent. - Leia
README.mdpra fluxo de criar sessão WAHA + escanear QR. - Leia
CLAUDE.mdpra convenções do projeto. - Veja
tasks/todo.mdpra entender o backlog atual.
Bem-vindo ao DeskcommCRM. 🛠️
Achou um erro neste guia? Abra uma issue ou mande um PR — esse doc vive da contribuição da comunidade.