Files
DeskcommCRM/.env.example
T
automatikpg-uxandClaude Opus 5.5 66c48ba9eb fix(agente): a resposta desatualizada não sai quando o cliente escreve de novo durante o turno
O turno lê a conversa, leva 10–40 s no modelo e envia. Se a pergunta real do
cliente chegou nesse intervalo ("Oi" / "Boa tarde" / ... / a pergunta), a
resposta sai desatualizada ("Como posso te ajudar?") e o turno seguinte, que
viu a pergunta, responde de novo. Medido numa instalação: 232 de 589 respostas
(39%) a menos de 3 min de outra ao mesmo cliente.

`respostaFicouObsoleta` usa a anotação `ultima_inbound_vista_em` que o turno já
grava antes de ler a conversa: havendo inbound mais nova, o primeiro
`send_message` do turno é recusado com `resposta_obsoleta` e o job da mensagem
nova responde a tudo. Teto (RESPOSTA_OBSOLETA_TETO_MS, 120 s; 0 desliga) pela
mensagem mais antiga sem resposta, para cliente que escreve sem parar não
ficar sem resposta.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:18:05 -03:00

668 lines
38 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# DeskcommCRM — variáveis de ambiente
# =============================================================================
# Copie para .env.local e preencha. NUNCA commitar .env*.
# Variáveis NEXT_PUBLIC_* são expostas ao browser; o resto fica server-side.
# =============================================================================
# --- Supabase ----------------------------------------------------------------
# URL pública do projeto Supabase (ex: https://xxxx.supabase.co)
NEXT_PUBLIC_SUPABASE_URL=
# anon key (safe pra browser; RLS é o gatekeeper real)
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# service role key — BYPASSA RLS. Apenas server. Nunca commitar. Rotação trimestral.
SUPABASE_SERVICE_ROLE_KEY=
# Endereço do Supabase PARA O SERVIDOR (ex: http://kong:8000). OPCIONAL.
# Vazio (o padrão) = o servidor usa a NEXT_PUBLIC_SUPABASE_URL, como sempre.
# Preencha só quando o Supabase estiver na MESMA rede do app: aí as REQUISIÇÕES
# do servidor (Auth, REST, Storage) usam o caminho curto, que não precisa sair
# para a internet. O endereço interno NÃO substitui a pública: o navegador
# continua falando com ela (Auth, Realtime e Storage) e é dela que saem os
# links que o app entrega a terceiros — mídia, avatar, PDF da LGPD e o redirect
# do login com Google. Por isso o interno não pode entrar na NEXT_PUBLIC_*.
# Nada aqui é assado no build — vale em runtime, sem rebuild da imagem.
# O navegador NUNCA a lê. Valor que não for um endereço http(s) é recusado com
# aviso no log, e vale a pública.
SUPABASE_SERVER_URL=
# --- Cron / interno ----------------------------------------------------------
# Bearer secret pros endpoints /api/v1/cron/*. DIFERENTE do service role key.
INTERNAL_SECRET=
# Secret dedicado opcional pros endpoints de cron. Se vazio, cai pra INTERNAL_SECRET.
INTERNAL_CRON_SECRET=
# Provisionamento de organizações por um sistema externo
# (POST /api/v1/tenants/provision). DESLIGADO por padrão: vazio, a rota
# responde 404. Para ligar, gere um segredo de 32+ caracteres
# (`openssl rand -hex 32`) e entregue-o só ao sistema que vai criar empresas —
# com ele, esse sistema cria organizações nesta instalação sem passar pela tela.
TENANT_PROVISIONING_SECRET=
# CRON_SECRET não é chave deste arquivo: é o nome com que o Vercel Cron injeta o
# Bearer, e vale para quem hospeda um fork lá. Em produção, se ela existir,
# lib/env.ts copia o valor para INTERNAL_CRON_SECRET. Quem usa o serviço
# `scheduler` do docker-compose NÃO precisa dela: lá o Bearer é o INTERNAL_SECRET
# (docker/scheduler/entrypoint.sh).
# Relógio local (`pnpm dev:crons`). Só bate em NEXT_PUBLIC_APP_URL; não instala
# crontab. NÃO rode contra o mesmo Supabase da VPS — disputa a fila com o prod.
# DEV_CRON_INTERVAL_MS=15000
# --- Janela do retorno agendado ----------------------------------------------
# Quão cedo e quão tarde o agente pode marcar um retorno ao cliente. Lidas pelo
# WORKER (agent-engine) e pelo APP (a capacidade que o dono liga na tela) — as
# duas leituras têm de bater, senão o mesmo horário seria aceito num caminho e
# recusado no outro. Defaults em lib/followup/janela.ts; deixe vazio para usá-los.
# FOLLOWUP_MIN_AHEAD_MS=300000 # 5 min
# FOLLOWUP_MAX_AHEAD_MS=15552000000 # 180 dias
# CRON_STAGGER_WINDOW_MS=60000 # espalha disparos do mesmo minuto (0 desliga)
# --- Encryption keys (pgcrypto) ----------------------------------------------
# Chave separada pra criptografar CPF em contacts (PII sensível LGPD).
CPF_ENCRYPTION_KEY=
# Chave pra criptografar tokens OAuth Nuvemshop em tenant_integrations.
# Agenda · Google Calendar (BYO) — opcional.
# Sem as duas, a Agenda funciona inteira: some o botão "Conectar Google".
# Onde obter: console.cloud.google.com > APIs e Serviços > Credenciais >
# ID do cliente OAuth (tipo "Aplicativo da Web").
#
# ⚠️ REGISTRE TAMBÉM O ENDEREÇO DE RETORNO, em "URIs de redirecionamento
# autorizados": a URL do app MAIS `/api/v1/agenda/google/callback`. O Google
# compara byte a byte, e registrar só a origem é o palpite natural — ele recusa
# com `redirect_uri_mismatch`, que aponta para o Google e não para a divergência.
GOOGLE_CALENDAR_CLIENT_ID=
GOOGLE_CALENDAR_CLIENT_SECRET=
# Google Ads — reporta venda de volta pro anúncio que trouxe o lead (migration
# 0296). Developer token vem do Centro de API do Google Ads (conta de GERENTE);
# o Client ID/Secret vem de um projeto no Google Cloud Console com a "Google
# Ads API" ativada. Igual ao aviso da Agenda acima, registre o endereço de
# retorno EXATO em "URIs de redirecionamento autorizados":
# <URL do app> + /api/v1/plataformas-de-anuncio/google/callback
GOOGLE_ADS_DEVELOPER_TOKEN=
GOOGLE_ADS_OAUTH_CLIENT_ID=
GOOGLE_ADS_OAUTH_CLIENT_SECRET=
NUVEMSHOP_OAUTH_ENCRYPTION_KEY=
# Chave pra criptografar credenciais BYO-WAHA (cliente que roda WAHA próprio).
WAHA_BYO_ENCRYPTION_KEY=
# Chave AES-256 (32 bytes em base64) pra criptografar API keys de provedores LLM
# (ai_provider_credentials). Gerar: openssl rand -base64 32
AI_CRED_AES_KEY=
# --- WAHA --------------------------------------------------------------------
# URL base da instância WAHA (ex: http://localhost:3000 em dev; https://waha.deskcomm.com em prod)
WAHA_API_BASE_URL=http://localhost:3000
# Plaintext da API key do WAHA. O ENV do container WAHA recebe o hash SHA512 hex desta.
# Gerar: echo -n "minha-chave-aqui" | shasum -a 512
WAHA_API_KEY=
# Segredo com que o WAHA assina os webhooks (header X-Webhook-Hmac). O app usa
# para CONFERIR a assinatura; o container do WAHA recebe o mesmo valor.
WAHA_HMAC_SECRET=
# ─── Chamada de voz WhatsApp (WaCalls, spec 18) — DESLIGADA POR PADRÃO ───────
#
# Ligá-la vincula um SEGUNDO APARELHO ao mesmo número que já atende, por um
# caminho que não é o oficial: o risco é a CONTA ser bloqueada pelo WhatsApp,
# não só o aparelho. Por isso nada aqui vem preenchido, e cada organização
# ainda precisa aceitar o risco na tela (Configurações › Segurança).
#
# Em produção o serviço vive num profile do compose que nasce desligado; ligar
# é `COMPOSE_PROFILES=voz` no .env. Vazio aqui = a feature não aparece na tela
# (banner, nunca erro).
WACALLS_API_BASE_URL=
# Bearer que o app usa para falar com o serviço. O WaCalls autenticado NÃO tem
# modo aberto: sem token, a API só aceita o cookie de login do navegador, que um
# processo server-to-server não tem. URL sem token = 401 em toda chamada.
WACALLS_API_TOKEN=
# O contêiner precisa de DUAS coisas para o áudio sair da rede local — e sem
# elas a ligação toca, conecta e fica MUDA na VPS (o WaCalls anuncia como
# candidato o IP interno do contêiner, 172.x, que navegador nenhum alcança):
# WACALLS_PUBLIC_IP o IP público da VPS, anunciado como candidato ICE
# WACALLS_WEBRTC_UDP_PORT a porta UDP fixa (sem ela, portas efêmeras
# aleatórias, impossíveis de publicar no compose)
# As duas são do CONTÊINER, não do app — por isso vivem aqui e não em lib/env.ts.
WACALLS_PUBLIC_IP=
WACALLS_WEBRTC_UDP_PORT=7881
# ── TELEFONIA POR SIP (#677) — MÓDULO OPCIONAL, DESLIGADO POR PADRÃO ────────
#
# Atender e ligar por telefone de verdade (tronco SIP + IA de voz em tempo
# real). Em produção são dois contêineres (asterisk + voice-agent) no profile
# `telefonia` do docker-compose.prod.yml: sem o profile o compose nem os cria,
# e nada aqui liga a feature sozinho.
#
# Para ligar, na VPS:
# 1. copie asterisk/pjsip.conf.example e asterisk/ari.conf.example para os
# nomes sem `.example` e ponha as credenciais do seu provedor SIP e do ARI;
# 2. ponha `telefonia` em COMPOSE_PROFILES (junto de `voz`, se já usa a
# chamada de voz do WhatsApp: COMPOSE_PROFILES=voz,telefonia);
# 3. `docker compose -f docker-compose.prod.yml --env-file .env up -d`.
#
# As portas UDP 5060 (sinalização) e 10000-10200 (áudio) passam a ser
# publicadas — é o tronco do provedor que precisa alcançá-las. A ARI (8088)
# NÃO é publicada: ela controla as chamadas e fica só na rede interna.
COMPOSE_PROFILES=
# As credenciais do ARI, iguais às de asterisk/ari.conf. Lidas pelo contêiner
# do voice-agent, não pelo app — por isso vivem aqui e não em lib/env.ts.
ARI_USERNAME=
ARI_PASSWORD=
# Porta do AudioSocket, por onde o Asterisk manda o áudio ao voice-agent. Só na
# rede interna do compose; mudar aqui exige mudar asterisk/extensions.conf.
AUDIOSOCKET_PORT=8090
# O modelo de voz em tempo real. Vazio = o default do worker.
OPENAI_REALTIME_MODEL=
# "true" exige assinatura válida em TODO webhook do WAHA. Desligado por padrão
# porque o WAHA Core não assina — exigir derrubaria a ingestão de mensagens.
# Ligue se roda WAHA Plus (ou um proxy que assine).
#
# O BANCO ESTÁ ACIMA DESTA LINHA. A tela /admin/sistema › Comportamento
# ("Exigir assinatura nas entregas do canal") grava a escolha em
# `platform_settings`, e é ela que vale a partir daí — sem reiniciar o app.
# Esta variável é o PISO: ela responde quando a instalação nunca abriu a tela e
# quando o app subiu e ainda não conseguiu ler o banco. Mudou AQUI? reinicie o
# app. Ver lib/instalacao/comportamento.ts e lib/waha/webhook-auth.ts.
WAHA_WEBHOOK_REQUIRE_SIGNATURE=false
# URL pública do app que o WAHA chama em webhooks (precisa ser HTTPS público; use ngrok em dev).
WAHA_WEBHOOK_BASE_URL=
# Retomar as sessões já pareadas quando o contêiner do WAHA reinicia. É lido
# pelo WAHA, não pelo app (por isso não está em lib/env.ts). O default do WAHA é
# False: o volume guarda a credencial, mas a sessão não sobe, e o número fica
# "conectado" no disco e mudo na prática até alguém abrir o front e clicar
# Reconectar. Só mexa aqui se quiser o comportamento antigo de propósito.
WHATSAPP_RESTART_ALL_SESSIONS=True
# --- OpenRouter: atribuição OPCIONAL ----------------------------------------
# `HTTP-Referer` e `X-Title` que a OpenRouter usa para atribuir o consumo e
# montar o ranking público do site dela. São OPCIONAIS — a doc deles diz
# "optional headers to identify your app", e a chamada funciona sem. Vazios,
# nenhum header é enviado. Preencha com o SEU domínio e o SEU nome se quiser
# que o consumo desta instalação apareça atribuído a você.
OPENROUTER_APP_URL=
OPENROUTER_APP_TITLE=
# --- Upstash Redis (rate limit, idempotency) ---------------------------------
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
# --- AI providers ------------------------------------------------------------
# Vercel AI Gateway (preferencial — fallback de provedor + observability + ZDR)
# AI_GATEWAY_API_KEY: chave do gateway. Quando ausente, ai-response-worker
# pula com skip="ai_gateway_key_missing" (não bloqueia boot do app).
AI_GATEWAY_API_KEY=
AI_GATEWAY_BASE_URL=
VERCEL_AI_GATEWAY_URL=
# Fallback direto Anthropic (caso AI Gateway indisponível ou desabilitado pra um tenant)
ANTHROPIC_API_KEY=
# OpenRouter (OPCIONAL) — alternativa ao gateway da Vercel, compatível com a API
# da OpenAI, com catálogo grande de modelos num único faturamento.
# Ordem de resolução do chat: AI_GATEWAY_API_KEY > OPENROUTER_API_KEY > provider
# direto (ANTHROPIC_API_KEY / OPENAI_API_KEY). Vazio = nada muda.
#
# ALCANCE — leia antes de trocar. Esta chave alcança a classificação de
# sentimento, o bot de resposta antigo E, desde que a organização esteja em
# OpenRouter, o AGENTE do CRM: quando não há credencial cadastrada na tela, o
# agente cai nesta chave como credencial de plataforma. É o caso normal de quem
# instalou pelo kit escolhendo OpenRouter e nunca abriu a tela de Provedores.
#
# O que ela NÃO faz é ESCOLHER o provedor. Quem escolhe é a instalação
# (`AI_PROVIDER`, gravado em settings.llm.provider) ou a tela Agente de IA →
# Provedores. Preencher a chave aqui com a organização em Anthropic não migra
# nada; migrar é trocar o provedor, e aí esta chave passa a ser a que paga.
#
# ATENÇÃO ao escolher o modelo do agente na tela: ele opera por FERRAMENTAS
# (contatos, leads, funil, catálogo, transferência para humano). Modelo sem
# suporte a ferramentas NÃO dá erro — responde um texto plausível ao cliente e
# nunca cria o lead nem move o card. Por isso o painel RECUSA salvar um modelo
# sem ferramentas nos pontos "Responder o cliente" e "Trabalhar o funil", usando
# o que o próprio fabricante declara no catálogo.
# O invariante que segura estas afirmações: tests/unit/openrouter-alcance.test.ts
OPENROUTER_API_KEY=
OPENROUTER_BASE_URL= # vazio = https://openrouter.ai/api/v1
# Para embeddings do RAG, OPENROUTER_API_KEY acima também serve quando não há
# chave OpenAI da organização ou gateway configurado. A busca e a indexação usam
# o mesmo text-embedding-3-small; a chave OpenAI segue necessária para transcrever áudio.
# OPCIONAL: é o ÚLTIMO degrau OpenAI. Quem preferir pode cadastrar a chave PELA TELA, em
# IA › Credenciais (ou no próprio passo de cadastrar material) — vale por organização,
# fica cifrada no banco e dispensa mexer neste arquivo.
OPENAI_API_KEY=
# Raciocínio dos modelos da OpenAI (gpt-5.x/gpt-6): none | minimal | low | medium | high | xhigh.
# Vazio = padrão do modelo. `none` deixa o agente bem mais rápido. Só vale para modelos o*, gpt-5* e gpt-6*;
# os demais não recebem o campo. Grafia errada impede o worker de subir.
OPENAI_REASONING_EFFORT=
# --- Transcrição de áudio por outro serviço compatível ------------------------
# Sem nada aqui, a transcrição usa a chave da OpenAI acima — é o de sempre.
# Com TRANSCRIPTION_API_KEY, o worker de mídia passa a transcrever no serviço
# que você apontar (Groq, Whisper próprio, qualquer endpoint /audio/transcriptions
# compatível com a OpenAI). BASE_URL e MODEL são opcionais: sem eles vale o
# padrão do provedor da chave. Só a transcrição muda; a descrição de imagem
# continua saindo pelo binding de visão do ponto.
TRANSCRIPTION_API_KEY=
TRANSCRIPTION_BASE_URL= # ex.: https://api.groq.com/openai/v1
TRANSCRIPTION_MODEL= # ex.: whisper-large-v3
# MODEL e LANGUAGES valem também SEM TRANSCRIPTION_API_KEY, com a chave da OpenAI
# da organização (o MODEL, só com TRANSCRIPTION_BASE_URL vazio: um modelo de
# outro serviço não é pedido à OpenAI, e segue o whisper-1). Para áudio em espanhol ou português, `gpt-transcribe` com o
# idioma declarado alucina bem menos que o `whisper-1` padrão (medido: um áudio
# sem fala virava "Thanks for watching!"; com o idioma, sai vazio).
# Idiomas esperados, ISO-639-1 separados por vírgula. Vazio = detecção automática.
TRANSCRIPTION_LANGUAGES= # ex.: es ou pt,es
# --- Jev (TypeSafe AI) --------------------------------------------------------
# A chave do Jev é cadastrada pela tela, por empresa. Esta linha só muda o
# ENDEREÇO da API, e só os testes de ponta a ponta precisam disso.
JEV_API_BASE_URL= # vazio = https://api.typesafe.ai
# --- Destinos internos autorizados pelo dono da instalação --------------------
# A saída para a rede de dentro é recusada por padrão: localhost, 127., 10.,
# 192.168., 169.254. e 172.16/12 não são alcançáveis, nem pelo texto da URL nem
# pelo IP que o nome resolve. Quem administra a instalação libera endereços
# internos na tela Administração › Destinos internos — e ESSA tela é a fonte.
# Esta chave é só o PISO: vale enquanto a tela nunca foi usada.
# Vale só para destinos que a INSTALAÇÃO configura (ex.: TRANSCRIPTION_BASE_URL);
# o endereço que uma empresa escolhe no painel dela continua sem poder apontar
# para dentro. Vírgula separa; cada entrada é um IPv4 ou uma faixa CIDR IPv4:
# IA_DESTINOS_INTERNOS_PERMITIDOS=10.1.2.7,10.1.0.0/16
# Nome não entra: o que se compara é o IP que o nome resolve. Entrada fora do
# formato é ignorada — e ignorar é recusar. Vazio = nada passa.
IA_DESTINOS_INTERNOS_PERMITIDOS=
# --- Teto de gasto de IA: a alavanca de emergência ----------------------------
# Quem escolhe o teto é cada organização, na tela Uso de IA › Orçamento (teto,
# aviso, e se a IA deve PARAR ao chegar nele). Esta chave não escolhe nada: ela
# só sabe AFROUXAR o que já foi escolhido.
# on (default) respeite o que cada organização escolheu
# avisar rebaixa qualquer "parar a IA" para apenas avisar
# off desliga a proteção nesta instalação inteira
# Também valem, como off: false, 0, no, nao, não, disabled. Valor não
# reconhecido cai em `on` — de propósito: a alavanca de emergência NUNCA pode
# derrubar o app por um typo. Mudou AQUI? reinicie o app e o worker (a tela não
# pede reinício).
#
# O BANCO ESTÁ ACIMA DESTA LINHA. A tela /admin/sistema › Comportamento
# ("Proteção de gasto de IA") grava a escolha em `platform_settings`, e é ela
# que vale a partir daí. Esta variável é o PISO: ela responde quando a
# instalação nunca abriu a tela e quando o app subiu e ainda não conseguiu ler o
# banco — a janela em que uma instalação desprotegida de propósito voltaria a
# bloquear sozinha. Ver lib/instalacao/comportamento.ts.
AI_BUDGET_ENFORCEMENT=on
# ── Comportamento da instalação que o MOTOR lê (o banco está acima) ──────────
# As duas chaves abaixo têm casa na mesma tela — /admin/sistema › Comportamento
# — e o `.env` é o PISO delas, no mesmo molde do SIGNUP_MODE: a escolha salva na
# tela vive em `platform_settings` e vence sem reiniciar nada; o que está aqui
# responde quando a instalação nunca abriu a tela e quando o processo subiu e
# ainda não conseguiu ler o banco. Vazio = o padrão do produto, que é o de
# sempre (`inject` e `true`).
#
# `inject` (padrão) acrescenta o texto de divulgação de pagamento à primeira
# mensagem; `veto` bloqueia o envio sem ele e devolve ao modelo a razão, para
# ele reescrever. Vazio ou valor não reconhecido = `inject`.
DISCLOSURE_MODE=
# "true" (padrão) faz CADA envio passar por uma conferência de modelo antes de
# sair, para não prometer o que a empresa não cumpre — custa uma chamada de
# modelo por envio. Vazio = `true`; `false` desliga.
PROMISE_SEMANTIC_ENABLED=
# --- Workers -----------------------------------------------------------------
# Ritmo com que o WORKER roda os handlers do event_log (mídia, branding,
# follow-up…). Antes destes knobs os handlers só rodavam pelo cron
# `event-log-drain`, 1×/min — e a cadeia persist→derive de um áudio levava
# 103-188s contra os 45s que o turno espera pela transcrição. O cron continua
# ligado como rede de segurança. Vazio = os defaults abaixo.
EVENT_LOG_DRAIN_INTERVAL_MS=2000
EVENT_LOG_DRAIN_IDLE_INTERVAL_MS=10000
EVENT_LOG_DRAIN_BATCH_SIZE=50
# --- Elegibilidade da IA por origem do lead ----------------------------------
# Vale SÓ nos canais em que você ligou o modo "só atende quem eu autorizei"
# (Configurações do canal › ai_gate = allowlist). Nesses canais, um contato fica
# elegível para a IA quando vem de uma origem conhecida (formulário do Respondi,
# campanha, liberação manual). Este número é por quantos DIAS essa autorização
# vale — depois disso, uma submissão antiga não reativa a IA sozinha. Uma
# conversa que segue viva renova o prazo a cada turno. Vazio = 21 dias.
AI_ALLOWLIST_TTL_DAYS=21
# Stub do endpoint de teste do agente: 'true' devolve trace fabricado em vez de
# executar o agente. Deixe false — o runtime real já existe. Só ligue para
# exercitar o render da UI sem gastar token.
INTERNAL_AGENT_RUN_STUB=false
# --- Sentry ------------------------------------------------------------------
SENTRY_DSN=
# --- E-mail transacional (Resend) ---------------------------------------------
# Convite de time, entrega de export LGPD e alarme de SLA saem por aqui.
#
# RESEND_FROM_EMAIL precisa ser um endereço de um domínio VERIFICADO na SUA
# conta Resend. Vazio = e-mail desligado (o convite mostra o link de aceite na
# tela e o export de LGPD fica em `pending_review`), e isso é de propósito:
# herdar um domínio que não é seu faz todo envio falhar lá na Resend, com uma
# mensagem que não diz que o problema é configuração.
RESEND_API_KEY=
RESEND_FROM_EMAIL=
# --- E-mail transacional (SMTP) -----------------------------------------------
# O SEGUNDO caminho de e-mail, ao lado da Resend acima — não no lugar dela.
# Preenchido, o envio sai pelo SEU servidor; vazio, continua pela Resend. Quem
# já roda com Resend não precisa tocar em nada aqui.
#
# A tela /admin/email grava os mesmos campos no banco, e o BANCO PREVALECE
# sobre estas variáveis: elas servem para provisionar uma VPS sem abrir a
# interface, e ficam como piso de rollback.
#
# Use somente o hostname, sem smtp:// e sem :porta.
# 465 = TLS implícito (SMTP_SECURITY=tls); 587 = STARTTLS.
# Host ou remetente vazio mantém o convite como link copiável na tela e o
# export de LGPD em revisão pendente, em vez de tentar entregar um e-mail
# incompleto.
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURITY=starttls
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=
# --- Impersonate / suporte ----------------------------------------------------
# Segredo HMAC do cookie de impersonate. Mínimo 32 chars quando a feature é usada.
IMPERSONATE_COOKIE_SECRET=
# Endereço de suporte mostrado ao CLIENTE FINAL (conta suspensa, cobrança).
# Quem instala para terceiros põe o próprio endereço aqui. Vazio = a tela não
# mostra endereço nenhum, e nunca cai num endereço do produto.
SUPPORT_EMAIL=
# --- LGPD ---------------------------------------------------------------------
# Chave usada para assinar exports de dados LGPD.
LGPD_SIGNING_KEY=
# E-mail do Encarregado (DPO). Impresso no rodapé do relatório entregue ao
# titular (`lib/lgpd/pdf-renderer.tsx`) quando a organização não tem
# `dpo_email` própria, e é o destinatário do alarme de SLA.
LGPD_DPO_EMAIL=
# Prazo, em horas, antes de um export LGPD expirar.
LGPD_EXPORT_EXPIRES_HOURS=72
# --- Nuvemshop OAuth ---------------------------------------------------------
# Obtenha em https://partners.tiendanube.com/ (criar app → copiar credenciais).
# Deixe vazio em dev/staging se ainda não tiver app aprovado — a integração
# degrada graciosamente e a UI mostra "Integração não configurada".
# Liga a integração Nuvemshop. Quando true, exige as credenciais abaixo.
NUVEMSHOP_ENABLED=false
# APP_ID aparece na URL do painel do app no portal de parceiros.
NUVEMSHOP_APP_ID=
NUVEMSHOP_CLIENT_ID=
NUVEMSHOP_CLIENT_SECRET=
# Para testar webhooks em dev exponha o app via ngrok/cloudflared (HTTPS) e
# aponte NEXT_PUBLIC_APP_URL para o túnel antes de clicar "Conectar".
# --- App URLs ----------------------------------------------------------------
# URL canônica do app (tenant). Ex: https://app.deskcomm.com
NEXT_PUBLIC_APP_URL=http://localhost:3000
# URL canônica do super-admin. Ex: https://admin.deskcomm.com
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000
# =============================================================================
# AGENT ENGINE (fusão Vendaval) — worker 24/7 do agente SDR
# =============================================================================
# Connection string do Postgres do Supabase (Settings → Database). O worker usa
# Postgres direto (fila FOR UPDATE SKIP LOCKED, locks, FTS). Recomendado: role
# dedicada com login (ex.: agent_worker) — nunca a service_role key aqui.
SUPABASE_DB_URL=
# A conexão que MEXE NO SCHEMA — e SÓ o kit (install.sh/update.sh/backup.sh) a
# usa; nenhum código do app lê esta chave. OPCIONAL: vazia, tudo roda pela de
# cima, que é o caso da nuvem (a string do pooler já vem privilegiada).
# Preencha quando o banco for um Supabase PRÓPRIO e a de cima for a role menor:
# `create extension`, o `baseline.sql` e a promoção do dono exigem o dono do
# banco. Ver docs/deploy-selfhost/README.md §2.
SUPABASE_DB_ADMIN_URL=
# --- Ritmo da fila do worker (custo de banco) --------------------------------
# Os dois governam quanto o worker conversa com o Postgres quando NÃO há trabalho
# — a conta que estourou a cota de egress do plano free do Supabase na issue #258.
# Ambos são opcionais: sem eles valem os defaults abaixo, que são o comportamento
# recomendado. Como ver o que está em vigor: a linha `agent-engine pronto` no log
# do worker imprime os dois. Quando mexer e o que medir:
# docs/runbooks/custo-e-cota-do-supabase.md
#
# TETO de espera do laço da fila. Com a fila vazia, é quanto o worker dorme entre
# uma consulta e a próxima; com job agendado, ele acorda no vencimento e este
# valor só limita a soneca. Não passe de 10000: acima disso a conexão ociosa
# expira entre as rodadas e cada consulta volta a pagar TCP+TLS — gasta MAIS.
#QUEUE_POLL_INTERVAL_MS=2000
# Ritmo do "havia trabalho e eu não peguei" (todas as vagas de
# QUEUE_MAX_CONCURRENCY ocupadas, ou outro turno rodando para o mesmo contato).
# Aqui há job vencido esperando vaga, então este valor é curto de propósito:
# aumentá-lo não economiza nada no ocioso e custa throughput no pico.
#QUEUE_CLAIM_RETRY_INTERVAL_MS=250
# Resposta obsoleta: se o cliente escreve de novo enquanto o agente ainda está
# compondo a resposta, a resposta desatualizada não sai e o turno seguinte (que
# lê a conversa inteira) responde a tudo de uma vez. Vale enquanto a mensagem
# mais antiga sem resposta tiver menos que este tempo; passado ele, a resposta
# sai mesmo assim (cliente que escreve sem parar não fica sem resposta). 0 desliga.
#RESPOSTA_OBSOLETA_TETO_MS=120000
# ── Retenção do histórico (poda diária, cron `data-retention`) ───────────────
# Espaço em disco é uma cota diferente da de tráfego: o plano free do Supabase
# limita 500 MB de BANCO, e `job_queue` + `api_audit_log` são as duas que crescem
# sozinhas. Os dois valores abaixo JÁ SÃO os defaults do código: apagar as linhas
# de um `.env` existente não muda nada, e quem atualiza sem tocar no arquivo
# recebe exatamente este comportamento. Quanto cada tabela ocupa hoje e o que
# fazer com o número: docs/runbooks/custo-e-cota-do-supabase.md
#
# Idade a partir da qual um job TERMINAL (done/failed/dead) é apagado. Job
# `pending`/`running` NUNCA é tocado — tem trabalho dentro. Apagar um job leva
# junto o `send_ledger` e o `before_send_traces` daquele run. Piso de 7 dias,
# aplicado dentro da função do banco (valor menor é elevado, com aviso no log).
JOB_QUEUE_RETENTION_DAYS=90
# Idade a partir da qual uma linha de auditoria é expurgada. O default é a
# retenção de 5 anos da regra L-10. Piso de 90 dias: nem com a chave de serviço
# esta poda alcança rastro recente. Diminuir aqui é a alavanca de quem está
# apertado de espaço e aceita guardar menos histórico.
AUDIT_LOG_RETENTION_DAYS=1825
# Idade a partir da qual a conversa da equipe com a IA sobre um caso é apagada.
# Um ano fiscal: depois disso, "por que decidimos assim" é respondido pelos
# eventos do caso, não pela deliberação. Piso de 90 dias, aplicado dentro da
# função do banco (valor menor é elevado, com aviso no log).
CASE_CHAT_RETENTION_DAYS=365
# Idade a partir da qual o registro de uma passagem do atendimento para uma
# pessoa é apagado. Cinco anos, o mesmo da auditoria: é o rastro de quem assumiu
# a conversa de quem, quando e por quê. Piso de 90 dias, aplicado dentro da
# função do banco — e passagem que NINGUÉM reconheceu nunca é apagada, em
# nenhuma idade: ela é uma pessoa ainda esperando resposta.
PASSAGEM_RETENTION_DAYS=1825
# Idade a partir da qual o registro de entrega do aviso de caso no WhatsApp da
# equipe é apagado. Seis meses: a única pergunta que essa linha responde — "o
# aviso daquele caso saiu?" — é de semanas. A linha não guarda o texto do aviso,
# só um resumo criptográfico dele. Piso de 30 dias, aplicado dentro da função do
# banco (valor menor é elevado, com aviso no log).
CASE_ALERT_RETENTION_DAYS=180
# Idade a partir da qual o candidato da prospecção nativa é expurgado. Um ano,
# alinhado ao horizonte da conversa do caso e da captação — decisão do dono
# (issue #1313). Piso de 90 dias, aplicado dentro da função do banco. O
# relógio conta da criação para quem nunca foi contatado e da última tentativa
# para quem já recebeu mensagem; `queued`/`sending` nunca entram, e os tokens
# de supressão de quem pediu exclusão são preservados para sempre.
PROSPECCAO_RETENTION_DAYS=365
# Idade a partir da qual a observação do Jev (o rótulo dele ao lado do da sua IA
# de sempre, sem texto de cliente) é apagada. Três meses: ela só responde "posso
# deixar o Jev decidir esta tarefa?". Piso de 30 dias — a janela da concordância
# no cartão —, aplicado dentro da função do banco.
JEV_OBSERVACOES_RETENTION_DAYS=90
# Idade a partir da qual um RASCUNHO sugerido por integração já VENCIDO é
# apagado (conversation_drafts). O relógio conta do `expires_at`, não da
# criação: depois do vencimento o link não abre e o consumo é recusado, e o
# texto é proposta que ninguém enviou. Trinta dias é o prazo de apurar "o link
# chegou?"; depois disso a trilha de auditoria responde sem guardar o texto.
# Piso de 7 dias (valor menor é elevado, com aviso no log).
DRAFT_RETENTION_DAYS=30
# Idade a partir da qual um candidato ao golden set (near-miss de skill e
# divergência classificador×modelo — rótulo, sem texto de cliente) é apagado.
# Três meses: a pergunta que a linha responde é de curadoria recente. Piso de 30
# dias, aplicado dentro da função do banco.
GOLDEN_CANDIDATES_RETENTION_DAYS=90
# Chave LLM de plataforma (fallback quando a org não tem credencial BYOK
# cadastrada em /app/ai/credentials). Opcional com BYOK.
ANTHROPIC_API_KEY=
# Dono ÚNICO dos eventos de despacho do agente. 'engine' (default) = o worker
# consome; 'native' = o dispatcher interno do app consome (deploy sem worker).
# NUNCA os dois — dois consumidores = turno duplicado ou perdido.
AGENT_DISPATCH_CONSUMER=engine
# Watchdog de sessão: reconcilia channel_sessions × WAHA e reenvia mensagens
# presas em queued. Usa WAHA_API_BASE_URL/WAHA_API_KEY acima (no compose o
# worker já recebe http://waha:3000). Knobs opcionais:
#WATCHDOG_INTERVAL_MS=60000
#WATCHDOG_REDRIVE_BATCH_SIZE=10
# Flywheel agendado (judge → proposta de melhoria; promoção SEMPRE manual na
# tela de Agentes IA). 0 = desligado; default 6h.
#FLYWHEEL_INTERVAL_MS=21600000
#FLYWHEEL_BATCH_LIMIT=10
# ── Marca da instalação (white-label) ───────────────────────────────────────
# Quem instala o CRM para clientes (agência, revendedor) troca a marca aqui, sem
# editar código — patch em código se perde no próximo update. Vazio = padrão.
# APP_LOGO_URL aceita qualquer URL pública de imagem; sem ela, o nome aparece
# como texto. Ver lib/branding.ts.
APP_NAME=
APP_LOGO_URL=
# Cor da marca, em hex (ex.: #506d48). Dela sai a rampa de 11 tons que pinta o
# produto inteiro — botão, anel de foco, chip, hover — nos dois temas. Vazio = a
# cor do produto. Cor sem contraste suficiente é DESLOCADA em vez de recusada, e
# marca acromática (cinza/preto/branco) não vira accent: em ambos os casos o
# motivo fica registrado. Ver lib/branding/contraste.ts.
APP_ACCENT_HEX=
# ── Quem pode criar conta nesta instalação ──────────────────────────────────
# `aberto` (padrão, e o comportamento de sempre), `com_aprovacao` (a conta é
# criada, mas a empresa só nasce quando quem administra a instalação aprova) ou
# `so_convite`. Vazio = `aberto`: nada muda para quem não mexer aqui.
#
# O BANCO ESTÁ ACIMA DESTA LINHA. Assim que alguém usa a tela em
# /admin/cadastro, a escolha passa a viver em `platform_settings` e é ela que
# vale. Esta variável é o PISO: ela responde quando a instalação nunca abriu a
# tela, e quando o app subiu e ainda não conseguiu ler o banco — que é a razão
# de ela existir, porque sem um piso declarado uma instalação fechada de
# propósito abriria nessa janela.
#
# Fechado, /signup recusa com tela (marca e idioma da instalação) quem chega
# sem convite; quem chega COM convite válido entra igual. Ver
# lib/auth/politica-de-cadastro.ts.
SIGNUP_MODE=
# ── Idioma com que a instalação NASCE ───────────────────────────────────────
# `pt-BR` (padrão) ou `es`. Quem pergunta é o install.sh; quem grava é ele
# mesmo (ou o scripts/bootstrap-owner.ts, no caminho local), em
# `organizations.locale` E na preferência do usuário dono.
#
# ⚠️ NÃO é lido pelo app em runtime, e por isso não está em `lib/env.ts`: mudar
# esta linha depois da instalação não muda a interface de ninguém. Trocar o
# idioma de quem já está instalado é pela TELA — no seletor do topo (só para
# você) ou em Configurações › Organização (para quem entrar sem preferência).
APP_LOCALE=pt-BR
# --- WhatsApp Cloud API (Meta) — Fase 3a/3b do seam de canais ---
# Opcionais: sem elas o canal oficial simplesmente não é configurado, e o WAHA
# segue funcionando. Nada aqui adiciona container ao compose — o servidor é a
# nuvem da Meta.
META_APP_ID=
# App Secret: chave do HMAC SHA-256 que valida os webhooks (X-Hub-Signature-256).
# RESERVA, desde a migration 0257: o caminho normal é a tela Admin › API Oficial
# (Meta), que guarda a chave cifrada no banco e gera o verify token. O banco
# vence o .env; o par daqui só vale enquanto a tela não tiver um par completo.
META_APP_SECRET=
META_WABA_ID=
META_PHONE_NUMBER_ID=
META_SYSTEM_USER_TOKEN=
# Token que a Meta devolve no handshake (hub.verify_token) para provar que o
# endpoint é seu. RESERVA, como o App Secret acima: pela tela Admin › API Oficial
# (Meta) o servidor o gera sozinho. Só preencha aqui se não for usar a tela — e
# nunca com um valor adivinhável. Sem token nenhum, o GET de verificação responde 403.
META_WEBHOOK_VERIFY_TOKEN=
# URL pública opcional SÓ para o callback da Meta (/api/v1/webhooks/meta/*): separa
# esse endereço do painel em NEXT_PUBLIC_APP_URL (ex.: painel sob VPN/rede interna).
# A Zernio e o canal parceiro continuam usando NEXT_PUBLIC_APP_URL. Vazia = como antes.
META_WEBHOOK_BASE_URL=
# Versão da Graph API. Explícita de propósito: bump é decisão, não deriva.
META_GRAPH_VERSION=v22.0
# Base (host) da Graph API do CANAL OFICIAL. Vazia = o host real da Meta
# (https://graph.facebook.com). Só para apontar a instalação para outro lugar:
# um receptor local para a prova em tela, ou um homólogo do fornecedor.
#
# Precisa ser uma base ABSOLUTA http:// ou https://; qualquer outra coisa (caminho
# relativo, ftp://, file://) é recusada e a instalação volta a falar com o host
# real, com aviso no log. Barra no fim é aparada.
#
# Não é campo de tela nem valor por organização: destino de chamada é decisão da
# INSTALAÇÃO, e deixá-lo por organização mandaria o token de um tenant por um host
# que o outro escolheu.
META_GRAPH_BASE_URL=
# Mesma coisa para o EIXO DE ANÚNCIO (conversões e métricas da conta de anúncios),
# que é credencial e ciclo de vida diferentes do canal — por isso a variável é
# própria. Apontar o canal para o receptor de prova não pode levar junto o
# relatório de venda.
META_ADS_GRAPH_BASE_URL=
# --- Canal via intermediário (BSP) — opcional ---
# Deixe em branco se você não usa este canal; nada quebra sem estas três.
#
# As DUAS de baixo andam juntas: o adapter só se considera configurado com as
# duas preenchidas. Preencher só uma não é meio-caminho — é o estado que fazia a
# mensagem ser marcada como enviada sem sair, e por isso a checagem agora exige
# o par. A credencial também pode viver na SESSÃO (cifrada), e aí estas ficam
# vazias.
ZERNIO_ACCOUNT_ID=
ZERNIO_API_KEY=
# Só para apontar para homologação. Vazio usa a produção do provedor.
ZERNIO_API_BASE_URL=
# --- Canal Datafy (WhatsApp oficial por parceiro homologado) — DESLIGADO POR PADRÃO ---
# Canal OPCIONAL da instalação. Vazio = ele não existe aqui: nenhuma aba em
# Conexões, a rota de conexão responde 404 e o webhook recusa entregas. Para
# ligar, ponha `true` e reinicie o app; cada empresa conecta o SEU número pela
# tela (Conexões), colando o token do provedor e o segredo de assinatura do
# webhook — nada de credencial aqui.
DATAFY_ENABLED=
# Só para apontar para homologação. Vazio usa a produção do provedor.
DATAFY_API_BASE_URL=
# --- Teto de login por IP (SÓ para CI de e2e) ---
# NÃO defina isto em produção nem numa VPS. O default (60 tentativas por IP a
# cada 5 min) é o valor correto para uso real e já é folgado por causa de NAT.
#
# Existe porque no CI todos os testes saem de UM IP (o runner): 28 specs de e2e
# estouram o teto e o login passa a responder "Muitas tentativas", reprovando
# specs que não têm nada a ver com autenticação.
#
# O teto por CONTA (5 falhas na mesma conta em 5 min) NÃO é configurável de
# propósito — é ele que barra brute force, inclusive distribuído por vários IPs.
# Valor inválido ou ausente cai no default: a falha é fechada.
# AUTH_RATE_LIMIT_LOGIN_IP=1000
# Retenção do arquivo do corpo cru dos webhooks (webhook_events_log).
# 7 dias mantém o banco dentro dos 500 MB do plano gratuito do Supabase; o
# arquivo era 86% do banco numa instalação real, crescendo ~23 MB/dia.
WEBHOOK_LOG_BODY_RETENTION_DAYS=7
# Quando a LINHA some (não só o corpo). Até aqui ela custa ~200 B e ainda
# responde quantos eventos chegaram, de que tipo e se a assinatura conferia.
WEBHOOK_LOG_ROW_RETENTION_DAYS=90
# --- Web Push (bandeja do SO com a aba fechada) ------------------------------
# Sem estas chaves, alertas só disparam com o site aberto (outra aba/minimizado).
# Gerar: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# Retenção do HISTÓRICO de leads captados (webhook_lead_captures) — a aba
# "Leads recebidos". Bem mais longa que a do arquivo acima porque aqui a linha
# É o produto: é ela que responde de qual campanha veio o cliente que fechou.
# ~1 kB por formulário preenchido, então 300/dia por um ano dão ~110 MB.
# O código que apaga aplica um piso de 30 dias — número menor não desarma a
# evidência de origem de um contato.
LEAD_CAPTURE_RETENTION_DAYS=365
# Somente laboratório local: origem HTTP exata do catálogo em 127.0.0.1.
# O app também precisa estar em loopback; vazio exige origem HTTPS pública.
EXTENSIONS_LOCAL_CATALOG_ORIGIN=